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

語音點餐系統開發紀錄:WebSocket 即時音訊串流與 AI 語意解析

記錄開發一套即時語音點餐系統的完整架構,從瀏覽器麥克風錄音、WebSocket 音訊串流、Whisper 語音辨識到 GPT Function Calling 結構化訂單輸出。

WebSocket語音辨識AI即時系統Next.js
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

專案動機

傳統的餐廳點餐流程:客人看菜單、跟服務生口頭點餐、服務生手寫或輸入 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()

邏輯:

  1. 有人聲 → 持續累積到 buffer
  2. 靜音超過 1 秒 → buffer 中的音訊送去 Whisper 辨識
  3. 辨識結果送回 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 閾值調整、到連線管理的每一層都做好延遲優化。

關於作者

GP Wang
GP Wang

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

了解更多 →