featured.svg

本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。

在先前的系列文章中,我們已經為本地推論引擎奠定了非常堅實的基礎:

  1. 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫 llama.cpp 原生函式庫。
  2. 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
  3. 第三篇:編譯嵌入式 Web UI 與 Axum 靜態資源整合:利用 rust-embed 將靜態網頁打包進 Rust 執行檔、實現了 SPA Fallback 路由,並用 Vanilla JS 打造出精美的暗黑毛玻璃聊天介面。

到目前為止,我們的「LLM 本地工作室」已經是一個非常成熟的文字對談系統。但在今天這個多模態(Multimodal)大放異彩的時代,僅僅能打字交流顯然有些單調。如果我們的本地服務也能直接聽懂我們的聲音,並在瀏覽器上實現即時的「語音對講」,那體驗絕對會上好幾個檔次!

為了實現這個目標,我們在最新的 llm-local-studio-4 中,帶來了兩項重量級的升級:

  1. 整合 Vulkan GPU 硬體加速:擺脫 CPU 推論的緩慢,讓生成速度成倍飆升。
  2. 導入 libmtmd 實現本地多模態音訊(Audio)直接推論:無需先用額外的語音轉文字(STT)模型,而是將聲音特徵直接送入多模態大模型進行解碼!

這篇文章將為大家解密:如何在 Rust 中開啟 Vulkan GPU 加速、如何使用 libmtmd 進行音訊與文字的向量融合、後端如何透過 ffmpeg 子行程將瀏覽器的 WebM 格式轉換成標準 WAV,以及前端如何使用 HTML5 MediaRecorder 原生串接相容於 OpenAI 格式的多模態 API。


技術關鍵一:跨平台 GPU 加速的救星 —— Vulkan 整合

要在本地流暢運行多模態大模型,GPU 加速是不可或缺的。然而,常見的 CUDA 方案有著不少痛點:它僅限於 NVIDIA 顯示卡,且安裝驅動與 CUDA Toolkit 的過程極度臃腫,這對發布一個輕量級、易於部署的 Rust 執行檔來說是個阻礙。

為了達到真正的「跨平台、低依賴」,我們在 Cargo.toml 中將 llama-cpp-4 的推論引擎啟用了 vulkan 特徵:

[dependencies]
llama-cpp-4 = { version = "0.3.0", features = ["mtmd", "vulkan"] }

Vulkan 是一個現代的、跨平台的低階圖形與計算 API。不論你使用的是 NVIDIA、AMD 還是 Intel 的顯示卡,只要顯卡驅動支援 Vulkan,我們的 Rust 程式就能直接調用顯示卡進行計算!

在 Rust 程式碼中,開啟 GPU 加速也變得無比簡單。在 src/inference.rsload_model 函數中,我們只需設定 n_gpu_layers 參數:

let model = LlamaModel::load_from_file(
    &backend, 
    &request.path, 
    &LlamaModelParams::default().with_n_gpu_layers(99) // 將 99 層(即所有層)模型載入 GPU 記憶體
)
.with_context(|| format!("failed to load model {}", request.path.display()))?;

透過這行設定,llama.cpp 底層會自動將模型權重搬移至顯示記憶體(VRAM)。在 Vulkan 的加持下,即使是在中階顯卡上,模型的 Tokens/s 生成速度也立刻提升了數倍,為流暢的語音對答提供了硬體基礎。


技術關鍵二:本地多模態音訊推論的奧秘 —— libmtmd

傳統的「語音對話」通常採用級聯(Cascade)架構

  1. 麥克風錄音 $\rightarrow$ 2. 送給 STT 模型(如 Whisper)轉成文字 $\rightarrow$ 3. 文字送給 LLM 產生回答 $\rightarrow$ 4. 回答文字送給 TTS 模型唸出來。

這種架構在本地運行時非常笨重,需要同時載入好幾個模型,且語音中的情緒、語調等特徵在轉成文字的過程中會完全流失。

而在 llm-local-studio-4 中,我們採用了 Direct Multimodal Audio(直接音訊多模態) 機制。我們使用支援音訊輸入的多模態模型(例如 gemma-4-E4B-it),並搭配其專屬的 音訊投影器(Audio Projector, mmproj)

其底層數據流如下:

graph TD Audio[輸入 WAV 語音檔] --> AudioEncoder[音訊編碼器] AudioEncoder --> Projector[音訊投影器 mmproj] Projector --> AudioEmbeds[音訊特徵向量 Audio Embeddings] Text[輸入文字 Prompt + 預留置換符] --> Tokenizer[分詞器] Tokenizer --> TextEmbeds[文字特徵向量 Text Embeddings] AudioEmbeds --> Merge[在預留位置將文字向量替換為音訊向量] TextEmbeds --> Merge Merge --> LlamaEval[llama.cpp 一次性評估完整 Context] LlamaEval --> SampleLoop[標準採樣輸出 Token 串流]

在 Rust 中,我們透過 llama_cpp_4::mtmd 模組來實現這套極度精密的操作(詳見 src/inference.rs):

// 1. 從音訊投影器檔案 (mmproj.gguf) 初始化多模態上下文
let params = MtmdContextParams::default();
let mtmd_ctx = MtmdContext::init_from_file(&mmproj_path, &session.model, params)?;

// 2. 將輸入的音訊檔案載入為 MtmdBitmap (此處 libmtmd 會處理對數梅爾頻譜圖轉換與特徵提取)
let bitmap = MtmdBitmap::from_file(mtmd_ctx, &request.audio_path)?;

// 3. 在文字 Prompt 中加入特殊的媒體預留標記 (Default Marker)
let marker = MtmdContext::default_marker();
let full_prompt = format!("{} {}", request.prompt, marker);

// 4. Tokenize:將文字與音訊資料進行融合,將預留標記替換為音訊向量
let text = MtmdInputText::new(&full_prompt, true, true);
let bitmaps = [&bitmap];
let mut chunks = MtmdInputChunks::new();
mtmd_ctx.tokenize(&text, &bitmaps, &mut chunks)?;

// 5. 評估(Evaluation)音訊與文字的混合向量
let mut lctx = session.model.new_context(&session.backend, self.loaded_context_params.clone())?;
let mut n_past = 0i32;
mtmd_ctx.eval_chunks(lctx.as_ptr(), &chunks, 0, 0, lctx.n_batch() as i32, true, &mut n_past)?;

// 6. 進入標準的 Token 採樣循環,即時輸出文字答案
let batch = LlamaBatch::new(512, 1);
let (output_text, generated_tokens) = sample_loop(
    session,
    &mut lctx,
    batch,
    n_past, // 評估音訊後的起始位置
    request.max_tokens,
    request.seed,
    &request.stream_callback,
)?;

這套實作完全繞過了傳統的「語音轉文字(STT)」步驟,由大模型的大腦直接去聽並理解你的聲波向量,實現了真正的原生多模態互動!


技術關鍵三:後端 WebM 到 WAV 的音訊處理管線

這時候,身為開發者的你一定會想到一個實際問題:

現代瀏覽器透過 MediaRecorder 錄製的音訊,通常是 audio/webm (包含 Opus 編碼) 格式;然而底層 libmtmd 進行特徵提取時,只收標準的 16 kHz 單聲道 (Mono) 16-bit WAV 格式。

為了解決這個格式鴻溝,我們在 Axum 後端的 /v1/chat/completions API 路由中(src/api/routes.rs),設計了一個即時音訊轉換管線

當後端偵測到 API 請求中含有音訊部分時,它會:

  1. 將前端傳送的 Base64 字串解碼回原始的 WebM 二進位數據。
  2. 啟動本機的 ffmpeg 作為子行程(Subprocess),並透過管道(Pipe)進行流式轉換,避免在硬碟上寫入未壓縮的超大暫存檔。

具體實作在 audio_bytes_to_wav 函數中:

fn audio_bytes_to_wav(audio_bytes: &[u8], _format_hint: &str) -> Result<std::path::PathBuf, String> {
    let tmp_dir = std::env::temp_dir();
    let wav_path = tmp_dir.join(format!("llm_audio_{}.wav", uuid::Uuid::new_v4()));

    // 啟動 ffmpeg 子行程
    let mut child = Command::new("ffmpeg")
        .args([
            "-y",            // 若檔案存在則覆寫
            "-i", "pipe:0",  // 從標準輸入 (stdin) 讀取 WebM 數據
            "-ar", "16000",  // 設定採樣率為 16000 Hz
            "-ac", "1",      // 單聲道 (Mono)
            "-f", "wav",     // 強制輸出格式為 wav
            wav_path.to_str().ok_or("temp path not UTF-8")?,
        ])
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;

    // 將 WebM 二進位數據寫入 ffmpeg 的標準輸入
    {
        let mut stdin = child.stdin.take().ok_or("Failed to open ffmpeg stdin")?;
        stdin.write_all(audio_bytes).map_err(|e| format!("Failed to write audio to ffmpeg: {e}"))?;
    }

    // 等待轉換完成
    let status = child.wait().map_err(|e| format!("ffmpeg wait error: {e}"))?;
    if !status.success() {
        return Err("ffmpeg failed to convert audio to WAV".to_string());
    }

    Ok(wav_path)
}

推論引擎接收到轉換後的 wav_path 並完成 eval_chunks 之後,後端會立刻刪除該暫存檔案std::fs::remove_file),確保使用者的隱私資料與暫存檔不會殘留在硬碟中。


技術關鍵四:前端 UI 語音錄製與 OpenAI 多模態 API 串接

在前端部分,我們遵循了 OpenAI 最新的多模態語音 API 規範,將網頁端錄製的語音封裝為 ContentPart 陣列傳送給後端。

前端 ui/src/main.ts 使用瀏覽器原生的 navigator.mediaDevices.getUserMedia 獲取麥克風權限,並啟動 MediaRecorder 錄製 WebM 音訊:

// 1. 啟動錄音
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunks = [];

mediaRecorder.ondataavailable = (event) => {
  if (event.data.size > 0) {
    audioChunks.push(event.data);
  }
};

mediaRecorder.onstop = async () => {
  // 將錄音數據打包為 Blob
  const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
  stream.getTracks().forEach(track => track.stop());
  await handleAudioMultimodal(audioBlob);
};

mediaRecorder.start();

當錄音結束時,前端會將 Blob 轉換為 Base64 編碼,並建構一個 OpenAI 規格的多模態請求體(Request Body):

// 將 Blob 轉成 Base64 字串
const base64Data = await new Promise<string>((resolve, reject) => {
  const reader = new FileReader();
  reader.onloadend = () => {
    const result = reader.result as string;
    const base64 = result.split(',')[1];
    resolve(base64);
  };
  reader.onerror = reject;
  reader.readAsDataURL(audioBlob);
});

// 建立多模態內容片段
const contentParts = [
  { type: 'text', text: chatInput.value.trim() || 'Listen and respond to this audio input.' },
  {
    type: 'input_audio',
    input_audio: {
      data: base64Data,
      format: 'webm' // 瀏覽器錄製的 webm 格式
    }
  }
];

// 推入對話歷史紀錄
messages.push({
  role: 'user',
  content: contentParts // 陣列格式的多模態訊息
});

// 呼叫 Axum 後端 (支援與純文字相同的 SSE 串流輸出)
await generateResponse();

這項設計使得我們的 UI 與後端 API 都具備極佳的通用性。你可以用相同的 UI 連接線上 OpenAI 語音 API,也可以用任何支援 OpenAI 協定的第三方工具(如 Continue 或 CLI 工具)無縫串接我們本地的多模態語音服務!


如何構建與運行?

想要親身體驗這款「能聽、能說」且具備 GPU 加速的本地多模態工作站,流程非常簡單:

1. 下載多模態模型與投影器

至 Hugging Face 下載多模態語音模型 gemma-4-E4B-it 及其對應的音訊投影器 mmproj-gemma-4-E4B-it-BF16.gguf,並放置於專案根目錄的 models/ 下。

2. 一鍵啟動服務

使用 Cargo 啟動服務。除了指定載入的主模型外,你需要額外加上 --mmproj 參數指定投影器路徑,以便初始化多模態上下文:

# 啟動服務並加載 Vulkan GPU 加速與音訊投影器
cargo run --release -- serve gemma-4-E4B-it-Q4_K_M.gguf --mmproj models/huggingface/lmstudio-community/gemma-4-E4B-it-GGUF/mmproj-gemma-4-E4B-it-BF16.gguf --port 8080

3. 打開瀏覽器用語音聊天

開啟瀏覽器連線至 http://localhost:8080。現在你會看到輸入框左側多了一個「麥克風」圖示:

  • 點擊麥克風即可開始錄音(狀態列與按鈕會呈現錄音中的呼吸燈特效)。
  • 再次點擊麥克風完成錄音,前端會自動將語音編碼並發送。
  • 本地 LLM 在收到聲音後,會透過 Vulkan GPU 加速迅速解碼,並以流暢的打字機效果(SSE Stream)即時輸出回答!

總結與展望

llm-local-studio-4 中,我們完成了本地 LLM 工作站從「純文字」到「硬體加速多模態」的華麗轉身。

透過本章的實踐,我們學習到了:

  • 如何在 Rust 中整合 Vulkan GPU API 來進行高效能的張量計算。
  • 深入探索了 libmtmd 底層多模態編碼器與投影器(MLP) 的融合原理與程式碼實現。
  • 搭建了後端多執行緒子行程的 ffmpeg 影音流式轉換管線。
  • 掌握了前端 HTML5 錄音與 OpenAI 多模態 API 標準 的完美整合。

原生多模態代表著 AI 互動的未來趨勢。能直接處理聲音、影像等物理世界訊號的模型,其資訊表達能力遠高於單純的文字。在下一篇文章中,我們將在此基礎上更進一步,挑戰將**本地相機影像(Vision)即時語音輸出(TTS)**進行深度整合,打造一個完全運行在本機、完全隱私安全的「本地雙向影音對話助理」!

本專案的所有原始碼已收錄於我們的 Rust 52 Projects 挑戰計畫中。如果你也對本地 AI 與 Rust 系統編程感興趣,歡迎前來專案倉庫點個 Star 並親自體驗!

我們下一個 Rust 專案見!