語音點餐系統開發紀錄:WebSocket 即時音訊串流與 AI 語意解析
記錄開發一套即時語音點餐系統的完整架構,從瀏覽器麥克風錄音、WebSocket 音訊串流、Whisper 語音辨識到 GPT Function Calling 結構化訂單輸出。
專案動機
傳統的餐廳點餐流程:客人看菜單、跟服務生口頭點餐、服務生手寫或輸入 POS 系統。如果能讓客人直接「說」要吃什麼,系統自動辨識並產生結構化訂單呢?
這個想法催生了 Voko——一個即時語音點餐系統的原型。客人對著手機麥克風說話,系統在幾秒內辨識語音並顯示結構化的點餐結果。
技術上的挑戰在於:如何做到「邊說邊辨識」的即時體驗,而不是「錄完一整段再傳上去處理」的批次模式。
系統架構
整個系統由三個服務組成:
Browser (Next.js)
│ Socket.IO (PCM binary)
▼
NestJS Backend
│ WebSocket (raw binary)
▼
Python Whisper Service
│ transcription text
▼
NestJS Backend
│ GPT-4o Function Calling
▼
Browser (structured order)
為什麼是三個服務而不是一個?
- 前端(Next.js):負責音訊錄製和 UI 呈現
- 後端(NestJS):WebSocket 路由、LLM 呼叫、業務邏輯
- Whisper 服務(Python):語音辨識需要 GPU,用 Python 生態系更方便
前端:瀏覽器音訊錄製
取得麥克風權限
const stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 16000, channelCount: 1 },
video: false,
});
指定 16kHz 單聲道——這是 Whisper 模型的原生取樣率。如果用 48kHz 錄製再降頻,白白浪費頻寬和處理時間。
Web Audio API 即時串流
const audioContext = new AudioContext({ sampleRate: 16000 });
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
const pcmData = e.inputBuffer.getChannelData(0);
// 直接把 raw PCM Float32 送出去
socketService.sendAudio(pcmData.buffer);
};
source.connect(processor);
processor.connect(audioContext.destination);
每 256ms(4096 samples / 16000 Hz)會觸發一次 callback。我們直接送出原始的 Float32 PCM 數據——不做任何壓縮或編碼。
為什麼不用 Opus 或 WebM?
- Whisper 需要的是原始 PCM,壓縮了後端還要解壓
- 在區域網路或同機器環境下,頻寬不是瓶頸
- 減少一層編碼/解碼延遲
即時波形視覺化
用 AnalyserNode 驅動 Canvas 畫出即時波形,給使用者「系統正在聽」的視覺回饋:
const analyser = audioContext.createAnalyser();
source.connect(analyser);
function drawWaveform() {
const data = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(data);
// 畫到 canvas 上...
requestAnimationFrame(drawWaveform);
}
結束錄音
使用者按下停止按鈕時,送出一個空的 Float32Array 作為結束信號:
socketService.sendAudio(new Float32Array(0).buffer);
後端收到長度為 0 的 buffer 就知道這段語音結束了。
後端 Gateway:Per-Client WebSocket 路由
NestJS 的 VocalGateway 是系統的核心路由層。每個連接的瀏覽器客戶端,gateway 會為其維護一條專屬的 WebSocket 連線到 Whisper 服務:
@WebSocketGateway({ namespace: '/vocal' })
export class VocalGateway {
// 每個客戶端對應一條 Whisper WS 連線
private whisperSocketMap = new Map(); // clientId → WebSocket
// 等待 Whisper WS 就緒的 Promise
private whisperReadyMap = new Map(); // clientId → Promise
// 累積的辨識歷史(per-client)
private whisperResHistory = new Map(); // clientId → string[]
}
Lazy Init + Promise Gate
第一個音訊 chunk 到達時,才建立 Whisper WebSocket 連線:
@SubscribeMessage('audio')
async handleAudio(client: Socket, data: ArrayBuffer) {
const clientId = client.id;
if (!this.whisperSocketMap.has(clientId)) {
this.initWhisperConnection(clientId);
}
// 等待 Whisper 連線就緒
await this.whisperReadyMap.get(clientId);
// 轉發音訊
this.whisperSocketMap.get(clientId).send(data, { binary: true });
}
Promise gate 解決了一個微妙的 race condition:如果 Whisper WebSocket 還在握手中,第一批音訊 chunk 會被丟棄。用 Promise 等待確保不會漏掉任何數據。
辨識結果累積
Whisper 每辨識出一段文字就回傳。Gateway 把歷史累積起來,整段送給 LLM:
whisperSocket.on('message', async (msg) => {
const text = msg.toString();
const history = this.whisperResHistory.get(clientId) || [];
history.push(text);
// 把完整的對話歷史送給 GPT
const order = await this.llmService.parseOrderFromText(
history.join(' '),
this.menuItems,
);
client.emit('partial_transcription', JSON.stringify(order));
});
為什麼累積而不只是送最新一句?
因為客人可能分次說:「我要兩個漢堡」...(停頓)...「再加一杯可樂」。如果只送「再加一杯可樂」給 GPT,它不知道前面已經點了漢堡。累積完整歷史讓 GPT 每次都能產出完整訂單。
Whisper 服務:VAD + 本地推理
為什麼自架而不用 OpenAI Whisper API?
- 延遲:自架版走本地網路,省去跨境 API 來回的 200-500ms
- 成本:OpenAI Whisper API 按音訊時長計費,自架是一次性 GPU 成本
- 隱私:音訊資料不離開自己的機器
Voice Activity Detection (VAD)
不是所有音訊都需要送去辨識。環境噪音、沈默、空氣聲都應該被過濾。我們用 Silero VAD 做即時的語音活動偵測:
chunk = np.frombuffer(raw_pcm, dtype=np.float32)
if vad.is_speech(chunk):
audio_buffer.append(chunk)
last_voice_time = time.time()
elif time.time() - last_voice_time > 1.0 and audio_buffer:
# 1 秒靜音 = 這段話結束,送去辨識
segment = np.concatenate(audio_buffer)
transcription = transcribe(segment)
await websocket.send_text(transcription)
audio_buffer.clear()
邏輯:
- 有人聲 → 持續累積到 buffer
- 靜音超過 1 秒 → buffer 中的音訊送去 Whisper 辨識
- 辨識結果送回 NestJS
這個 1 秒的閾值是實測調整出來的:太短會把一句話切成碎片,太長會讓延遲感變明顯。
本地 Whisper 推理
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-large-v2").to(device)
在 Apple Silicon 上用 MPS 加速,推理一段 3-5 秒的語音約需 1-2 秒。
GPT Function Calling:結構化訂單輸出
辨識出的文字不是最終輸出——「我要兩個大麥克跟一杯中可」需要被轉換成結構化的訂單資料。
為什麼用 Function Calling 而不是直接讓 GPT 輸出 JSON?
直接要 GPT 輸出 JSON 有時候格式會跑掉(多餘的文字、欄位名不一致)。Function Calling 強制 GPT 填入預定義的 schema:
tools: [{
type: 'function',
function: {
name: 'parse_order',
description: '解析顧客的點餐內容並輸出餐點名稱與數量',
parameters: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: '餐點名稱' },
quantity: { type: 'integer', description: '數量' },
},
required: ['name', 'quantity'],
},
},
},
required: ['items'],
},
},
}]
菜單約束
系統啟動時從資料庫載入完整菜單,注入到 prompt 中。GPT 只能從「現有菜單」中匹配——如果客人點了不存在的品項,會被排除。
const systemPrompt = `你是一個點餐助手。以下是目前的訂單:
${menuItems.map(item => `- ${item.name} $${item.price}`).join('\n')}
請根據客人說的話,辨識他們想點的餐點和數量。
只回傳菜單上有的品項。`;
Temperature 設為 0.2,確保輸出穩定。
完整的延遲分析
從客人說完一句話到 UI 更新的延遲:
| 階段 | 延遲 | |------|------| | VAD 偵測到靜音(1s threshold) | 1000ms | | Whisper 推理(3-5s segment) | 1000-2000ms | | Network 傳回 NestJS | ~5ms | | GPT-4o-mini Function Calling | 500-1000ms | | Socket.IO 推送到前端 | ~5ms | | 總計 | 2.5-4 秒 |
對點餐場景來說,這個延遲是可接受的——客人說完一句話後 3 秒看到結果更新,體驗是流暢的。
踩踩的坑
ScriptProcessorNode 已被標記為 deprecated
Web Audio API 建議改用 AudioWorklet,但 AudioWorklet 的 API 更複雜(需要另外載入 worklet module),而且不是所有瀏覽器都完整支援。目前先用 ScriptProcessorNode,功能正常但可能有較高延遲。
Whisper 的語言設定
如果不指定語言,Whisper 會自動偵測,但在中英夾雜的場景下容易跳來跳去。硬編碼 language="zh" 後穩定很多。
記憶體洩漏:per-client 狀態
whisperResHistory 會無限累積。對短對話(一次點餐 1-2 分鐘)沒問題,但如果客戶端不正常斷開,歷史不會被清理。需要在 handleDisconnect 事件中清除:
handleDisconnect(client: Socket) {
const ws = this.whisperSocketMap.get(client.id);
if (ws) ws.close();
this.whisperSocketMap.delete(client.id);
this.whisperReadyMap.delete(client.id);
this.whisperResHistory.delete(client.id);
}
結語
這個專案最有趣的部分是「三段式即時串流」的設計:Browser → NestJS → Whisper → NestJS → GPT → Browser。每一段都是即時的(event-driven、push-based),沒有任何地方在做 polling。
語音 AI 應用的核心挑戰不在 AI 本身——Whisper 和 GPT 都很成熟——而在「如何讓整個管線的延遲低到使用者感覺不到」。這需要從音訊格式選擇、VAD 閾值調整、到連線管理的每一層都做好延遲優化。
關於作者

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