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