featured.svg

前陣子在整理抽屜時,翻出了退役已久的 Google Pixel 3。這款 2018 年推出的手機雖然早已停止系統版本更新,但硬體功能依然正常。閒置在抽屜裡有些可惜。

恰巧最近在我的 Omarchy Linux 桌面環境 上,日常遠端會議(Google Meet、Discord)與語音輸入的需求增加——特別是 Omarchy 整合的 voxtype(預設長按 F9 進行 push-to-talk,亦可透過 Super + Ctrl + X 或在 ~/.config/hypr/bindings.lua 自訂綁定至 Meh 鍵組合的 Ctrl + Alt + Shift + V 切換,呼叫本地 Whisper 模型進行語音辨識)。桌上型電腦通常沒有內建麥克風,若不想常態配戴耳機,就得外接一支獨立麥克風佔用桌面空間。

Pixel 系列內建多麥克風硬體矩陣與聲學降噪調校,收音品質比許多常見的視訊鏡頭或平價外接麥克風更好。如果能把這台 Pixel 3 作為電腦的專屬桌上型麥克風使用,會是很實用的硬體重用方式。

市面上現成的 Android 麥克風方案(例如透過區域網路 WebRTC 或特定第三方應用程式)大多需要額外開啟瀏覽器分頁,且延遲相對不穩定。我希望的運作方式是:

  • 隨插即用,支援 USB 與無線 ADB。
  • 直接註冊為 Linux 系統層級的 PipeWire / PulseAudio 音訊輸入裝置,名稱清楚顯示為 Pixel 3 Microphone。
  • 整合進 Omarchy 桌面——在 Quickshell 狀態列提供常駐圖示,點擊展開即時選單,支援熱插拔與斷線自動重連,且具備全鍵盤操控能力。

最初我使用了一包 Bash 腳本搭配 Python(pysocat + connect.py)進行概念驗證。雖然功能可行,但在設備探測與狀態監聽時反覆啟動 Python 直譯器會帶來不必要的冷啟動開銷,且在行程異常中斷時的資源清理也較難保證。

因此,我將桌面端串流核心改寫為 Rust,整合成單一二進位程式的 Pocket Mic(patrick.pocketmic)插件。本文記錄這套音訊串流的架構設計、從腳本走向 Rust 的重構考量、Quickshell 雙重插件模型,以及在 Linux 音訊子系統中的實作細節。

端到端音訊傳輸架構

要將 Android 手機上的麥克風訊號低延遲轉送至 Linux 桌面,並被系統各個應用程式識別為標準錄音設備,資料管線需要跨越 Android 系統、ADB 橋接層、Linux IPC 與 PipeWire 音訊核心:

audiosource-pipeline.svg

整個流程在底層依序由四個核心階段組成:

Android 端:原始 PCM 音訊擷取

手機端執行開源輕量級 App fr.dzx.audiosource。App 在前景啟動錄音服務,透過 Android 原生 AudioRecord API 擷取 44.1 kHz、16-bit PCM 單聲道(mono)音訊。擷取後的未壓縮 PCM 資料流不經過額外編碼(減少運算負擔與編解碼延遲),直接寫入手機本地的抽象 Unix domain socket(localabstract:audiosource)。

Linux 桌面音訊核心:PipeWire / PulseAudio 虛擬音訊源建立

現代 Linux 發行版大多採用 PipeWire 作為音訊伺服器,並提供 PulseAudio 相容介面。在開始串流前,pocket-mic 會先呼叫 pactl 動態載入 module-pipe-source:

pactl load-module module-pipe-source \
    source_name="android-89ay04l" \
    channels=1 \
    format=s16 \
    rate=44100 \
    file="/tmp/pocket-mic-1234-5678/audio" \
    source_properties="device.description=\"Pixel 3 Microphone\""

值得注意的是:正是 module-pipe-source 負責在指定路徑建立具名管線 (FIFO),並同時在系統中註冊虛擬錄音裝置 Pixel 3 Microphone。

串流轉送與低延遲橋接:Rust pocket-mic 核心

接著,pocket-mic 建立並接管音訊串流:

  1. 通訊埠轉送:透過 adb forward localabstract:android-xxxx localabstract:audiosource 將手機端的抽象 socket 轉送至本機。
  2. 原生抽象 Socket 連線:直接使用 Rust 提供的 std::os::linux::net::SocketAddrExt,連線至本機的抽象 Unix domain socket,無需依賴外部工具或腳本封裝。
  3. 開啟 FIFO 管線:連線成功後,pocket-mic 以 O_NONBLOCK | O_NOFOLLOW 開啟剛才由 PulseAudio 建立的 FIFO(若 PipeWire 尚未完成建立,會在 ENXIO 錯誤上持續等待並重試最多 10 秒)。
  4. 延遲與原子寫入機制:
    • 在串流迴圈 forward_pcm 中,每次讀取 PCM 後以 1024 位元組為單位寫入 FIFO。在 Linux 上 PIPE_BUF 為 4096 位元組,小於等於 PIPE_BUF 的寫入符合 POSIX 原子寫入保證。
    • 透過 libc::fcntl 將管線容量鎖定在 4096 位元組(一個記憶體分頁大小):
    // 限制管線緩衝區大小為 4096 位元組,確保原子寫入且無音訊積壓
    unsafe {
        libc::fcntl(pipe.as_raw_fd(), libc::F_SETPIPE_SZ, 4096);
    }
    若應用程式端讀取暫時落後,非阻塞寫入會主動略過區塊,徹底避免管線積累過期音訊。

Omarchy 桌面整合

最上層則是專為 Omarchy 打造的 Quickshell 插件,負責監聽設備插拔、管理後台 pocket-mic 守護行程,並在狀態列提供連線與控制介面。

為什麼從 Shell / Python 重構為 Rust?

在第一版原型中,系統由 Bash 腳本(audiosource)與 Python 腳本(connect.py)組成。功能雖然能運作,但在長期日常使用中,浮現了幾項架構問題:

  1. 設備探測與輪詢開銷 (Discovery Overhead): 早期原型使用 Python 執行 --list 設備探測,每次執行都得重新初始化 Python 直譯器、載入標準庫模組與解析 JSON,帶來明顯的冷啟動延遲。改用 Rust 原生二進位檔後,單次探測僅需約 20 毫秒(主要開銷為 adb devices 子行程,相較於先前啟動 Python 直譯器大幅精簡)。更進一步地,在後續架構優化中,我們將設備探測從背景常駐計時器改為「展開選單時按需探測」,讓日常桌面閒置時的探測開銷徹底歸零。
  2. 資源釋放的脆弱性 (Brittle Cleanup): 在 Bash 中使用 trap,或在 Python 中使用訊號處理程序來卸載 PulseAudio 模組與清理 FIFO,一旦遭遇父行程被非正常終止、未捕獲的例外或孤兒行程問題,系統中容易殘留虛擬音訊設備與暫存檔案。
  3. 宣告式 CLI 與型別安全 (Type-Safe CLI & Process Management): 改用 Rust 後,透過 clap 定義宣告式 CLI 結構體與子命令,嚴謹驗證參數衝突與全域選項;並透過 thiserror 與 anyhow 建立結構化錯誤模型,搭配 prctl(PR_SET_PDEATHSIG, SIGTERM) 與獨立行程群組隔離,確保子行程生命週期嚴密綁定,不再依賴未結構化的字串錯誤與外部 Python 環境。

實作細節:Linux 音訊處理與 Rust RAII 機制

改寫為 Rust 後,程式架構與資源管理更加清晰。以下是實作過程中的核心設計:

RAII Drop 模式:自動資源釋放

在 Rust 中,我們為管理串流生命週期的 Session 實作了 Drop trait:

struct Session<'a> {
    runner: &'a Runner,
    serial: &'a str,
    name: String,
    directory: PathBuf,
    module: Option<String>,
    load_attempted: bool,
    forwarded: bool,
}

impl Drop for Session<'_> {
    fn drop(&mut self) {
        if let Some(module) = &self.module {
            self.runner.cleanup("pactl", &["unload-module", module]);
        } else if self.load_attempted {
            // 若 load-module 超時但伺服器端可能已建立模組,主動掃描並卸載匹配的殘留模組
            if let Ok(listing) = self.runner.execute(
                "pactl",
                &["list", "modules", "short"],
                Duration::from_secs(3),
                true,
            ) {
                for line in listing.lines().filter(|line| module_matches(line, &self.name)) {
                    self.runner.cleanup("pactl", &["unload-module", line.split_whitespace().next().unwrap()]);
                }
            }
        }
        if self.forwarded {
            self.runner.cleanup(
                "adb",
                &["-s", self.serial, "forward", "--remove", &format!("localabstract:{}", self.name)],
            );
        }
        let _ = fs::remove_dir_all(&self.directory);
    }
}

透過 RAII 機制,無論程式是因為手機離線、使用者主動停止、遭遇 I/O 錯誤或是收到中斷訊號,一旦 session 離開作用域,編譯器就會確保觸發 drop,依序卸載 PulseAudio 模組、移除 ADB forward 規則並刪除暫存目錄。

特別值得一提的是 else if self.load_attempted 的邊界保護:若 load-module 在客戶端等待超時但 PulseAudio 伺服器端實際已建立模組,Drop 會主動掃描 pactl list modules short 並精確卸載同名模組,防止伺服器端在高負載下洩漏殘留裝置。

宣告式 CLI 與子命令架構(clap)

在第一版 Rust 原型中,命令列參數採用手動字串匹配處理,當功能擴充至設備列舉、音量控制與 APK 安裝時,參數驗證變得脆弱且難以維護。

重構後引入 clap(v4 derive),將 CLI 定義為宣告式結構體與子命令列舉:

#[derive(Debug, Parser)]
#[command(version, about)]
pub struct Cli {
    #[arg(short = 's', long, global = true, value_name = "SERIAL")]
    pub serial: Option<String>,

    #[arg(long, global = true, value_name = "PATH")]
    pub config: Option<PathBuf>,

    #[arg(long, conflicts_with_all = ["once", "retry"])]
    pub list: bool,

    #[arg(long, global = true, conflicts_with = "retry")]
    pub once: bool,

    #[command(subcommand)]
    pub command: Option<Action>,
}

#[derive(Clone, Debug, PartialEq, Eq, Subcommand)]
pub enum Action {
    Run,
    List,
    Install {
        #[arg(value_name = "APK", env = "AUDIOSOURCE_APK")]
        apk: PathBuf,
    },
    Volume {
        #[arg(value_name = "LEVEL")]
        level: String,
    },
    Build {
        #[arg(long)]
        release: bool,
    },
}

透過 clap 的型別系統與屬性巨集,帶來幾項架構優勢:

  • 靈活的全域參數:-s / --serial 與 --config 宣告為 global = true,無論使用者將其放置於子命令之前或之後(例如 pocket-mic -s phone volume 100% 或 pocket-mic volume 100% -s phone),都能正確解析。
  • 嚴謹的互斥校驗:透過 conflicts_with 防止矛盾參數傳入(例如 --list 與子命令衝突、--once 與 --retry 衝突),在進入主邏輯前自動截斷無效指令。
  • 環境變數整合:Install 子命令直接綁定 AUDIOSOURCE_APK 環境變數,兼顧腳本自動化與手動執行的便利性。

結構化錯誤處理與行程防護(thiserror 與 PR_SET_PDEATHSIG)

過去在呼叫外部命令(adb、pactl)時,多數腳本或原型容易將錯誤簡化為無型別的字串訊息,難以區分「使用者主動中斷」、「指令逾時」還是「ADB 連線拒絕」。

在 Rust 核心中,我們透過 thiserror 定義了專屬的 ProcessError 列舉:

#[derive(Debug, Error)]
pub enum ProcessError {
    #[error("Stopped")]
    Stopped,
    #[error("{program}: {source}")]
    Io {
        program: String,
        #[source]
        source: io::Error,
    },
    #[error("{program}: timed out after {timeout:?}")]
    TimedOut { program: String, timeout: Duration },
    #[error("{program}: {status}: {message}")]
    Failed {
        program: String,
        status: ExitStatus,
        message: String,
    },
    #[error("{program}: {stream} reader failed")]
    ReaderPanicked {
        program: String,
        stream: &'static str,
    },
    #[error("{0}")]
    Connect(String),
}

底層以精確的錯誤型別表達故障原因,上層呼叫端則配合 anyhow::Context 注入業務層脈絡(例如 .context("Discover devices"))。在單元測試中,我們可以直接使用 matches! 驗證錯誤型別與終止碼,而終端使用者也能看見包含具體成因的完整錯誤鏈。

在子行程管理層面,為了避免父行程中斷時留下無人管理的孤兒行程,pocket-mic 在 pre_exec 階段調用 Linux 原生 prctl:

// 建立獨立行程群組,並在父行程結束時發送 SIGTERM,確保無殘留子行程
command.process_group(0);
unsafe {
    command.pre_exec(move || {
        if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
            return Err(io::Error::last_os_error());
        }
        if libc::getppid() != parent {
            libc::_exit(1);
        }
        Ok(())
    });
}

當父行程意外崩潰或收到退出訊號時,Linux 核心會保證向衍生出的子行程發送 SIGTERM;同時,配合獨立的行程群組(process_group(0)),在逾時或清理時可直接透過 libc::kill(-(child.id() as i32), libc::SIGKILL) 清理整個行程樹,徹底杜絕懸掛的管線描述子或孤兒行程。

裝置名稱的多詞引號處理(PipeWire Quoting)

在載入 PulseAudio 模組時,傳入的裝置描述通常包含空格(如 Pixel 3 Microphone)。在 PipeWire 底層的屬性解析器中,引號會經過模組參數與屬性列表的雙重解析。若只包一層引號,名稱常會在第一個空格處被截斷,最後在系統設定中看到的裝置名稱會變成單詞 "Pixel"。

在 Rust 模組中,我們對字串進行兩層跳脫處理:

fn pulse_quote(value: &str) -> String {
    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}

pub fn description_property(description: &str) -> String {
    let property = format!("device.description={}", pulse_quote(description));
    format!("source_properties={}", pulse_quote(&property))
}

經此處理,傳入 pactl 的字串為 source_properties="device.description=\"Pixel 3 Microphone\"",PipeWire 便能正確解析出完整名稱。

精確卸載與同名前綴防護

在每次連線時,為了避免重複載入同名來源,程式會先呼叫 pactl list modules short 尋找既有模組。此處必須精準匹配整個名稱,避免誤卸載名稱具有相同前綴的其他音訊模組:

pub fn module_matches(line: &str, name: &str) -> bool {
    let fields: Vec<_> = line.split_whitespace().collect();
    fields.get(1) == Some(&"module-pipe-source")
        && fields[2..].iter().any(|field| {
            field
                .strip_prefix("source_name=")
                .is_some_and(|value| value.trim_matches('"') == name)
        })
}

Android 12 與 Android 13+ 的動態權限相容

Pixel 3 的最後一個官方系統版本為 Android 12。在測試自動授權邏輯時,程式在 Pixel 3 上會發生錯誤。

原因在於通知權限 android.permission.POST_NOTIFICATIONS 是在 Android 13(API 33)才引入的動態執行階段權限。在 Android 12 的 Pixel 3 上執行 pm grant ... POST_NOTIFICATIONS 會被系統回傳失敗。

因此在 Rust 檢查邏輯中,將兩者分開處理:

for permission in [
    "android.permission.POST_NOTIFICATIONS",
    "android.permission.RECORD_AUDIO",
] {
    let granted = dumpsys
        .lines()
        .any(|line| line.contains(&format!("{permission}:")) && line.contains("granted=true"));
    if !granted
        && runner.adb(serial, &["exec-out", "pm", "grant", PACKAGE, permission]).is_err()
        && permission == "android.permission.RECORD_AUDIO"
    {
        return Err("Could not grant microphone permission; grant it in Android app settings".into());
    }
}

通知權限即使授權未成功也僅視為選用項目略過,唯獨麥克風錄音權限必須確保成功。這樣一來,Android 12 的 Pixel 3 也能順利自動取得授權並啟動串流。

Quickshell 插件架構:雙重角色設計

在 Omarchy Linux 桌面架構 中,桌面 UI 與狀態列是基於 Quickshell (Qt6/QML) 運作。在 manifest.json 中,我們將插件註冊為 patrick.pocketmic,並同時啟用 service 與 bar-widget:

{
  "schemaVersion": 1,
  "id": "patrick.pocketmic",
  "name": "Pocket Mic",
  "version": "1.0.0",
  "description": "Turn your Android phone into a desktop microphone from the bar, with automatic reconnection",
  "kinds": ["service", "bar-widget"],
  "entryPoints": {
    "service": "Service.qml",
    "barWidget": "BarWidget.qml"
  },
  "barWidget": {
    "displayName": "Pocket Mic",
    "category": "Audio",
    "allowMultiple": false,
    "defaultSection": "right"
  }
}

背景常駐服務:Service.qml

將後台管理抽離為獨立單例服務,使音訊串流與狀態列 UI 解耦,避免狀態列重新渲染或選單關閉時中斷音訊通話:

  • worker 程序生命週期管理:使用者選取設備後,直接啟動 pocket-mic --serial <serial>。若手機意外拔除或斷線,服務會自動進入 5 秒重試倒數,直到手機重新插入便自動恢復連線。
  • 設備探測與就地更新:提供 refreshDevices() 函式,透過 pocket-mic --list 取得線上設備的原始 ADB 狀態(如 device、unauthorized、offline、no permissions)並更新反應式屬性 devices。
  • 零背景盲目輪詢:不同於早期版本在背景常駐 5 秒定時器,最新架構完全移除了背景輪詢定時器,將探測控制權交由前端選單,徹底消除了桌面閒置時的背景開銷。
readonly property string binaryPath: decodeURIComponent(
    Qt.resolvedUrl("pocket-mic").toString().replace(/^file:\/\//, "")
)

Process {
    id: worker
    command: [root.binaryPath, "--serial", root.selectedSerial]
    // ...
}

狀態列與即時控制面板:BarWidget.qml

  • 按需輪詢機制 (On-Demand Polling):將設備探測計時器綁定在選單展開狀態(popupOpen):
    Timer {
        interval: 5000
        running: root.popupOpen && root.controller !== null
        repeat: true
        triggeredOnStart: true
        onTriggered: root.controller.refreshDevices()
    }
    當使用者點擊圖示展開面板時,triggeredOnStart: true 立即執行一次設備探測,讓使用者能第一時間看到最新狀態;選單保持展開時,每 5 秒更新一次;一旦關閉選單,探測排程立即休眠。閒置狀態下維持真正的零子行程開銷。
  • 手動刷新按鈕:面板右上角提供 󰑐 重新整理按鈕,支援隨時手動點擊觸發 controller.refreshDevices()。
  • 狀態圖示與提示:未啟用時顯示半透明的休眠麥克風圖示 󰍭;串流時點亮為 󰍬,並提供懸停狀態說明。
  • 彈出式控制面板 (PopupCard):比對序號就地更新模型,確保選單平順更新且不丟失焦點。
  • 鍵盤導航支援:配合 Omarchy 的鍵盤操作慣例,支援使用 Tab 鍵在不同設備間跳轉,並按下 Space 或 Enter 快速啟動或停用。

下圖為插件在 Omarchy 桌面狀態列中的實際運作畫面——未啟用時狀態列顯示為休眠麥克風圖示 󰍭,點選設備後立即轉為串流狀態,圖示點亮為 󰍬:

未連線休眠狀態 串流進行中狀態
pocketmic-dropdown-stopped.png pocketmic-dropdown-streaming.png

日常體驗:會議通話與語音聽寫

將 Pocket Mic 整合至日常工作後,使用體驗相當穩定。

遠端會議與通話

開會時只需用一條 USB 傳輸線將 Pixel 3 連接電腦,狀態列右上角的麥克風圖示隨即變亮。點擊圖示或用鍵盤快捷鍵勾選,系統層級的 PipeWire 音訊核心便會載入虛擬音訊源,在 Omarchy 的 Audio 面板中自動識別為 Pixel 3 Microphone 並設為預設輸入源:

pipewire-audio-source.png

在 Google Meet 與 Discord 的麥克風選單裡就能直接選取 Pixel 3 Microphone。Pixel 3 內建的硬體消噪與指向性收音表現良好,對話時背景的機械鍵盤敲擊聲能被有效抑制,不需要額外開啟降噪軟體。

隨時呼叫 voxtype 本地 Whisper 聽寫

在日常開發或寫作時,無論是長按 F9(push-to-talk,放開即停止)或是按下 Ctrl + Alt + Shift + V(切換錄音開關),桌上立著的 Pixel 3 都是方便的桌面麥克風。說完一段話,文字便即時輸入至螢幕游標處,辨識率因為清晰的收音大幅提升。

發熱與功耗表現

因為手機端只執行純粹的 AudioRecord 與 socket 寫入,螢幕處於休眠關閉狀態,沒有額外的 UI 繪製與複雜編碼運算,Pixel 3 在持續收音數小時的情況下完全處於常溫狀態,不會發燙,同時電腦的 USB 孔還能維持手機微量補電。

結語:延續硬體價值的實踐

在電子產品更迭頻繁的環境下,一支舊手機往往因為不再支援新版作業系統或電池衰退而閒置。然而它內部搭載的感測器、音訊晶片與麥克風硬體素質,依然足以勝任許多周邊設備的需求。

從最初的 Shell + Python 概念驗證腳本,到最後透過 Rust RAII 與 Linux 抽象通訊協定重構成型,Pocket Mic 展現了現代 Linux 與 Wayland 桌面的擴充能力:不需要依賴特定商業軟體,就能讓退役的手機在日常工作中發揮實際價值。

如果你手邊也有一台閒置的 Android 手機,不妨也把它接上電腦試試看。

專案原始碼與安裝說明: