
前兩天有個朋友問我你們那個 ESP32 AI 玩偶為什么每次問完一句話都要轉圈等半天能不能像人一樣我說上半句它就能接下半句我說到一半它還能自己停下來這個問題一下就戳中了我一直在做的重構。老版本的玩偶用的是“按鍵對講”式交互按住說話、松開識別、轉圈等待、播放回答整個鏈路是半雙工的體驗很像對講機。這次我和團隊一起把音頻鏈路整體換成了 WebSocket 二進制直連讓 ESP32 端持續推流音頻服務端流式識別和合成再通過二進制幀把音頻一塊一塊推回給玩偶真正做到全雙工的連續對話體驗。文章會從協議設計、ESP32 端采集上行、服務端流式處理、打斷控制、狀態機、穩定性和踩坑排查幾個方面展開每一步都有可直接落地的代碼、參數和思路適合正在做低成本 AI 硬件原型、想把“能對話”升級成“連續對話”的開發者參考。1. 這個重構到底在解決什么問題1.1 老版“按鍵通話”模式的三個硬傷先說說我為什么要動手重構。老版本的架構很簡單玩偶上有一個觸摸按鍵用戶按住說話ESP32 錄一段音頻松開按鍵后這段音頻通過 HTTP POST 上傳到服務器服務器做 ASR 識別、再丟給 LLM 生成回復、TTS 合成語音最后把 MP3 文件地址返回ESP32 下載并播放。聽著好像沒什么問題但實際用起來有三個讓我抓狂的硬傷。第一個是交互節奏太慢。一次完整的問答應答鏈路里音頻上傳少則幾百毫秒服務器識別和生成回復少則兩三秒再算上播放 MP3 文件前要等整個文件下載完用戶從松開按鍵到聽到回答經常要等 4 到 6 秒。成人等得起小朋友等不起等待超過兩秒就開始亂按按鍵整個對話就亂掉了。第二個是用戶不能打斷。玩偶正在“說話”的時候用戶沒辦法插話因為鏈路是半雙工的要么在錄音上傳要么在下載播放兩個方向不會同時跑。可真實對話里打斷和被搶話是最自然不過的事情。用戶都覺得它說錯了我就該能立刻喊停讓它重聽我的指令。第三個是“一次一問”缺乏上下文連續感。老版每次對話都是全新會話玩偶不記得上一句也不理解用戶會在兩句話之間停頓很久更不會在你還沒把話說完時就“等著你”。所以體驗是純問答不是對話。1.2 連續對話需要什么樣的音頻鏈路連續對話和按鍵問答的差異核心在于全雙工和流式處理。全雙工的意思是上行音頻和下行音頻可以同時跑流式處理的意思是客戶端一邊采集數據一邊發送服務端一邊接收一邊識別識別結果不再需要等整段音頻全部傳完TTS 合成出的語音也按幀發送而不是等整段合成完再一次性下發。要做到這一點需要滿足幾個硬條件端側有持續采集音頻的能力而不是按一次錄一段上行鏈路是長連接可以一直推流而不是每次單獨建一個 HTTP 請求服務端能對同一音頻流做增量識別能夠判斷什么時候是靜音、什么時候是停頓下行鏈路也能流式傳輸音頻播放器要有局部緩沖能力不能每來一個音頻包就立刻播放否則網絡抖動會讓語音一頓一頓用戶可以在播放中繼續上行說話服務端需要用“打斷”信號把下行語音切掉并切回識別狀態。相比傳統 HTTP 上傳WebSocket 幾乎是天然為這種場景準備的它天然支持全雙工TCP 連接建立后雙向都可以隨時發數據不需要反復握手同時它支持二進制幀我們可以把音頻數據、控制命令、序列號、時間戳都封裝在一個自定義幀里。這個消息設計是整個重構的地基也是我認為最值得仔細講的部分。2. WebSocket 二進制音頻鏈路的協議設計2.1 為什么選 WebSocket 而不是 RTMP 或 HTTP/2我評估過三個方案RTMP、WebSocket、HTTP/2 的流式上傳加 SSE 下行。RTMP 是成熟的流媒體協議做音視頻直播很穩但對 ESP32 這種資源受限設備來說太重了協議棧復雜握手繁瑣而且很多服務端組件并不是為低延遲“對話式”場景設計的。HTTP/2 的流式能力很強服務端推流也很方便但 ESP32 端要支持 HTTP/2 需要加密和 HPACK 頭壓縮底層網絡棧的開銷明顯比 WebSocket 大。WebSocket 勝在簡單直接基于 TCP 的長連接客戶端和服務端實現都成熟ESP32 的 ESP-IDF 里自帶 esp_websocket_client 組件服務端用 Go 的 gorilla/websocket 或者 nhooyr.io/websocket 都行。它還有現成的 Ping/Pong 控制幀配合應用層的心跳能讓我非常清晰地判斷連接是不是還活著。最終我選 WebSocket主路線是 ws:// 明文等部署到生產環境再考慮 wss://。2.2 二進制幀格式一次頂一個協議音頻數據如果用 WebSocket 的文本幀傳要先做 Base64 編碼體積膨脹 33%加解碼開銷完全沒有必要。我直接用二進制幀而且自定義了一個很精簡的頭部結構。WebSocket 的 frame payload 本身就帶長度信息所以我只需要在 payload 最前面加一小段“頭部”用來表達消息類型、序列號和時間戳。我這里定義每幀 payload 的結構如下第 0 字節消息類型opcode1 字節無符號整數第 1~4 字節序列號 seq4 字節大端無符號整數用來做丟包檢測和重排第 5~8 字節時間戳 ts4 字節大端無符號整數單位是采樣數表示這一段音頻在采樣時間軸上的位置從第 9 字節開始真正的業務數據音頻幀或控制命令。消息類型我留了 4 種后面如果要擴展還可以繼續加opcode方向含義0x01客戶端 → 服務端上行音頻幀攜帶 PCM 數據0x02服務端 → 客戶端下行音頻幀攜帶 TTS 音頻數據0x03雙向文本/元數據比如識別中間結果、設備信息0x04雙向控制命令如打斷、心跳、重連協商這種結構的好處是一條 WebSocket 連接上既能傳音頻數據又能傳控制指令還不會互相干擾。服務端解碼時只要判斷 opcode就可以決定把 payload 交給音頻處理管線還是控制命令處理管線。2.3 心跳、序列號與音軌對齊網絡設備千奇百怪尤其是家用的路由器動不動就會把長時間空閑的 TCP 連接斷開。WebSocket 在 TCP 之上TCP 被斷開時應用層如果一直不收發數據就可能很久之后才發現連接已經死了或者干脆出現半開連接。所以我在幀設計里加了兩個機制。第一個是應用層心跳。每隔 5 秒客戶端發一個 opcode 為 0x04 的控制幀控制幀內部用一個子字段表示心跳請求服務端收到后回復心跳響應。如果連續三次心跳沒有收到響應客戶端就認為鏈路已經失效主動斷開重連。注意這里心跳的間隔不能太短太短會增加無線模塊的喚醒次數抬高 ESP32 的功耗也不能太長太長則無法及時感知鏈路異常。實測 5 秒比較合適。第二個是序列號。在弱 WiFi 環境下就算 TCP 能保證數據不重復不亂序但重傳導致的延遲抖動依然會讓音頻播放不連貫。有了 seq客戶端可以判斷自己收到的音頻幀是否有空洞有了 ts播放器可以計算 jitter buffer 的基準點。音頻傳輸不要只看瞬時到達的速度更重要的是播放端能否維持穩定的節奏。3. ESP32 側音頻采集、編碼與上行發送3.1 硬件選型與麥克風/喇叭的接法我用的核心板是 ESP32-S3-WROOM-1 模組配 8MB PSRAM板子是 ESP32-S3-DevKitC-1。為什么選 S3因為它有更充足的 RAM有原生 USB 燒錄調試口AI 推理和 DSP 指令也比老 ESP32 強不少做音頻緩沖時不用太摳內存。麥克風用的是 INMP441 數字 MEMS 麥克風走 I2S 接口。注意 INMP441 是 PDM 輸出的只是它內部集成了數字接口直接用 I2S 協議去讀就行。喇叭功放用 MAX98357A 這種 D 類功放模塊同樣走 I2S。這樣設計最大的好處是麥克風采集和喇叭播放共用一條 I2S 總線不用額外占用模擬引腳抗干擾能力也比板載模擬麥克風好太多。接線我列一下方便直接抄外設引腳ESP32-S3 GPIO說明INMP441 SCKGPIO4I2S BCLK 位時鐘INMP441 WSGPIO5I2S LRCLK 左右聲道時鐘INMP441 SDGPIO6數據輸出MAX98357A BCLKGPIO4與麥克風共享位時鐘MAX98357A LRCGPIO5與麥克風共享 LRCLKMAX98357A DINGPIO7數據輸入兩者電源3.3V / GND注意共地這里有個很關鍵的坑MAX98357A 和 INMP441 最好用同一個 3.3V 電源域并且功放的電源要加 100uF 電解電容和 0.1uF 陶瓷電容并聯去耦。如果不加喇叭播放大動態音頻時電源電壓會被拉低INMP441 的輸出就會帶上明顯的“電源噪聲”語音識別率直線下降。這個問題我在 7.2 節里還會細說。3.2 用 I2S DMA 連續采樣 16bit PCM在 ESP-IDF 里配置 I2S 非常簡單但要注意版本差異。ESP-IDF 5.x 之后 I2S 驅動 API 和舊版 4.x 完全不兼容我這里按 5.x 的新驅動寫。配置成標準 TDM 模式采樣率 16000位深 16bit單聲道內置 DMA 緩沖。#include driver/i2s_std.h i2s_chan_handle_t rx_chan NULL; i2s_chan_handle_t tx_chan NULL; i2s_chan_config_t chan_cfg { .id I2S_NUM_0, .role I2S_ROLE_MASTER, .dma_desc_num 8, .dma_frame_num 240, .auto_clear true, }; i2s_new_channel(chan_cfg, tx_chan, rx_chan); i2s_std_config_t std_cfg { .clk_cfg { .sample_rate_hz 16000, .clk_src I2S_CLK_SRC_DEFAULT, }, .slot_cfg { .slot_mode I2S_SLOT_MODE_MONO, .data_bit_width I2S_DATA_BIT_WIDTH_16BIT, .slot_bit_width I2S_SLOT_BIT_WIDTH_16BIT, .slot_mask I2S_STD_SLOT_LEFT, }, .gpio_cfg { .mclk I2S_GPIO_UNUSED, .bclk GPIO_NUM_4, .ws GPIO_NUM_5, .dout GPIO_NUM_7, .din GPIO_NUM_6, }, }; i2s_channel_init_std_mode(rx_chan, std_cfg); i2s_channel_init_std_mode(tx_chan, std_cfg); i2s_channel_enable(rx_chan); i2s_channel_enable(tx_chan);dma_frame_num 我配成 240240 × 16bit × 2(雙聲道占位) 960 字節一個 DMA 描述符8 個描述符就是 7680 字節的環形緩沖。這個數值不是隨便拍的DMA 幀數太小會導致中斷頻繁CPU 占用高太大則采集延遲變大。實測 240 幀、16000Hz 下每個 DMA 塊對應約 30ms 音頻反應速度在玩具場景里完全可以接受。采集循環只需要不停調用 i2s_channel_read讀到一定長度的 PCM 數據就攢進一個發送隊列int16_t pcm_buf[320]; // 20ms 16kHz size_t bytes_read 0; esp_err_t ret i2s_channel_read(rx_chan, pcm_buf, sizeof(pcm_buf), bytes_read, 100 / portTICK_PERIOD_MS); if (ret ESP_OK bytes_read sizeof(pcm_buf)) { // 送到編碼或發送任務 xQueueSend(audio_queue, pcm_buf, 0); }一次讀 320 個采樣正好是 20ms 的音頻快。為什么選 20ms因為很多 ASR 服務商對音頻分幀的最佳長度就是 20ms 到 40ms20ms 的包大小適中即使加頭部整幀 WebSocket 消息也只有 650 字節左右無線鏈路一次就能發完不至于被底層 TCP 拆成多個小包造成無效重傳。3.3 本地 VAD什么時候該把音頻發出去如果 ESP32 采集到的所有音頻都無腦推給服務端服務端會被大量靜音數據淹沒ASR 也容易在無人說話時產生幻覺識別。更可怕的是全雙工鏈路里下行播放的喇叭聲音會被麥克風采進來形成自激干擾。所以本地必須有一個 VAD語音活動檢測只把有效語音推上行。我用的方案是先做能量閾值檢測再做靜音尾判定。具體來說對每一段 20ms 的音頻計算 RMS 值uint32_t sum 0; for (int i 0; i 320; i) { int32_t v pcm_buf[i]; sum (uint32_t)(v * v) 4; } uint32_t rms (uint32_t)sqrt(sum / 320);如果 RMS 大于某個閾值我習慣用 300 到 800 之間具體數值要靠板子實測因為麥克風增益差異很大就認為有人聲開始進入“說話中”狀態持續把音頻推給服務器。當連續若干幀 RMS 都低于閾值比如 30 幀也就是 600ms 靜音就判定說話結束給服務端發一個“語音結束”控制幀。這里我的經驗是靜音尾巴不要設太短否則會覺得“搶話”用戶停頓半秒想一下措辭也會被判定為說話結束。也不要設太長否則回答延遲會變高。600ms 到 1000ms 是常見區間。另外如果 ESP32-S3 內存富余可以考慮用 ESP-SR 里的 WakeNet 做喚醒詞檢測把“小貝小貝”喚醒和 VAD 結合體驗會更自然。我在原型里是先做成 WakeNet 喚醒 能量 VAD效果已經很接近真實對話了。3.4 把 PCM 包成二進制幀并走 WebSocket 上行發送邏輯我就直接基于 esp_websocket_client 實現。連接建立后單獨開一個“音頻發送任務”從隊列里拿 PCM 數據拼裝成二進制幀再發送。typedef struct __attribute__((packed)) { uint8_t op; uint32_t seq; uint32_t ts; } audio_pkt_head_t; static uint32_t g_seq 0; static uint32_t g_ts 0; void send_audio_pcm(int16_t *pcm, size_t sample_count) { size_t payload_len sizeof(audio_pkt_head_t) sample_count * sizeof(int16_t); uint8_t *buf heap_caps_malloc(payload_len, MALLOC_CAP_SPIRAM); audio_pkt_head_t *hdr (audio_pkt_head_t *)buf; hdr-op 0x01; hdr-seq htonl(g_seq); hdr-ts htonl(g_ts); memcpy(buf sizeof(audio_pkt_head_t), pcm, sample_count * sizeof(int16_t)); esp_websocket_client_send_bin(g_websocket, (char *)buf, payload_len, pdMS_TO_TICKS(100)); heap_caps_free(buf); g_ts sample_count; }這里要特別注意字節序問題。C 語言在 ESP32 上是小端而網絡傳輸約定用大端所以 seq 和 ts 都做了一次 htonl 轉換。如果你在服務器端忘了用大端解析時間戳就會變成一個巨大的錯誤數字。同時我建議發送緩沖區不要直接在任務棧上開大數組。ESP32 的任務棧一般只有 4KB 到 8KB一個 650 字節的幀還好但如果你把多個幀拼在一起發就可能爆棧。我用 heap_caps_malloc 分配 PSRAM 內存發完馬上釋放既靈活又不占棧空間。一個更重要的經驗是音頻發送任務必須保證低延遲不能被其他任務搶占太久。我把它設置成高優先級并綁定到一個專用核心上運行xTaskCreatePinnedToCore(send_task, audio_send, 4096, NULL, 5, NULL, 1);核心 0 用來跑 WiFi 協議棧和系統任務核心 1 跑音頻收發避免任務遷移導致的不確定延遲。實測對比后同樣的代碼綁定核心后首包延遲能低 10ms 到 30ms 左右別看這個數字不大對端到端低延遲很關鍵。4. 服務端把二進制流變成“能聽、能想、能說”的閉環4.1 服務端框架與連接管理服務端我用 Go 寫WebSocket 庫用的 gorilla/websocket。選 Go 而不是 Node 或 Python是因為它天然的并發模型非常適合維護大量長連接而且 gorilla/websocket 的接口對二進制消息處理非常直接。每個 ESP32 設備連上來后我會在服務端維護一個 DeviceSession 結構體里面把這條連接綁定的相關處理器都串起來type DeviceSession struct { Conn *websocket.Conn rwmu sync.Mutex asr ASREngine llm LLMClient tts TTSClient buffer *AudioBuffer state AtomicState }連接讀循環很簡單通過判斷 opcode 分發0x01 的音頻包送給 ASR 緩沖0x04 的控制包觸發打斷或心跳等動作。寫循環單獨用一個 channel 保證多 goroutine 不會并發寫同一個 WebSocket因為 gorilla/websocket 的同一時刻只允許一個 goroutine 寫連接并發寫會把底層幀寫壞。我在讀循環里獲取音頻幀的代碼示例如下mt, payload, err : conn.ReadMessage() if mt ! websocket.BinaryMessage || len(payload) 9 { return fmt.Errorf(invalid packet) } op : payload[0] seq : binary.BigEndian.Uint32(payload[1:5]) ts : binary.BigEndian.Uint32(payload[5:9]) data : payload[9:] switch op { case 0x01: session.buffer.Push(seq, ts, data) case 0x04: handleControl(session, data) }序列號在這里不只是好看它還能幫我過濾重復幀。由于 TCP 本身不會重復主要是 Wi-Fi 重傳可能導致亂序到達再加上 ESP32 端如果因為網絡卡頓重發了上一幀服務端需要對 seq 做一次簡單比較小于當前已處理序列號的幀直接丟棄避免音頻內容出現重復片段。4.2 流式 ASR 會話識別什么時候已經說完上行音頻到達服務端后我先累積到一個會話級緩沖里再按照 ASR 引擎要求的切片大小送識別。用的流式 ASR 模型內部會持續更新“中間結果”不斷返回 它 目前聽到的部分文字。關鍵點在于“結束判定”。我在設計里遵循了這樣的規則當 ESP32 端本地 VAD 判定說話結束會發一個“end of speech”控制幀這是我的第一重結束信號服務端 ASR 自身也會根據靜音檢測產生一個 vad_end 事件兩個信號只要有一個觸發ASR 就做一次 final 判定把完整文字交給下一環。如果只聽 ESP32 一個信號萬一本地上行網絡丟了幾幀會導致結束信號丟失服務端就一直傻等著。所以服務端 ASR 的靜音檢測作為兜底很重要。雙保險機制代碼上一點都不復雜但能避免大部分“它怎么還不回答我”的詭異問題。這里還有一個細節ASR 是流式的中間結果可能每 200ms 變化一次。想讓玩偶有“正在認真聽”的感覺可以每拿到一個中間結果就通過 0x03 文本幀返回給 ESP32ESP32 再在屏幕上顯示或讓耳朵燈閃爍。這個功能對最終體驗提升不是致命的但加了之后用戶會明顯覺得玩偶“活”了。4.3 LLM 流式輸出到 TTS再到下行音頻幀ASR 給出完整文本后我會帶著會話上下文請求 LLM。為了讓響應延遲盡可能低LLM 必須用流式輸出接口也就是一邊生成文字一邊吐出 token不能等全部生成完再返回。LLM 流式輸出的每個 token 片段被送進一個“增量 TTS 合成器”。這里有個很現實的問題TTS 引擎往往要求至少是完整的句子或合理短語才能合成自然語音如果每個 token 都立刻合成音頻會支離破碎。我采用的折中方案是按標點符號和語氣詞切句當前面累積的文字出現逗號、句號、問號、感嘆號時就把這一段提交 TTS一旦合成出第一段音頻立刻通過 WebSocket 下行推給設備不用等整個回答結束。這樣做用戶感知到的首包延遲通常能壓到 300~800ms遠比整段合成完再下發快了。而且因為 LLM 是邊生成邊提交 TTS整個下行的音頻流是連續的播放端不會出現“說一句停半句”的割裂感。下行音頻幀格式我復用了 0x02 二進制幀數據區域放 TTS 引擎輸出的 PCM 編碼。注意 TTS 輸出采樣率一定要統一設置成 16000Hz 16bit 單聲道和 ESP32 采集端一致否則 ESP32 播放時要么變速要么需要做重采樣。我最初沒統一TTS 默認輸出 24000Hz結果播放速度變快了 1.5 倍非常滑稽。4.4 下行播放與打斷Barge-in控制下行播放最核心的問題是打斷。用戶正在聽玩偶說話突然發現這不是自己想要的開口插了一句話。這時候會發生兩件事第一ESP32 本地要識別到用戶開口迅速停止播放當前下行音頻同時往服務端發一個 0x04 打斷控制幀。把揚聲器“閉嘴”的動作放在設備端而不是等服務端指令這是降低打斷延遲的關鍵。本地判斷到用戶開口到喇叭靜音延遲可以控制在 20ms 以內如果等服務端處理再回傳控制幀至少要多出 200ms 的網絡往返用戶體驗會感覺“它好像頓了一下才閉嘴”。第二服務端收到打斷幀后要立刻做三件事停止當前 TTS 合成與下發、清空下行發送隊列、重置當前 ASR 會話準備接收新的語音。Go 代碼里大概是這樣一個流程case ctrlBargeIn: tts.Cancel() session.sendQueue.Clear() session.state.Set(stateListening) asr.Reset()這里有一個容易漏掉的細節清空下行發送隊列的時候當前正在寫連接的 goroutine 可能已經拿到了一個音頻幀。所以寫循環必須也監聽一個 cancel channel當打斷發生時即使拿取幀成功也要在發送前檢查會話狀態是中斷狀態就直接丟棄。5. 連續對話的狀態機與關鍵時序5.1 用一個狀態機統一“聽、想、說、被搶話”全雙工音頻鏈路最怕狀態混亂。如果服務端還按照“收到一段音頻 → 處理 → 回復”這種線性思維設備端一旦同時發生上行和下行整個邏輯當場就會卡死。我把整個會話抽象成一個狀態機這是重構里性價比最高的一步。狀態定義如下狀態含義備注IDLE空閑等待喚醒詞或本地 VAD 觸發LISTENING傾聽中ESP32 上行音頻流入 ASRTHINKING思考中ASR 結束LLM 生成回復SPEAKING播放中TTS 音頻下行播放INTERRUPTED被打斷用戶插話清空下行WAIT_RECOVER恢復等待結束語處理回到 LISTENING狀態機的變化規則IDLE → LISTENING喚醒詞或本地 VAD 觸發LISTENING → THINKING語音結束ASR 輸出最終文本THINKING → SPEAKINGTTS 開始產生下行音頻幀SPEAKING → INTERRUPTED收到 0x04 打斷控制幀或檢測到新的上行語音能量INTERRUPTED → LISTENING清空下行隊列、重置 ASR 后立刻進入LISTENING → IDLE連續長時間無人說話超過 10 秒后釋放鏈路但不是斷開 WebSocket。這個狀態機在服務端和 ESP32 端各維護一份只不過 ESP32 端的狀態不需要有 THINKING因為它在等下行音頻時只需要表現為“待播放”。兩端通過心跳幀里的狀態字段同步出現不一致時以服務端為準。5.2 關鍵時序估算從“發聲”到“回應”做流式架構之后有個好處是各個環節的延遲可以并行疊加而不是串行相加。我拿實測數據列一份典型鏈路耗時階段耗時說明ESP32 采集并打包上送20ms基于 20ms 一個音頻塊WiFi 上行傳輸50~100ms取決于路由器同一局域網更快ASR 語音結束判定300~600ms包含靜音尾判定LLM 首個 token 輸出500~1500ms取決于模型和服務端性能TTS 合成首個音頻塊200~600ms流式合成下行傳輸 抖動緩沖100~200ms包含足夠預讀量理論總延遲1.2~3s用戶實際感覺是“說完后不到 2 秒開始回應”實際體驗中用戶從停止說話到聽到玩偶開口大約在 1 秒到 2 秒之間這比老版按鍵模式動不動 4 秒已經強了很多。再往下壓延遲主要瓶頸在 LLM 的首 token 輸出如果你用本地小模型延遲能更快但要犧牲回復質量如果接云上大模型網絡往返是硬成本只能靠流式接口盡量把首包提前。這里我強烈建議端到端延遲統計一定要拆開測不要只記住一個總數字。我在服務端給每個會話都加了分層耗時日志ASR 結束時間、LLM 首 token 時間、TTS 首塊時間、下行首包時間分別記錄問題出在哪一環看日志一眼就能定位。5.3 如何讓玩具“閉嘴”更干脆丟棄與清緩存很多人做打斷功能只在上層邏輯里喊一句“停止播放”結果還是要等當前音頻緩沖播完才靜音體驗非常拖沓。在 ESP32 端要讓喇叭立刻閉嘴必須連同 I2S 的 DMA 緩沖一起清掉。我在代碼里做了以下幾步void audio_playback_stop_now(void) { // 停止寫入新的音頻數據 playback_task_suspend(); // 清空軟件隊列 xQueueReset(play_queue); // 清空 DMA 緩沖防止舊音頻繼續播放 i2s_channel_disable(tx_chan); i2s_channel_enable(tx_chan); }i2s_channel_disable 再 enable 看起來粗暴卻是最有效的“軟復位”方式。清完之后從用戶開口到喇叭靜音整體能做到 30ms 以內耳朵基本聽不出有殘余聲音。如果只清軟件隊列喇叭還會把 DMA 里已經搬進去的音頻繼續放完通常會產生 50ms 到 100ms 的“殘留尾巴”在打斷場景里會顯得特別拖。下行播放端還需要處理一個“新舊音頻黏連”的問題。當用戶說“我要的不是這個”新 TTS 的音頻包已經開始進入 play_queue但舊音頻可能還沒清干凈。我的經驗是打斷發生時不僅要把隊列里數據全清掉還要在狀態機切到新的 SPEAKING 之前強制間隔一個 50ms 的“靜音幀”防止新老語音在前面拼接處產生爆音。6. 穩定性、延遲與內存的工程細節6.1 WiFi 不穩導致 1006 的那一晚在 WebSocket 錯誤碼里1006 是個非常特殊的碼因為按規定客戶端和服務端都不該主動發這個碼。1006 只表示“連接異常關閉”也就是說底層連接掛了但雙方都沒有收到關閉幀。我遇到過不止一次ESP32 端打印出 onclose code 1006同時服務端也莫名其妙地發現連接中斷兩個進程都沒有任何主動 close 的日志。印象最深的一次是同一個原型機在家里路由器信號弱的角落測試穩定運行了 5 分鐘然后就出現 1006。我一開始懷疑是服務端空閑連接被路由器殺了但加長應用層心跳也沒用后來抓 WiFi 日志才發現是 ESP32 的 WiFi 射頻在持續上傳大流量時和 2.4G 頻段上藍牙或者其他設備的干擾打架導致 TCP 重傳超時底層連接被內核直接斷開WebSocket 層來不及發 close 幀。解決思路有三個一是開啟 WiFi 的 TCP keepalive讓底層協議棧盡快發現死連接二是應用層心跳間隔縮短到 5 秒同時在收到心跳超時后代碼里主動 close 并重連三是 WiFi 工作模式固定為 STA關閉 BT 共存在某些型號上能減少射頻干擾。這一套組合下來我的設備已經連續跑了幾十個小時沒再出現 1006。6.2 內存優化與 DMA 緩沖預算ESP32-S3 有 512KB SRAM但其中很大一部分可能被 WiFi 協議棧、TLS 和音頻緩沖占掉。如果再用老的靜態緩沖做法很容易莫名奇妙死機。我的內存預算大致是這樣的用途內存占用說明ESP-IDF 基礎 WiFi120KB包含協議棧和驅動WebSocket 客戶端15KB緩沖和任務棧I2S DMA 緩沖8KB雙通道 8 描述符音頻發送隊列24KB最多 36 幀每幀 650B下行播放隊列48KB最多 72 幀每幀 650B其他任務和堆余量剩余用于系統抖動我把大塊的環形緩沖放在 PSRAM 里但要注意 DMA 不能直接訪問 PSRAM所以 DMA 描述符本身必須留在內部 RAM而承載數據的 buffer 放 PSRAM 是可以的只要先通過 i2s_channel_read 讀到內部 RAM 小緩沖區再拷貝到 PSRAM 大隊列里。這個做法能顯著提高內存余量代價是多一次拷貝但在 20ms 的音頻塊尺度下一次 memcpy 性能損失可以忽略。還有一個讓我踩過的坑在 ESP-IDF 項目里同時啟用 BLE 和 WiFi會把內部 RAM 占用推高很多。如果你不需要藍牙編譯時把 BLE 功能關掉內存會寬裕不少。6.3 OTA 升級和日志給現場玩偶留一條后路玩偶是實體產品燒錄一次程序容易但發給用戶之后再想改邏輯沒有 OTA 簡直是一場災難。ESP32 的 OTA 升級是一個經典操作我把 WebSocket 音頻鏈路和 OTA 分成兩個獨立邏輯平時走 WebSocket 長連接升級時服務端下發一條 0x04 控制幀內容是“進入 OTA 模式”ESP32 收到后停止音頻鏈路然后從 OTA 服務器拉取固件。OTA 分區表需要提前分好我的 partition 大致是這樣nvs0x9000存放配置otadata0x2000OTA 狀態app00x200000主應用app10x200000OTA 備份。OTA 升級過程中HTTP 下載固件會占滿網絡帶寬和 WebSocket 音頻鏈路完全沖突。所以設計上必須嚴格串行先斷開 WebSocket再做 OTA升級完重啟后重新連。否則音頻幀和固件數據在同一個網口搶帶寬可能導致固件下載超時失敗。日志方面ESP32 的串口日志在設備端調試時好使但設備一旦裝進玩偶外殼串口就沒了。我做的方案是把關鍵日志封裝成 0x03 文本幀回傳服務端按設備 ID 存一份滾動日志文件。這樣就算外殼完全封閉我依然能看到設備運行時的每一次狀態切換、心跳超時和錯誤碼。遇到用戶反饋問題我直接查服務端日志就能還原現場。7. 踩坑記錄與問題速查表7.1 必看排查速查表我把實際排查過程中遇到頻率最高的問題和對應解法整理成了一張表直接按圖索驥就行。現象可能原因排查方法解決方案WebSocket 連不上報 1006網絡不穩定、TCP 半開連接抓包看 TCP 重傳看心跳是否超時縮短心跳到 5s應用層主動重連開啟 TCP keepalive語音識別文字斷斷續續音頻幀丟失或亂序服務端打印 seq看是否有空洞增加播放端 jitter buffer服務端用 seq 去重和重排序玩偶播放聲音變快/變調TTS 采樣率和 I2S 播放采樣率不一致打印 TTS 音頻采樣率服務端強制 TTS 輸出 16000Hz 16bit 單聲道打斷時喇叭還有殘余聲音DMA 緩沖沒有清理聽感判斷或示波器看音頻波形i2s_channel_disable/enable 清 DMA播放有滋滋聲且只在播放大音量時出現電源紋波大功放與麥克風共享不干凈電源用示波器看功放供電紋波加電解電容去耦電源分路供電長時間運行后內存不足崩潰任務棧太小或靜態緩沖占用過大打開 heap debugging查看任務棧高水位任務棧加大數據緩沖移到 PSRAMESP32 突然重啟但日志沒異常看門狗超時任務卡在 I2S 讀操作打開 Task WDT查看卡住的任務給 I2S 讀操作加超時任務循環里喂狗TTS 首包延遲高但之后正常TTS 合成器被阻塞比如申請了串行鎖看服務端并發日志有沒有鎖等待TTS 引擎實例化多個 goroutine或做請求隊列隔離玩偶一段時間不響應語音本地 VAD 閾值過高或麥克風積灰查看上行流量統計看是否有音頻發送調低閾值添加 AGC 自動增益定期清理麥克風這張表里的問題我基本都實際遇到過其中 1006 和 DMA 殘余聲音是最影響體驗的兩個建議大家在開發階段就做好可視化日志否則現場排查會非常痛苦。7.2 還踩過的幾個小坑最后把我一直想單獨拎出來說的幾個“非典型坑”分享給大家這些問題是官方文檔通常不會講清楚的。第一個是麥克風和功放共地的問題。我把 INMP441 和 MAX98357A 接到同一個 3.3V 電源上用了杜邦線連接一開始沒有任何去耦電容。結果一旦喇叭輸出大音量麥克風采到的音頻就會被電源紋波污染。這種污染不是簡單的底噪而是帶有明顯包絡的哼聲ASR 在播放大音量時幾乎無法識別。后來我把電源線加粗在功放電源引腳旁并聯了 100uF 和 0.1uF 電容問題大幅緩解。做音頻硬件電源潔凈度永遠要排在第一位。第二個是 ESP32 WebSocket 客戶端發送大幀時的經驗。esp_websocket_client_send_bin 有一個超時參數如果你發送較長的音頻幀在 WiFi 信號差的時候可能因為底層阻塞而超時返回失敗。我一開始沒檢查返回值結果部分音頻幀在弱信號時被靜默丟棄表現是玩偶聽不清長句。后來我在發送失敗時保留幀并重試一次重試還是失敗就把會話狀態重置效果穩定很多。第三個是關于語音識別結束信號的“雙保險”。早期我只依賴服務端 ASR 的靜音判定當用戶說話聲音很小或者背景安靜時識別結束非常快但當環境里有一點風扇聲靜音判定就永遠不觸發玩偶就會一直“聽”不回答。加上 ESP32 本地 VAD 的結束信號后問題基本消失。兩端各自獨立判斷誰先觸發結束都可以這比單一來源可靠得多。7.3 下一步想做的事這次重構把 WebSocket 二進制音頻鏈路打通之后我至少已經解決了從“能對話”到“連續對話”的全部核心問題全雙工傳輸、流式識別、流式合成、打斷控制、狀態機、穩定性優化。現在玩偶在安靜家庭環境里已經能比較自然地實現多輪對話用戶說話時可以隨時插話玩偶也會斷句式地合成回復不用再等待轉圈。下一步我想做兩個方向一是把音頻編碼從裸 PCM 換成 Opus。PCM 在 16kHz 16bit 單聲道時是 32KB/s 的流量對一般 WiFi 來說完全沒壓力但如果以后要做電池供電和蜂窩網絡版本流量和功耗都是問題。Opus 在 12kbps 到 24kbps 就能保持不錯的語音質量只是 ESP32 側的編碼庫和內存開銷還要調優。二是在本地加入更豐富的喚醒交互比如讓玩偶通過檢測用戶音量來調整自己回答狀態甚至能夠識別用戶情緒。這些后續再單獨寫文章分享。最后分享我實際的體會從“能對話”走向“連續對話”技術難點其實不在某一個點上而在于把全雙工鏈路、流式處理、打斷控制、狀態機這些環節耐心地串起來。每一個環節都不是無解的難點但它們彼此耦合一旦某個點沒有處理好整體體驗就會回到對講機時代。我在這個項目里最大的感悟是設計協議時多做一層的序列號和時間戳實現音視頻鏈路時永遠校驗每一個返回值調試交互時一定要把自己當成三歲小朋友把他們毫無邏輯的打斷和搶話當成正常輸入。這三件事做到位你的 AI 玩偶大概率就已經跑在真正的“連續對話”賽道上了。