ESP32语音采集失败后别让旧音频继续过关
语音设备最难排查的故障,有时不是“完全没声音”,而是流程看起来一直正常。采集回调照常返回,算法继续收数据,网络也能发包。可这次送进去的音频,真的读成功了吗? 如果程序先把一块缓冲标成有效,再去问驱动读取是否成功,失败就可能被包装成一次正常采集。缓冲里留下的旧内容,甚至一次失败读取已经改写的局部内容,都有机会跟着“有效长度”一起过关。 下面从一块 ESP32-S3 语音核心板出发,把这个边界拆开。电路负责把声音送到正确入口,软件则必须证明这次拿到的数据可以交给后级。两件事缺一件,后面的降噪和识别都不好定位问题。 ## 先认准声音进来的那条线 这里核对的是 CoreBoard V1.0。它把采集和播放分给不同器件,麦克风通路进入 ES7210,播放走 ES8311,再送到 NS4150B 功放。不能因为看见一颗“音频 Codec”,就把所有录音问题都归到 ES8311。  图1 由真实 MCU 图页重新导出的 I2S 局部,版本为 V1.0。 这几个信号要按主控的方向读。MCLK 是 GPIO42,BCLK 是 GPIO40,WS 是 GPIO39;GPIO41 把播放数据送往 ES8311,GPIO15 接收 ES7210 的采集数据,GPIO4 控制功放。控制总线使用 SCL GPIO1、SDA GPIO2。 同一套 BSP 也支持 V1.2,但那一版把采集输入和功放控制分别改成了 GPIO3、GPIO15。板子是 V1.0,配置却选了 V1.2,GPIO15 就会承担完全不同的角色。排查前应先对照板号和 `CONFIG_BSP_PCB_VERSION_V1_0`,而不是盯着软件里的 DIN 名字猜接法。  图2 U5 的数字接口局部。`SDOUT1/TDMOUT` 接到 `I2S_DI`,这里的 DI 是主控的数据输入。 还有一个容易误解的地方。电路中的 `OUTP/OUTN` 从 ES8311 输出,一路送到功放输入,另一路经阻容网络回到 ES7210 的 `ADC_MIC3_P/N`,作为回声消除的参考通路。它取自功放之前,不是把扬声器端的 VOP/VON 直接接回 ADC。参考通道也不等于又多了一只环境麦克风。 在 PCB 上先确认板面和接口位置,再去找软件入口。下面两图来自同一个 V1.0 工程,只用于板面定位和版本核对;本次没有修改走线或元件。  图3 PCB 顶面。下载、导出与软件核验使用同一 CoreBoard V1.0。  图4 底面图层仍按从顶面向下看的方向显示,因此底层丝印反向,不能据此倒推焊接方向。 ## 报错为什么没有挡住这块数据 在固定版本的 `audio_processor.c` 中,录音入口回调先写 `load->valid_size = wanted_size`,随后调用 `esp_codec_dev_read()`,最后返回 `wanted_size`。中间的驱动返回值没有参与判断。 这相当于在货物到齐之前,先签了“全数收到”。假设这次想要128字节,缓冲原本保留着上一轮内容;驱动这次失败,回调依旧告诉后级128字节有效。若失败发生在部分缓冲已被改写之后,这块数据还可能是新旧内容的混合。 这里要分清三个量。 - `buf_length` 是缓冲容量,回答“最多装多少”。 - `valid_size` 是这一次可交付的数据长度,回答“本次确认拿到多少”。 - `esp_codec_dev_read()` 的返回值是状态码,回答“调用是否成功”。成功值是 `ESP_CODEC_DEV_OK`,不能把某个正的错误码理解成读取字节数。 GMF 端口又有自己的约定。核对的 v0.6 实现以非负返回判断 acquire 成功,载荷长度放在 payload 里。因此原回调返回128,也会落进成功分支。修订后可以返回 `ESP_GMF_IO_OK`,同时通过 `valid_size` 明确交付长度。 问题不在于128这个数特殊,而在于**驱动成功与数据交付之间缺了一道判断**。 ## 把有效长度留到最后再提交 改法很小,顺序却很关键。先撤销上一轮的有效状态,检查句柄、缓冲和容量,再读取;只有驱动明确成功,才写入本轮有效长度。 下面是完整的检查函数。放到已有 `esp_codec_dev.h`、`esp_gmf_payload.h` 等声明之后即可。函数指针是为了在主机测试中注入错误,实际板端仍调用原来的 Codec API。 ```c /* SPDX-License-Identifier: MIT */ /* Include after esp_codec_dev.h and esp_gmf_payload.h. */ typedef int (*codec_read_fn)(esp_codec_dev_handle_t, void *, int); static int checked_record_read(esp_codec_dev_handle_t dev, esp_gmf_payload_t *load, int wanted_size, codec_read_fn read_fn) { if (load == NULL) { return ESP_GMF_IO_FAIL; } load->valid_size = 0; load->is_done = false; if (dev == NULL || load->buf == NULL || read_fn == NULL || wanted_size load->buf_length) { return ESP_GMF_IO_FAIL; } int status = read_fn(dev, load->buf, wanted_size); if (status != ESP_CODEC_DEV_OK) { return ESP_GMF_IO_FAIL; } load->valid_size = (size_t)wanted_size; return ESP_GMF_IO_OK; } ``` 然后在 `audio_processor.c` 中替换原有同名回调,不能把新旧两份定义一起留下。 ```c static int recorder_inport_acquire_read( void *handle, esp_gmf_payload_t *load, int wanted_size, int block_ticks) { (void)handle; (void)block_ticks; return checked_record_read( audio_manager.rec_dev, load, wanted_size, esp_codec_dev_read); } ``` `valid_size = 0` 要在参数检查和驱动调用之前执行。否则上轮成功的128字节,还可能残留在这次失败的 payload 上。实时录音中的 `is_done` 也先清为 false,读取失败不能被解释成正常流结束。 容量检查同样不能省。先拒绝零和负的 `wanted_size`,再转为 `size_t` 比较,避免负数转换后变成巨大无符号数。容量不够就返回错误,不应在这个边界悄悄截短读请求,因为下游可能按完整音频块解释数据。 失败时不需要靠清空整个缓冲来“修好音频”。缓冲仍可能含有旧字节,但它不再被声明为有效。真正的约束是后级必须遵守错误返回和有效长度。若其他任务绕过接口直接读这片内存,这个函数也不能替它建立并发安全。 另外,`block_ticks` 在这个回调中没有传给 Codec API。本次修改没有增加超时能力,也没有决定设备关闭后的重启策略。把错误归一化为 `ESP_GMF_IO_FAIL` 能挡住假成功;定位具体驱动错误时,可在失败分支增加限频日志,记录原始 `status`,不应把它直接当成 GMF 字节数返回。 ## 让失败主动发生一次 只跑成功路径,看不出这处修改有没有用。验证时,先把128字节全部填成 `0xA5`,再用一个假驱动只改写前8字节为 `0x3C`,随后返回错误。这个输入故意模拟“缓冲已经被碰过,但本次操作失败”的情况。 测试驱动的核心如下,和上面的函数使用同一份真实 GMF payload 声明。 ```c #include #include static int fail_after_prefix( esp_codec_dev_handle_t dev, void *dst, int size) { (void)dev; (void)size; memset(dst, 0x3c, 8); return -1; /* 注入非零失败状态 */ } /* 在主机测试或专门的测试入口中执行 */ static void test_failed_read(esp_codec_dev_handle_t dev) { uint8_t buf[128]; memset(buf, 0xa5, sizeof(buf)); esp_gmf_payload_t p = { .buf = buf, .buf_length = sizeof(buf), .valid_size = sizeof(buf), .is_done = true, }; int rc = checked_record_read( dev, &p, sizeof(buf), fail_after_prefix); assert(rc == ESP_GMF_IO_FAIL); assert(p.valid_size == 0 && !p.is_done); assert(buf[0] == 0x3c && buf[127] == 0xa5); } ``` 这里传入的 `dev` 必须是非空测试句柄。假驱动不访问设备,所以不需要真的发起 I2S 读取;在主机测试里可用局部对象地址充当句柄。不能把这个假驱动留在正式录音链路中。  图5 故障注入结果与缓冲示意。图中的字节来自合成输入,不是麦克风波形或板端实测。 同样的错误输入,原回调得到 `ret=128, valid=128`;检查函数得到 `ret=-1, valid=0`。前8字节依旧被改过,后120字节依旧保留 `A5`,但整块数据不会再以“本次有效音频”的身份交付。 主机验证还覆盖了成功读取、请求超过容量、零长度、负长度、空指针和空句柄;对成功及多种错误状态做了10000次交替,检查上一轮的有效长度不会泄漏到下一轮。特意加入正数128作为错误状态,确认它不会因为“看起来像长度”就被当成成功。 这些测试证明的是回调的提交规则,不是识别率提升,也不是整机故障已经消失。真实驱动未必会按这里的方式部分改写缓冲;故障注入用于检验接口面对这种失败时仍能守住边界。 ## 上板时还要验收什么 集成位置是 `core/brookesia_core/ai_framework/agent/audio_processor.c`。固定源码中,这个回调注册到包含 `ai_afe` 的录音流水线;启用按键对话等其他录音路径时,必须另外确认是否经过同一入口,不能只替换函数就宣称所有模式都覆盖了。 获取下方固定提交的完整仓库,在原有 `products/speaker` 工程中修改,使用工程要求的 ESP-IDF 5.5或兼容环境,选择实际板卡版本后重新构建。这里没有提供可直接烧录的固件,也没有完成整套目标工程构建,不能用主机测试通过代替 `idf.py build`。 板端验收可分三步。先确认成功路径持续给出正确长度,再在专门测试配置中注入一次错误,观察 GMF 任务的错误状态和后级是否停止消费该块数据;最后恢复真实驱动,核对停止、关闭、重开设备的生命周期。出现错误后是重建流水线还是提示用户重试,应由业务层统一处理,别在采集回调里无限重试拖住音频线程。 测量电路时也沿着信号方向走。控制总线能访问,不代表采集时钟和数据一定正确;有 MCLK/BCLK/WS,也不代表本次读缓冲成功。先把板号、数字接口和软件返回值逐层对齐,排错才不会在“换一颗麦克风试试”之间打转。 ### 资料与验证范围 硬件图来自 Espressif 的 ESP-VoCat 喵伴 CoreBoard V1.0,项目及 EDA 标注 GPL-3.0。本文重新从对应 EDA 工程导出原理图与 PCB,做局部裁剪、缩放及 PCB 亮度调整,未改变连线、元件或数值。工程入口及许可见 https://oshwhub.com/esp-college/echoear 。 软件核对版本为 esp-brookesia 提交 `b22c488f50bafe53342c8e171081bd736396ef58`,回调所在文件版权为2025 Espressif Systems (Shanghai) CO LTD,Apache-2.0。完整源码入口 https://github.com/espressif/esp-brookesia/tree/b22c488f50bafe53342c8e171081bd736396ef58 ,查看 `products/speaker` 及上述回调路径。 GMF 端口和 payload 参照 v0.6提交 `ce2390f30bd12e8cdc2684a01b9ac5deb61e3c21`,https://github.com/espressif/esp-gmf/tree/ce2390f30bd12e8cdc2684a01b9ac5deb61e3c21 。相关代码受 Espressif Modified MIT 约束,仅用于乐鑫产品,复用时须保留其版权及完整许可。Codec API声明见 [官方头文件](https://github.com/espressif/esp-adf/blob/master/components/esp_codec_dev/include/esp_codec_dev.h) 。 本文新增检查函数及故障注入示例按 MIT 许可提供;第三方文件仍遵循各自许可。已完成使用真实 GMF 结构声明的 C11 主机编译和故障注入验证,未完成目标整机编译、烧录和声学实测。