前陣子在整理抽屜時,翻出了退役已久的 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 音訊核心:
整個流程在底層依序由四個核心階段組成:
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 建立並接管音訊串流:
- 通訊埠轉送:透過
adb forward localabstract:android-xxxx localabstract:audiosource將手機端的抽象 socket 轉送至本機。 - 原生抽象 Socket 連線:直接使用 Rust 提供的
std::os::linux::net::SocketAddrExt,連線至本機的抽象 Unix domain socket,無需依賴外部工具或腳本封裝。 - 開啟 FIFO 管線:連線成功後,
pocket-mic以O_NONBLOCK | O_NOFOLLOW開啟剛才由 PulseAudio 建立的 FIFO(若 PipeWire 尚未完成建立,會在ENXIO錯誤上持續等待並重試最多 10 秒)。 - 延遲與原子寫入機制:
- 在串流迴圈
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)組成。功能雖然能運作,但在長期日常使用中,浮現了幾項架構問題:
- 設備探測與輪詢開銷 (Discovery Overhead):
早期原型使用 Python 執行
--list設備探測,每次執行都得重新初始化 Python 直譯器、載入標準庫模組與解析 JSON,帶來明顯的冷啟動延遲。改用 Rust 原生二進位檔後,單次探測僅需約 20 毫秒(主要開銷為adb devices子行程,相較於先前啟動 Python 直譯器大幅精簡)。更進一步地,在後續架構優化中,我們將設備探測從背景常駐計時器改為「展開選單時按需探測」,讓日常桌面閒置時的探測開銷徹底歸零。 - 資源釋放的脆弱性 (Brittle Cleanup):
在 Bash 中使用
trap,或在 Python 中使用訊號處理程序來卸載 PulseAudio 模組與清理 FIFO,一旦遭遇父行程被非正常終止、未捕獲的例外或孤兒行程問題,系統中容易殘留虛擬音訊設備與暫存檔案。 - 宣告式 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 桌面狀態列中的實際運作畫面——未啟用時狀態列顯示為休眠麥克風圖示 ,點選設備後立即轉為串流狀態,圖示點亮為 :
| 未連線休眠狀態 | 串流進行中狀態 |
|---|---|
|
|
|
日常體驗:會議通話與語音聽寫
將 Pocket Mic 整合至日常工作後,使用體驗相當穩定。
遠端會議與通話
開會時只需用一條 USB 傳輸線將 Pixel 3 連接電腦,狀態列右上角的麥克風圖示隨即變亮。點擊圖示或用鍵盤快捷鍵勾選,系統層級的 PipeWire 音訊核心便會載入虛擬音訊源,在 Omarchy 的 Audio 面板中自動識別為 Pixel 3 Microphone 並設為預設輸入源:
在 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 手機,不妨也把它接上電腦試試看。
專案原始碼與安裝說明:
- GitHub 專案倉庫:https://github.com/p47t/patrick.pocketmic