前阵子把一颗ESP32-S3焊成了一块能聊天的板子:接上麦克风和喇叭,通电联网后,直接对着它说一句话,它顿个一两秒,然后用极其自然的语气回我。整个过程没有手机App,没有电脑中转,也没有按键触发。很多人以为这种实时语音对话必须靠树莓派或者手机才能跑,但实际上,在豆包端到端实时语音这套方案面前,ESP32-S3这个级别的芯片就已经足够。这篇文章就是把我从开箱到跑通、再到踩坑调优的完整过程写下来,给那些想低成本做语音硬件、又对端到端链路感兴趣的朋友一条能直接照做的路。先说清楚:这里说的不是调通一个SDK就算完,而是真正理解为什么能“开箱即用”,以及卡住时该怎么排。
1. 选型逻辑:为什么是ESP32-S3,而不是树莓派或手机
1.1 端到端实时语音对硬件的真实需求
先拆一下题目里的“端到端”。传统语音助手是ASR(语音识别)→ LLM(大模型)→ TTS(语音合成)三级级联,每一级都是一个独立模型,各自处理完再传给下一级。问题在于:ASR丢失语气,TTS丢掉语义,中间的文本转写还会吞掉“嗯、啊、停顿时长”这些副语言信息,听感就是又硬又假。而端到端语音方案,比如豆包这套实时语音接口,是音频直接进模型,音频直接出模型,中间不做显式文本中转。识别、理解、合成全部塞进一条链路里。听感上最明显的变化是:它会像真人一样带呼吸声、带停顿、带情绪。
那这对设备端算力有什么要求?没有想象中高,因为真正的推理发生在云端。设备端要干的活只有四件:采集音频、推流上行、接收下行、播放音频。这四件事都不需要大算力,但需要低延迟、稳定的IO和够用的内存。ESP32-S3双核240MHz,带SIMD指令,板上有I2S、PDM、ADC/DAC通道,再挂一块PSRAM,处理24kHz的PCM音频流绰绰有余。这也是为什么它能在这种场景里当主角,而不是被树莓派取代。
顺带说一句,现在“端到端”这个词在智驾圈也很热,那个是感知、决策、控制一体化。语音这边的端到端是识别、理解、合成一体化,两者本质思路一样,都是减少中间模块的信息截断,但别把两件事混着看。
1.2 ESP32-S3与树莓派Zero、老ESP32、手机方案的取舍
想把对话设备做成一个真正“像硬件”的东西,候选方案其实不少,但每个都有明显短板。我做了一张对比表,按实际体验排过序:
| 方案 | 开发成本 | 音频IO质量 | 延迟可控性 | 体积功耗 | 上手难度 |
|---|---|---|---|---|---|
| ESP32-S3 + codec | 低 | 中上,需要外接或板载codec | 好,全链路本地控制 | 极小,可电池 | 中等,C/IDF有上手门槛 |
| 树莓派Zero 2W | 中 | 上,USB声卡或I2S均可 | 一般,Linux调度不可控 | 大,功耗高 | 低,Python生态成熟 |
| 老ESP32 | 低 | 中 | 一般 | 极小 | 低,但内存吃紧 |
| 手机改装 | 高 | 上 | 差,系统占用严重 | 大 | 高,改造成本不划算 |
老ESP32的问题很典型:内存只有520KB SRAM,跑完WiFi协议栈和TLS握手之后,剩余的堆空间已经不够容纳足够深的音频缓冲。端到端语音对瞬时抖动的容忍度比普通HTTP请求低得多,缓冲稍浅,网络一抖就断音。树莓派Zero看着算力强,但它要开机、要跑系统,纯做外设控制时反而是累赘。手机更不用说——它本身就能装豆包App,再套一层壳去连豆包API纯属脱裤子放屁。
所以ESP32-S3是眼下最甜的点:算力刚刚好,外设刚刚好,功耗刚刚好,而且乐鑫和火山方舟这边已经有人把云端对接部分抽成了现成Demo,我们自己要做的更多是配置和打磨。
2. 开箱到能说话:硬件清单、烧录与首次上电
2.1 一套能直接复刻的硬件组合
先说结论:如果你想最接近“开箱即用”,直接买乐鑫官方的ESP32-S3-Korvo-2开发板。这块板子板载了双麦克风、ES8311音频codec、喇叭接口,还留了PSRAM,音频采集和播放的硬件链路全部在板上,不需要自己飞线。我最初图便宜,用合宙ESP32-S3开发板搭了一套:外接INMP441 PDM麦克风采集,MAX98357A功放推3W喇叭。这套也能跑,但调试时多了很多GPIO对齐、电源去耦的活。
如果选择自己搭,连线时要注意:INMP441是PDM数字麦克风,接到ESP32-S3的PDM引脚上,采样时钟和数据线一定要短,避免WiFi射频干扰;MAX98357A是I2S输入功放,BCLK、LRCK、DIN三根线和ESP32-S3对应,SD模式脚要接一个确定的电平,不能悬空。麦克风别用驻极体模拟麦直接进ADC,WiFi一开,底噪能盖住人声。
电源是整个项目里最容易被低估的环节。我建议直接上5V/2A的独立电源,不要用电脑USB口凑合。原因后面在踩坑章节细说,这里先记住:ESP32-S3拉满WiFi瞬时电流能到500mA以上,劣质USB线压降一大,整块板子就会在射频和音频之间互相牵扯。
2.2 开发框架选择:ESP-ADF vs ESP-IDF vs MicroPython
很多人在热搜里搜“esp32-s3 micropython”,我先给结论:这个项目别用MicroPython。MicroPython控制GPIO、I2C非常舒服,但实时双向音频需要稳定的定时采样、低抖动DMA回调和快速搬运buffer,MicroPython的解释执行加上内存管理机制,在高频音频流下很容易造成周期性卡顿。你很难在Python回调里维持一个严格的40ms音频帧节奏。
ESP-ADF是乐鑫官方音频框架,封装了audio pipeline、audio_element这些抽象层,直接拿来做播放器、录音机很方便。但对实时语音对话这种需要灵活控制全双工链路的场景,ADF的框架反而有点重,有些底层参数藏在抽象层后面,不好调。我最后用的是ESP-IDF直接写C,要哪块用哪块:I2S自己配置、WebSocket用官方组件、协议解析手写状态机。代码量可控,而且出了问题能直接追到寄存器层面。
至于官方Demo,确实做到了“开箱即用”的效果:烧录后改一下WiFi SSID、密码和API Key,接好音频外设就能跑。只是Demo默认的音频参数、缓冲区大小不一定适合你的板子和网络环境,所以我下面的链路拆解才是真正有用的部分。
2.3 烧录与首次上电验证
烧录步骤很简单:装好ESP-IDF(我用的v5.2),把官方Demo工程clone下来,执行idf.py set-target esp32s3,再idf.py build flash monitor。如果你的板子是Korvo-2,默认sdkconfig基本能用;如果是自组的合宙板,需要先改sdkconfig里的I2S引脚和codec型号。
首次上电不要急着连豆包。先做两个基础验证:第一,串口日志能看到WiFi成功连接、拿到IP;第二,做一次本地音频回环——用Demo里的loopback例程,让麦克风采集的声音直接从喇叭放出来。如果回环听到的是清晰的自己说话声,说明音频链路是通的;如果只有电流声或完全没声,先回头查GPIO和codec供电。
配网这一步,官方的Demo一般支持软AP配网或者BLE配对配网。这里很多人搜“esp32-s3蓝牙配对”,实际指的不是蓝牙音频,而是用BLE把WiFi凭据发给设备。BLE配对本身不难,难的是供电不稳时射频模块状态异常,这个坑我放在第六节重点讲。
3. 豆包实时语音协议的对接细节
3.1 从“为什么豆包AI请求格式是input而不是message”讲起
网上有个高频问题:为什么豆包的AI请求格式是input而不是message?如果你写过OpenAI的Chat接口,脑子里全是messages数组,每轮对话一个message。但豆包的实时语音接口走的是全双工流式协议,它把所有上行数据统一叫input——不管是一帧音频、一句文本、还是一条控制指令,在模型看来都是同一条输入流的一部分。
这个区别直接影响客户端设计:你不能像OpenAI那样在每轮请求里反复携带历史消息,而应该把音频帧持续塞进同一条连接。模型根据音频流自己判断什么时候该回话,什么时候该闭嘴。所以代码里发音频和发文本走的往往是同一个input字段类型,只是payload内容不同。理解了这一点,你才不会拿OpenAI那套思维硬套豆包协议。
3.2 WebSocket建连与鉴权流程
豆包实时语音API的底层是WebSocket,跑在TLS之上。建连流程分三步:
- 从火山方舟控制台或豆包开放平台拿到API Key/Access Token,以及对应区域的WebSocket Endpoint。
- 客户端发起wss握手,把Key放在
Authorization请求头里。 - 握手成功后,客户端先发一个
start类型的事件,表明我要发起一次实时语音会话,并附上音频参数。
start事件的JSON结构我按实际能跑通的格式简化如下,具体字段名请以你手头SDK版本的控制台文档为准:
{ "type": "start", "payload": { "audio": { "sample_rate": 24000, "channels": 1, "bits": 16, "codec": "opus" }, "session": { "id": "esp32s3-desk-box-001" } } }鉴权失败最常见的几个原因:Key头写成了查参、大小写复制错、设备系统时钟偏差过大导致握手时间戳校验失败。第三个比较隐蔽,我遇到过板子RTC时间没同步,TLS握手直接被服务端拒绝。解决方法是上电后先做一次NTP时间同步再建连。
3.3 三种事件流:音频上行、文本/音频下行、状态控制
实时语音连接建立后,同时存在三类数据流动:
上行音频:把麦克风采集的PCM或Opus编码帧通过WebSocket二进制帧发出去。这是最主要的流量,节奏固定,一帧一帧推。
下行音频/文本:服务端返回的语音回复通常以Opus或PCM形式下发,同时会附带一条文本信息,相当于实时字幕。文本事件可以用来做显示,也可以用来调试——如果音频卡了,但文本一直在更新,至少能判断云端还在正常工作。
状态控制:包括会话开始、会话结束、错误信息、计量信息,以及最重要的“打断”事件——用户插话时服务端会下发一个控制事件,通知客户端停止播放当前内容。
客户端最好给这三类数据各建一个队列,由一个状态机统一调度。不要把WebSocket回调里直接做播放或录音操作,回调里的任务栈很小,稍一复杂就崩。
4. 核心代码链路:从麦克风到扬声器
4.1 整体任务划分
ESP32-S3跑的是FreeRTOS,实时语音链路至少需要拆成四个任务:
- WiFi连接与WebSocket连接管理任务:负责建连、重连、心跳维持。
- 音频采集任务:从I2S读PCM数据,做增益处理,通过队列交给推流任务。
- 推流任务:从队列取音频帧,通过WebSocket二进制接口发送。
- 接收播放任务:从WebSocket接收事件,解析音频帧,写入I2S播放。
这四个任务之间用FreeRTOS队列或环形缓冲区传递数据。我用的是xQueueSend配合xQueueReceive,队列深度设置为10帧左右,太小会导致采集端阻塞,太大会增加延迟。
要注意的是:WebSocket接收回调运行在TCP/IP任务上下文中,绝对不能在里面做动态内存分配或长时间运算。正确做法是回调里只做事件识别,用队列把数据和事件类型转发给专门的协议解析任务。
4.2 音频采集与音量归一化
音频采集配置为24kHz采样率、16bit量化、单声道。这里之所以用24kHz而不是更高,是因为服务端模型大多数采样率设置就是24k或16k,匹配之后不用做重采样,省掉一道质量损耗环节。
I2S采集到的原始PCM数据,幅度很可能只在几百的量级,因为麦克风增益不足。我写了一个简单的音量归一化逻辑:
static void apply_gain_and_agc(int16_t *buf, size_t samples) { int64_t sum = 0; int16_t peak = 0; for (size_t i = 0; i < samples; i++) { int16_t v = abs(buf[i]); sum += v; if (v > peak) peak = v; } int32_t avg = (int32_t)(sum / samples); if (avg > 0) { // 目标平均幅度约2000,限制最大增益避免削波 float gain = 2000.0f / avg; if (gain > 8.0f) gain = 8.0f; if (gain < 1.0f) gain = 1.0f; for (size_t i = 0; i < samples; i++) buf[i] = (int16_t)(buf[i] * gain); } }这个AGC逻辑很简单,只在静音和正常语音之间浮动,不引入过多噪音放大。条件是要先在代码里设置一个合理的启动增益,我的经验是初始增益设成2,然后让AGC慢慢调整,而不是一开始全速放大。
4.3 WebSocket推流与VAD
推流节奏是40ms一帧,每帧约1920字节(24kHz * 2字节 * 0.04s)。发送时直接走esp_websocket_client_send_bin,发送完不要立刻返回等下一帧,而是交给TCP缓冲区,然后vTaskDelay(10ms)左右,防止连续大帧把系统调度打乱。
VAD(静音检测)我用的不是算法库,就是一个能量阈值。判断逻辑很简单:
static bool is_voice_active(const int16_t *buf, size_t samples) { int64_t sum = 0; for (size_t i = 0; i < samples; i++) sum += abs(buf[i]); return (sum / samples) > VAD_THRESHOLD; }VAD_THRESHOLD需要实测调整。我房间底噪大约在150左右,人正常说话平均幅度在1800以上,阈值设在500既不会漏报也不会被风扇声误触发。VAD的用处有两个:静音时段不推流,节省上行带宽;配合打断检测,用户开口时立刻降低本地播放音量。
4.4 接收与播放:音频缓冲怎么管理
下行音频播放是延迟和卡顿博弈最明显的地方。我用的缓冲深度在150ms左右,也就是约15帧WebSocket下行音频。缓冲太深,对话延迟感拉满;太浅,网络抖动一次就卡一下。
实际播放任务从环形缓冲区里读取数据,循环写入I2S发送通道。如果缓冲区空了,播放任务会主动填充一小段静音,而不是卡死等待,这样至少不会出现电流爆音。
如果服务端返回的是Opus编码,还要集成libopus解码器。ESP32-S3跑Opus解码,占用一个核心大约20%的CPU,完全没问题。解码后的PCM再写入同一个环形缓冲。我在工程里把解码器单独封装成了一个opus_decoder_handle_t,方便后续换其他编码格式。
4.5 断线重连与看门狗
实时语音最怕的就是断线后状态卡死。我做一个简单的状态机:
typedef enum { STATE_BOOT, STATE_WIFI_CONNECTING, STATE_WIFI_CONNECTED, STATE_WS_CONNECTING, STATE_WS_CONNECTED, STATE_CONVERSATION, STATE_RECONNECT_WAIT, STATE_ERROR } app_state_t;WiFi断了,状态回到WIFI_CONNECTING;WebSocket断了,进入RECONNECT_WAIT,按1秒、2秒、4秒的指数退避重试。另外用esp_task_wdt_add把采集任务和播放任务都挂上看门狗,防止代码里某个阻塞调用卡死整个链路。我在这上面吃过一次亏:某次播放任务在等待I2S TX队列时没设超时,网络断开后整个任务永不返回,板子看起来像死了,后来加了看门狗才定位到。
5. 实测:低延迟、全双工和音质的调优
5.1 我实测的延迟分布
搭好之后,我用手机秒表粗略测了几组数据:
| 节点 | 端到端方案 | 传统ASR+LLM+TTS级联方案 |
|---|---|---|
| 说完到第一字回复 | 0.6秒-1.2秒 | 1.5秒-2.5秒 |
| 句间整体响应 | 接近自然对话 | 明显停顿感 |
| 语气自然度 | 高,带停顿和情绪 | 低,机械感强 |
第一字延迟和句间延迟的差异,主要来自端到端模型不需要等整句识别完再开始理解,也不等整句合成完再开始播放。它一边听一边生成,模型内部的流式解码决定了首包出来的时间。网络环境正常、服务端区域就近的情况下,体感很接近人和人对话。
当然,不要神话“端到端”,延迟还有一部分是网络RTT和WebSocket包头,这部分是加在设备端的硬开销。测试时如果第一字延迟超过2秒,先ping一下服务端域名,看网络延迟是不是飘了。
5.2 全双工下的打断处理
全双工意味着用户可以在AI说话的同时说话。我实测豆包端到端语音是支持打断的:我正听到一半插一句,几毫秒后服务端下发一个控制事件,当前合成音频被截断,模型开始处理我的新输入。
客户端侧要做两步配合:
- 本地检测到人声时,立即降低播放音量而不是直接静音,这样用户能听到自己在说什么,也方便判断AI是否已停止。
- 收到服务端的打断事件后,把播放环形缓冲全部清空,并把DAC输出静音100ms,清掉功放余音。
第一次实现时我没清缓冲,结果AI的话都已经被打断了,缓冲区里还残留着后半句,等静默过去后突然冒出来一句“……你觉得呢”,非常诡异。打断事件处理要快、要彻底,这是全双工体验的核心。
5.3 音质相关的三个参数
音质调优时我只动三个参数,其他保持默认。
采样率稳定在24kHz,位深16bit。如果服务端支持48kHz,从24k升到48k并不会让听感变好,模型本身训练用的很可能就是24k。
麦克风增益启动值设置在2倍以下。很多人一上来把增益调到8倍,人声是大声了,但底噪也被抬到不可忍受,AGC还会不停抽风。
扬声器增益不要推到功放削波。MAX98357A之类的小功放推3W喇叭时,音量设到60%和90%差别不大,90%就开始破音。宁可音量偏小,也要保持声音干净。
6. 踩坑清单:这10个坑我提前替你踩了
6.1 蓝牙配对反复失败,结果是供电问题
我最初用BLE配网,手机App一直搜索不到设备,偶尔搜到了也配对秒断。一开始怀疑是不是硬件射频有问题,换了一个板子还是这样。最后用USB电流表一测:劣质USB线在WiFi发射瞬间压降到了3.1V,BLE射频工作都不正常。换了一根粗线,配网秒成。ESP32-S3的WiFi和BLE对供电纹波极其敏感,这是排在第一位的坑。
6.2 codec I2C地址冲突和I2S时钟极性
ES8311这类codec芯片的I2C地址默认是0x18,如果你的板子上还有别的I2C设备用了同一地址,会互相干扰。解决办法是调整codec的ADDR引脚电平改地址,或者在驱动层改I2C_ADDR宏定义。
I2S时钟极性更隐蔽。BCLK或LRCK的极性配置反了,声音不会完全无声,而是变成“沙沙的糊声”,或者音量极低。我一开始以为codec坏了,换了芯片还是一样,最后对比Datasheet才发现是invert_bclk这个参数的问题。遇到声音异常,先优先怀疑时钟配置。
6.3 上电爆音与GPIO浮空
每次上电喇叭都会“啪”一声,原因不是软件,是功放芯片的SD引脚在芯片初始化前处于浮空状态。硬件上给SD引脚加一个10k下拉电阻到GND,软件上在初始化序列里把GPIO先设为低电平,最后初始化完功放再拉高。顺序反了,爆音照旧。
6.4 内存规划:没有PSRAM会怎样
如果你买的板子不带PSRAM,跑实时语音会非常难受。Opus解码器本身占几十KB,WebSocket收发buffer、DMA buffer、FreeRTOS任务栈再一占,剩下的堆空间喂几个音频帧队列就没了。我实测过没有PSRAM的板子,对话进行到第十几秒时直接abort()重启。所以买板子认准带8MB PSRAM的版本,并且把大块buffer都放在PSRAM里,SRAM留给实时性要求高的任务。
6.5 外网连接不稳:先排除内网环境
WiFi连着但WebSocket频繁断,不是代码问题,是内网认证的问题。公司网、校园网常有网页认证或白名单,WebSocket长连接刚建好就被网关踢掉。先开手机热点验证拨通链路,再回正常网络环境调优。DNS解析异常也是一大类原因,建议在代码里直接写死可用的DNS服务器地址。
7. 下一步玩法:离线唤醒、低功耗和更多模型
7.1 加离线唤醒词
端到端语音虽然厉害,但如果一直保持WebSocket长连接,设备每时每刻都在耗电、占网络。更合理的结构是加一个本地唤醒词模块,用乐鑫的ESP-SR做“你好豆包”这类离线唤醒。平时设备处于低功耗监听状态,唤醒词一触发,再建立WebSocket连接,开始往上推音频。唤醒词和端到端链路不冲突,它们是最外层的前端和主对话链路的关系。
7.2 电池供电与低功耗策略
如果想做便携设备,功耗要专门设计。ESP32-S3在WiFi连接时的平均电流在80mA-160mA之间,2000mAh的锂电池大概能撑十几个小时,做桌面设备直接插电更省心。低功耗策略的核心是:没唤醒时不开WiFi,唤醒后再快速连网,对话结束闲置10秒自动断连进入轻度睡眠。
7.3 换成其他大模型服务的适配思路
这套代码最有价值的地方在于协议层和音频层解耦。只要目标服务也是“WSS + 二进制音频帧”的实时语音协议,改URL、改鉴权方式、改事件类型字段名,就能复用整个音频采集和播放链路。我后面又接了一个其他家的实时语音接口,只花了半天,主要是把start事件的字段结构对齐。所以即使你现在不确定用哪家,先把ESP32-S3这层音频框架搭好,协议层都是可以替换的。
最后说一个个人体会:做完这套东西之后,我才真正理解“端到端”这三个字的意义。不是技术上的炫技,而是它让机器说话终于像人说话了。如果你也照着这条路搭到一半卡住了,先别怀疑代码逻辑,第一步查电源、查GPIO电平、查I2S时钟配置,第二步再怀疑协议。等到设备真的能和你一来一回对话时,那种成就感很值得。