← 返回部落格
·11 min 閱讀·專案紀錄

OpenJarvis:打造全離線 AI 會議助理的技術架構

深入解析一款 macOS 桌面 AI 會議助理的完整技術架構,涵蓋本地語音辨識、即時逐字稿、AI 會議摘要、語音複製 TTS,以及自動回覆引擎的實作細節。

TauriRust語音辨識AI桌面應用
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

專案動機

遠端會議是現代工作的日常。但有些場景下你不方便開口說話——在咖啡廳、在共用辦公空間、或只是不想被旁邊的人聽到。

OpenJarvis 要解決的問題是:讓你在 Google Meet 中用打字取代說話,同時用你自己的聲音合成語音。附帶的功能是即時逐字稿和 AI 會議摘要——因為既然已經在處理音訊了,順便把這些做了。

最重要的設計原則:全部離線運作。音訊不離開你的電腦,沒有雲端 API 呼叫。

技術棧總覽

| 層級 | 技術 | |------|------| | 桌面框架 | Tauri 2.x (Rust + WebView) | | 前端 | React 19 + TypeScript + Vite | | 音訊 I/O | cpal + CoreAudio FFI | | 語音辨識 | WhisperKit (Apple Silicon 本地推理) | | LLM | Ollama + Qwen 2.5:7b | | TTS | MLX-Audio + Qwen3-TTS (本地語音合成) | | 虛擬音訊 | BlackHole 2ch | | 狀態管理 | Zustand |

核心架構:音訊路由

整個系統最巧妙的部分是音訊路由設計。問題是:如何讓 Google Meet 聽到我們合成的語音,同時我們能聽到會議中其他人的聲音?

BlackHole 虛擬音訊設備

BlackHole 是一個 macOS 虛擬音訊驅動程式,充當音訊 loopback。系統需要兩個虛擬設備:

  1. Multi-Output Device:包含實體喇叭 + BlackHole。設為系統輸出後,所有聲音同時送到喇叭和 BlackHole
  2. Aggregate Input Device:只包含 BlackHole,用於擷取會議音訊

Google Meet 的麥克風設為 BlackHole——這樣 Rust 後端寫入 BlackHole 的任何音訊,Google Meet 都會當成「你的麥克風輸入」。

用 CoreAudio FFI 自動建立設備

讓使用者手動到「音訊 MIDI 設定」建立虛擬設備太麻煩。我們直接用 CoreAudio C API 程式化建立:

// 726 行的 CoreAudio FFI 程式碼
// 處理各種 Mac 硬體的音訊設備名稱差異
// MacBook Pro Speakers, Mac mini, 外接耳機...
pub fn create_aggregate_device(name: &str, sub_devices: &[AudioDeviceID]) -> Result<AudioDeviceID> {
    // AudioObjectSetPropertyData 建立空白 aggregate device
    // 設定 sub-device list (CFArrayRef)
    // 設定 clock source (防止 sample rate drift)
    // 啟用 drift correction
    // ...
}

這段程式碼需要處理各種邊界情況:不同 Mac 硬體的內建喇叭名稱不同、中文系統的設備名稱、設備已存在時的冪等性處理。

音訊處理管線

專用 OS Thread

音訊管線跑在專用的 OS thread 上,因為 cpal::Stream!Send(不能跨 thread)。所有音訊相關的操作都在這個 thread 完成:

std::thread::spawn(move || {
    let _guard = rt_handle.enter(); // 進入 Tokio runtime

    // Ring buffer: 2 秒容量 (44100 * 2 samples)
    let ring_buf = HeapRb::<f32>::new(RING_BUF_CAPACITY);

    loop {
        // 1. 從 ring buffer drain 音訊
        // 2. Resample 44.1kHz → 16kHz
        // 3. 送入 VAD
        // 4. 累積語音片段
        // 5. 靜音 500ms → flush 到 WhisperKit
        // 6. 檢查 command channel (try_recv)
    }
});

Thread 和主應用透過 tokio::sync::mpsc::channel 溝通,指令包括:StartCaptureStopCaptureSpeakTextCancelTts 等。

VAD + Speech Accumulator

不是所有音訊都需要送去辨識。用 WebRTC VAD 做即時語音活動偵測:

// 每 20ms 一個 frame (320 samples at 16kHz)
if vad.is_voice_segment(frame) {
    speech_buffer.extend(frame);
    silence_count = 0;
} else {
    silence_count += 1;
    if silence_count >= 25 && speech_buffer.len() > MIN_SPEECH_SAMPLES {
        // 500ms 靜音 = 這句話結束,送去辨識
        flush_to_whisperkit(&speech_buffer);
        speech_buffer.clear();
    }
}

// 防止超長語音:10 秒強制 flush
if speech_buffer.len() >= MAX_SPEECH_SAMPLES {
    flush_to_whisperkit(&speech_buffer);
    speech_buffer.clear();
}

三個關鍵參數:

  • MIN_SPEECH_SAMPLES = 8000(0.5 秒):太短的不送,可能是噪音
  • SILENCE_FLUSH_FRAMES = 25(500ms):靜音閾值
  • MAX_SPEECH_SAMPLES = 160000(10 秒):強制切斷防止有人不停說話

TTS Gate:防止辨識自己的聲音

一個微妙但關鍵的問題:當 TTS 播放語音到 BlackHole 時,這個聲音也會被 capture 到。如果不處理,WhisperKit 會把你的 TTS 語音也辨識一遍。

let tts_active: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));

// 在 ASR pipeline 中
if !tts_active.load(Ordering::Relaxed) {
    // TTS 沒在播放時才送入 ASR
    resample_buf.extend_from_slice(&audio_chunk);
}

Flag 在 TTS 開始播放之前就設為 true,確保第一個 sample 就被抑制。

本地語音辨識:WhisperKit

WhisperKit 是 Apple Silicon 優化的 Whisper 實作,作為 sidecar binary 跑在 localhost:50060。

async fn transcribe(wav_data: Vec<u8>) -> Result<String> {
    let form = multipart::Form::new()
        .part("file", Part::bytes(wav_data).file_name("audio.wav"))
        .text("language", "zh");

    let response = client.post("http://localhost:50060/v1/audio/transcriptions")
        .multipart(form)
        .send()
        .await?;

    let text = response.json::<WhisperResponse>().await?.text;

    // 過濾 Whisper 幻覺
    if HALLUCINATION_BLOCKLIST.contains(&text.trim()) {
        return Ok(String::new());
    }

    // 簡體 → 繁體轉換
    Ok(hanconv::to_traditional(&text))
}

Whisper 幻覺過濾

WhisperKit 在靜音或噪音上偶爾會輸出固定的幻覺文字,如「感謝觀看」、「Thank you for watching」等。我們維護一個 blocklist 直接過濾。

語音合成:用你自己的聲音說話

錄製語音樣本

使用者錄一小段自己的語音作為 reference。錄製流程包含品質把關:

// 三級 SNR 品質門檻
enum VoiceQuality {
    Pass,        // > -30 dBFS
    SoftWarning, // > -45 dBFS
    Reject,      // < -45 dBFS (太安靜)
}

錄製的 WAV 使用原子性寫入(先寫 temp file 再 rename),防止中途失敗產生損壞的檔案。

串流 TTS

TTS 使用 MLX-Audio 在本地跑 Qwen3-TTS 模型。關鍵是串流解碼——不等整段語音合成完才播放:

// HTTP response body 串流處理
let mut wav_header_parsed = false;
let mut sample_rate = 24000;

while let Some(chunk) = response.chunk().await? {
    if !wav_header_parsed {
        // 從前 44 bytes 解析 WAV header
        sample_rate = parse_wav_sample_rate(&chunk[..44]);
        wav_header_parsed = true;
        pcm_data = &chunk[44..]; // 跳過 header
    }

    // 即時解碼 PCM bytes → f32
    let samples = decode_pcm_i16_to_f32(pcm_data);
    // Resample 24kHz → 設備原生 sample rate
    let resampled = linear_resample(&samples, sample_rate, device_rate);
    // 推入 lock-free ring buffer → cpal output stream 播放
    ring_buffer_producer.push_slice(&resampled);
}

Time-to-First-Byte 約 85ms——使用者幾乎感覺不到延遲。

AI 會議摘要

會議結束後,把完整逐字稿送給 Ollama (Qwen 2.5:7b) 生成結構化摘要:

  • 重點整理:會議中討論的核心議題
  • 行動項目:誰要做什麼、什麼時候
  • 決策紀錄:達成的共識和決定

全部在本地完成,7B 模型在 Apple Silicon 上推理速度夠快。

自動回覆引擎

最有趣的功能:AI 監聽即時逐字稿,自主決定是否需要發言。

狀態機

idle → deciding → intercepting → speaking → idle
  1. idle:監聽逐字稿,新文字進來時觸發判斷
  2. deciding:送最近 10 行逐字稿給 LLM,問「需要回應嗎?」
  3. intercepting:LLM 決定要說話 → 顯示倒數計時(預設 5 秒),使用者可以攔截取消
  4. speaking:倒數結束,TTS 播放 AI 生成的回覆

LLM Prompt 設計

你正在監聽一場即時會議逐字稿。
如果不需要回應:只輸出 SILENT。
如果需要回應:只輸出你要說的話。

你的角色指令(最高優先):{user_role_prompt}

使用者可以自訂角色指令(例如「你是技術顧問,只在被問到技術問題時回答」),控制 AI 何時該介入。

為什麼有攔截視窗?

AI 不是完美的。它可能在不恰當的時機決定發言。5 秒的倒數計時讓使用者有機會在 AI 開口之前取消——這是「AI 自主性」和「人類控制權」之間的平衡。

為什麼選 Tauri + Rust?

  • 效能:音訊處理需要低延遲,Rust 的零成本抽象比 Electron 的 Node.js 快得多
  • 記憶體:Tauri 的 WebView 比 Electron 的 Chromium 省 10x 記憶體
  • 系統整合:需要直接呼叫 CoreAudio FFI,Rust 的 FFI 能力是原生的
  • 音訊安全cpal::Stream!Send 約束在編譯時就防止了跨 thread 使用的 bug

學到的經驗

音訊 debug 很痛苦

音訊問題不像 UI bug 可以看到。聲音不對只能「聽」,很難定位是 resample 的問題、VAD 閾值的問題、還是設備路由的問題。最有效的 debug 方式是在每個階段把音訊存成 WAV 檔,逐個聽。

本地 LLM 的限制

7B 模型的理解能力有限。自動回覆引擎偶爾會在不該說話的時候決定說話。這就是為什麼攔截視窗不能省——它是 UX 的安全網。

Apple Silicon 是本地 AI 的理想平台

WhisperKit + MLX-Audio + Ollama 都針對 Apple Silicon 做了優化。在 M1 Pro 上,語音辨識延遲約 1-2 秒、TTS 首字節約 85ms、LLM 推理約 3-5 秒。這些延遲對會議場景完全可接受。

結語

OpenJarvis 是一個「把多個 AI 能力組合起來解決實際問題」的專案。單獨的語音辨識、TTS、LLM 都不是新東西——但把它們串在一起,配合精心設計的音訊路由和使用者體驗,就變成了一個真正有用的工具。

全離線的設計選擇意味著更高的初始設定成本(下載模型、設定虛擬音訊設備),但換來的是完全的隱私和零營運費用。對於處理敏感會議內容的使用者來說,這個取捨是值得的。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →