在桌面上運行一個簡易的語音助理,一直是我很想嘗試的學習專案。市面上的商業語音助理(如 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),負責在不同的音訊處理階段之間進行切換:
核心模組實現拆解
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-4 的 MtmdContext(多模態上下文)將音訊檔案 (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 的朋友提供一些參考與啟發: