featured.svg

在桌面上運行一個簡易的語音助理,一直是我很想嘗試的學習專案。市面上的商業語音助理(如 Alexa 或 Siri)背後有極為龐大的雲端基礎設施與複雜的工程團隊;但作為個人學習 Rust 與本地 AI 技術的練習,我們也可以用幾百行 Rust 程式碼,把麥克風音訊擷取、WebRTC 降噪、喚醒詞辨識、多模態 LLM 與語音合成組裝成一個本機運行的終端機實驗原型。

這就是 voice-assistant 專案的由來,也是 Rust 52 Projects 挑戰的第 52 篇專案(完結篇)。

這個專案的主要目的是學習如何整合各個獨立的 Rust Crate 與 C/C++ FFI 綁定。特別的是,為了實驗多模態模型的可能性,專案嘗試繞過了傳統「語音轉文字 (STT, Whisper) $\to$ 文字 LLM $\to$ 文字轉語音 (TTS)」的三階段流程,改為將麥克風錄製的語音音訊直接作為多模態向量(Audio Tensor Embedding)餵給 Gemma-4 Multimodal Audio 模型,進行端到端推論。

這篇文章記錄了這個實驗專案的架構設計、模組組合與學習心得。


專案學習重點與組件構成

作為一個學習練習,這個專案將幾個常見的語音處理組件整合在一起:

  • 本機流程實驗 (Local Pipeline):全程在本地電腦執行麥克風採樣、降噪、喚醒詞辨識與模型推論,方便在沒有網路連線時進行測試與調試。
  • WebRTC APM 音訊預處理 (Noise Suppression & AGC2):利用 sonora 封裝的 WebRTC 引擎,將麥克風採樣率重採樣至 16kHz 單聲道,並進行基礎的背景雜音消除與自動增益調整。
  • 喚醒詞偵測 (openWakeWord):使用 oww-rs 在背景持續監聽 1280 樣本 (Sample) 的音訊視窗,判定是否觸發喚醒詞(預設支援 Alexa 或 Hey Mycroft 測試模型)。
  • 基礎 RMS 能量 VAD (Voice Activity Detection):喚醒後進入錄音模式,以 10ms (160 樣本) 視窗計算音訊方均根 (RMS) 能量,當持續低於門檻 1.5 秒或達到 8 秒上限時停止錄音。
  • 多模態語音推論嘗試:透過 llama-cpp-4 與 Vulkan GPU 加速加載 Gemma-4 多模態模型 (gemma-4-E4B-it + mmproj-gemma-4-E4B-it-BF16.gguf),將 WAV 音訊轉為向量直接由 LLM 進行解碼與串流生成。
  • 原生 PowerShell TTS 與簡單迴路防護:呼叫 Windows PowerShell 內建的 System.Speech 進行語音朗讀;並在朗讀結束前清理輸入緩衝區,避免麥克風收錄到喇叭播放的聲音而造成誤觸發。

系統狀態機架構 (State Machine)

程式的主體架構是一個簡單的狀態機(State Machine),負責在不同的音訊處理階段之間進行切換:

%%{init: { 'themeVariables': { 'fontSize': '16px', 'subGraphTitleFontSize': '18px' } }}%% flowchart TD subgraph Listening ["1. State::Listening (背景監聽)"] direction TD A1["cpal 麥克風音訊擷取 (16kHz 重採樣)"] --> A2["WebRTC APM (降噪 NS + AGC2 增益)"] A2 --> A3["openWakeWord (1280 樣本視窗比對)"] end subgraph Recording ["2. State::Recording (使用者口述錄音)"] direction TD B1["10ms 視窗計算 RMS 音訊能量"] --> B2["終端機即時繪製彩色音量波形條"] B2 --> B3["VAD 判定: 靜音 > 1.5s 或 時間 > 8.0s"] end subgraph Processing ["3. State::Processing (Gemma-4 多模態推論)"] direction TD C1["寫入錄音至 temp_query.wav"] --> C2["Gemma-4 Multimodal Projector (Audio Embedding)"] C2 --> C3["llama-cpp-4 (Vulkan GPU 加速串流生成)"] end subgraph Speaking ["4. State::Speaking (語音反饋與重置)"] direction TD D1["Windows PowerShell System.Speech 語音朗讀"] --> D2["清空音訊緩衝區 & 防範自激迴音"] end A3 -->|"偵測到喚醒詞 (High Beep)"| Recording B3 -->|"錄音完成 (Low Beep)"| Processing C3 -->|"推論完成"| Speaking D2 -->|"恢復背景監聽"| Listening

核心模組實現拆解

1. 音訊擷取與 WebRTC APM 降噪預處理 (preprocessor.rs)

不同麥克風設備的預設採樣率(例如 44.1kHz 或 48kHz)與聲道數各不相同。AudioPreprocessor 的作用是將這些異構音訊轉化為 openWakeWord 與 LLM 模型所需的 16,000 Hz 單聲道格式,並經過 WebRTC 的降噪與增益模組處理:

pub struct AudioPreprocessor {
    input_sample_rate: u32,
    apm: Option<AudioProcessing>,
    mic_samples_accumulator: Vec<f32>,
    apm_input_buffer: Vec<f32>,
}

impl AudioPreprocessor {
    pub fn feed_and_process(&mut self, raw_samples: &[f32], channels: usize) -> Result<Vec<f32>> {
        if raw_samples.is_empty() { return Ok(Vec::new()); }

        // 1. 單聲道混音 (Channel Mixing)
        let mono_samples = convert_channels_to_mono(raw_samples, channels);
        self.mic_samples_accumulator.extend_from_slice(&mono_samples);

        // 2. 線性重採樣至 16,000 Hz
        let mic_samples_needed = (160.0 * (self.input_sample_rate as f32 / 16000.0)).ceil() as usize;
        while self.mic_samples_accumulator.len() >= mic_samples_needed {
            let chunk_to_resample = self.mic_samples_accumulator.drain(0..mic_samples_needed).collect::<Vec<_>>();
            let resampled_16k = resample_linear(&chunk_to_resample, self.input_sample_rate, 16000);
            self.apm_input_buffer.extend_from_slice(&resampled_16k);
        }

        // 3. WebRTC APM 處理 10ms 影格 (160 樣本)
        let apm_frame_size = 160;
        let mut processed_samples = Vec::new();
        while self.apm_input_buffer.len() >= apm_frame_size {
            let frame: Vec<f32> = self.apm_input_buffer.drain(0..apm_frame_size).collect();
            let processed_frame = if let Some(ref mut apm_engine) = self.apm {
                let mut dest = vec![0.0f32; apm_frame_size];
                apm_engine.process_capture_f32(&[&frame], &mut [&mut dest])?;
                dest
            } else {
                frame
            };
            processed_samples.extend_from_slice(&processed_frame);
        }
        Ok(processed_samples)
    }
}

2. openWakeWord 喚醒詞偵測 (detector.rs)

openWakeWord 是一個輕量級的開源喚醒詞模型。這裡使用 oww-rs 將處理後的 16kHz 音訊累積至 1280 樣本(約 80ms)的固定區塊後傳入模型進行推論:

pub struct WakewordDetector {
    oww_model: OwwModel,
    oww_chunk_buffer: Vec<f32>,
}

impl WakewordDetector {
    pub fn feed_and_detect(&mut self, samples: &[f32]) -> bool {
        self.oww_chunk_buffer.extend_from_slice(samples);
        let mut triggered = false;

        while self.oww_chunk_buffer.len() >= OWW_MODEL_CHUNK_SIZE {
            let chunk: Vec<f32> = self.oww_chunk_buffer.drain(0..OWW_MODEL_CHUNK_SIZE).collect();
            let result = self.oww_model.detection(chunk);
            if result.detected {
                triggered = true;
            }
        }
        triggered
    }
}

3. 基礎 RMS 靜音檢測 (VAD) (vad.rs)

專案中採用了較簡單的能量門檻法作為 VAD 實作。每 10ms 影格計算 Root Mean Square (RMS) 音訊能量:

$$RMS = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_i^2}$$

若 RMS 持續低於設定門檻(預設 0.003)達到指定影格數(1.5 秒),或總錄音長度超過最大上限(8 秒),則終止錄音。雖然簡單的 RMS 門檻在吵雜環境或說話停頓較長時可能會誤判,但作為教學概念驗證已經足夠直觀:

pub fn process_frame(&mut self, frame: &[f32]) -> (bool, f32) {
    self.recording_samples.extend_from_slice(frame);
    self.total_frames_count += 1;

    let mut sum_squares = 0.0f32;
    for &sample in frame {
        sum_squares += sample * sample;
    }
    let rms = (sum_squares / frame.len() as f32).sqrt();

    if rms < self.vad_threshold {
        self.silence_frames_count += 1;
    } else {
        self.silence_frames_count = 0;
    }

    let is_silent = self.silence_frames_count >= self.silence_limit_frames 
        && self.total_frames_count >= self.min_recording_frames;
    let hit_max = self.total_frames_count >= self.max_recording_frames;

    (is_silent || hit_max, rms)
}

4. Gemma-4 多模態音訊直通推論 (engine.rs)

在這個學習實驗中,最有趣的部分是嘗試用 llama-cpp-4MtmdContext(多模態上下文)將音訊檔案 (temp_query.wav) 編碼為 Embeddings(MtmdBitmap),並直接傳給 Gemma-4 模型進行推論:

pub fn run_multimodal(
    &mut self,
    prompt: &str,
    audio_path: &Path,
    max_tokens: u32,
    seed: Option<u32>,
    mut stream_callback: impl FnMut(&str) + Send,
) -> Result<String> {
    let marker = MtmdContext::default_marker();
    let full_prompt = format!("{} {}", prompt, marker);

    // 1. 將音訊轉換為多模態 Bitmap
    let bitmap = MtmdBitmap::from_file(&self.mtmd_ctx, audio_path)?;

    // 2. 切分文字 Prompt 與多模態標記
    let text = MtmdInputText::new(&full_prompt, true, true);
    let bitmaps = [&bitmap];
    let mut chunks = MtmdInputChunks::new();
    self.mtmd_ctx.tokenize(&text, &bitmaps, &mut chunks)?;

    // 3. 一次性評估音訊 Tokens
    let mut lctx = self.session.model.new_context(&self.session.backend, self.loaded_context_params.clone())?;
    let mut n_past = 0i32;
    self.mtmd_ctx.eval_chunks(lctx.as_ptr(), &chunks, 0, 0, lctx.n_batch() as i32, true, &mut n_past)?;

    // 4. 自迴歸串流生成回應文字
    let mut sampler = LlamaSampler::chain_simple([
        LlamaSampler::dist(seed.unwrap_or(42)),
        LlamaSampler::greedy(),
    ]);
    // ...逐 Token 解碼並觸發 stream_callback(&piece)...
}

這個方式省去了整合 STT 模型的步驟,讓我們能直接在 Rust 中實驗多模態模型對語音輸入的回應效果。


5. Windows PowerShell TTS 整合 (speech.rs)

為了保持專案輕量、避免引進額外的 C/C++ 語音合成依賴,語音輸出部分選擇直接透過 std::process::Command 呼叫 Windows 內建的 PowerShell System.Speech 進行朗讀:

pub fn speak(text: &str) {
    if text.trim().is_empty() { return; }

    let script = format!(
        "Add-Type -AssemblyName System.Speech; \
         $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; \
         $synth.Speak([Console]::In.ReadToEnd())"
    );

    let child = std::process::Command::new("powershell")
        .args(["-NoProfile", "-Command", &script])
        .stdin(std::process::Stdio::piped())
        .spawn().ok();

    if let Some(mut c) = child {
        if let Some(mut stdin) = c.stdin.take() {
            let _ = stdin.write_all(text.as_bytes());
        }
        let _ = c.wait();
    }
}

在測試過程中發現,如果 TTS 播放時麥克風仍處於接收狀態,喇叭發出的聲音很容易再次觸發喚醒或錄音邏輯。因此,程式在 Speaking 狀態下會阻塞等待 TTS 結束,並在重新進入 Listening 前呼叫 input_buffer.lock().unwrap().clear() 清空累積的音訊緩衝區。


測試與執行方式

準備好 Gemma-4 GGUF 模型與 Multimodal Projector 檔案後,即可執行此練習專案:

cargo run --release -- --wakeword alexa --threshold 0.5

可用選項參數:

Usage: voice-assistant [OPTIONS]

Options:
  -m, --model <MODEL>              GGUF 模型路徑
  -p, --mmproj <MMPROJ>            Multimodal Projector GGUF 路徑
  -w, --wakeword <WAKEWORD>        喚醒詞模型 [default: alexa] [alexa, mycroft]
  -t, --threshold <THRESHOLD>      喚醒詞信心門檻 (0.0 - 1.0) [default: 0.5]
      --no-apm                     停用 WebRTC APM 降噪預處理
  -v, --vad-threshold <THRESHOLD>  VAD 靜音門檻 (RMS) [default: 0.003]
  -d, --max-duration <SECONDS>     最長單次錄音秒數 [default: 8.0]
  -s, --silence-duration <SECONDS> 靜音結束判定秒數 [default: 1.5]

Rust 52 Projects 學習之旅總結 💡

完成這個專案,也代表著 Rust 52 Projects 個人學習挑戰告一段落。

當初發起這個挑戰,目的只是希望能強迫自己每週透過動手寫一個小專案,從實務中學習 Rust 的不同領域。回看這 52 個練習專案,涵蓋了許多過去不曾涉足的方向:

  • 系統與模擬器學習:嘗試練習了 6502 CPU 與 NES 主機模擬器的邏輯解構。
  • 圖形與 UI 框架:接觸了 wgpu (WGSL Shaders)、GPUI 與 Egui 等不同的繪圖與桌面 GUI 框架。
  • FFI 與電腦視覺:學習如何在 Rust 中呼叫 OpenCV 5、MediaPipe ONNX 模型與系統級輸入 API。
  • 機器學習與本地 LLM:從解析 GGUF 格式、手寫基礎 Tensor 算子,到調用多模態大模型。

這 52 個專案絕大多數都只是簡單的概念驗證 (PoC) 或學習實驗,距離成熟或生產級的軟體還有很長的距離。但過程中深刻體會到了 Rust 強大的型別安全、零成本抽象以及豐富的社群 Ecosystem。

特別想感嘆與感謝的是,能夠順利堅持並完成這整整 52 個專案,AI 輔助開發 (AI-Assisted Coding) 的進步絕對功不可沒。不論是快速搭建範例原型、除錯 C/C++ FFI 綁定、理解音訊與機器學習演算法,還是探索陌生的套件,AI 都扮演了隨時隨地的 Pair Programming 夥伴,大幅降低了跨領域學習的門檻,讓我也能一步步把這個原本看似遙遠的 52 篇系列挑戰圓滿完成。

專案的原始碼都已整理並開源在 GitHub 上,希望能給同樣在學習 Rust 的朋友提供一些參考與啟發:

👉 p47t/rust-52-projects (voice-assistant)