本来想着做个桌宠,待机的时候一直听着,喊一声就醒,说完一句自动切好上传。听起来挺美的。
结果从"打开仓库不知道看什么",到"串口一个字节都收不到",中间踩了七八个坑。这篇把过程完整记下来,包括几次判断错误。
2.1 先让 AFE 跑起来
我打开 espressif/esp-sr 仓库,盯着看了十分钟,不知道该看什么。
2.1.1 这个仓库,其实没有源码可读
我一开始的想法很朴素——读源码嘛,src/ 里面翻一翻,算法怎么跑的不就清楚了。
然后我 clone 下来一看:
esp-sr/ ├── lib/esp32s31/ │ ├── libesp_audio_front_end.a │ ├── libvadnet.a │ ├── libwakenet.a │ ├── libmultinet.a │ └── ... ← 11 个 .a,全是二进制 └── src/ ← 5 个文件,全是模型加载和 Kconfig 胶水
哦吼。算法是闭源的。
所以"读源码"这条路,从一开始就是死的。能看的只有三层:
| 概念 | docs/zh_CN/ | 有中文,讲清楚 AFE / VAD 是干嘛的 |
| 接口 | include/esp32s31/ | 头文件注释就是唯一的 API 文档 |
| 用法 | test_apps/ | 唯一完整可跑的代码 |
想通这一点以后就好办多了。我要做的是"常开监听 + 自动切段",那唤醒词(WakeNet)、命令词(MultiNet)、语音合成(TTS)、声源定向(DOA)这些全都可以先跳过,只看 AFE + VADNet 这一条线。
include/esp32s31/ 底下躺着 39 个头文件,一开始看得我眼晕。冷静下来数了数,真正要碰的就 3 个:esp_afe_config.h、esp_afe_sr_iface.h、esp_vadn_iface.h。
2.1.2 照着文档抄,第一次翻车
文档里给了一段特别清晰的骨架,feed / fetch 两个任务,一看就懂。我兴冲冲复制进 audio_wake.c,编译——
error: implicit declaration of function 'read_data_from_i2s_or_file' error: expected expression before '...' token error: expected expression before '...' token
我又回去看了一眼文档:
while (1) {
read_data_from_i2s_or_file(buff, ...); // read audio data from I2S or file
afe_handle->feed(afe_data, buff);
}... 是省略号。read_data_from_i2s_or_file 是编出来的函数名。
这是给人看的示意骨架,不是能编译的代码。是我太急了。
顺便还发现一个更隐蔽的:我抄的时候把文档里 create_afe_task() 那一段给漏了,也就是说 afe_handle 从头到尾就是个 NULL。就算把语法错误都改对,第一行 afe_handle->get_feed_chunksize() 照样当场空指针。
教训:文档示例先整段读完再抄,别抄一半。
2.1.3 真正能抄的在哪
后来才发现,能直接抄的代码根本不在 esp-sr 里,而在 esp-skainet(乐鑫的语音应用框架,esp-sr 只是它底下的算法库)。
尤其是这个例程,简直就是照着我的需求写的:
esp-skainet/examples/voice_activity_detection/main/main.c
AFE 初始化、feed 循环、按 VAD 状态存语音段,一百来行全齐了。
不过有个坑得留神:esp-skainet 用的是它自带的板级抽象(esp_board_init() + esp_get_feed_data()),而我这块 Korvo-1 走的是 BSP + esp_codec_dev。这两套是不通的,喂数据那一行不能照抄,得自己用 esp_codec_dev_read() 换掉。
2.1.4 顺手挖到的一个大坑:VAD 首帧
例程里有一段注释,我差点划过去,还好停下来读了:
VAD 首帧触发会延迟 1~3 帧(算法固有延迟 + min_speech_ms 防误触发),直接用首帧会把第一个字切掉。AFE V2.0 为此加了 vad_cache。
这个对我来说是致命的。我要做的正是"自动截取有意义的片段然后上传"——第一个字丢了,后面云端识别出来的就是另一句话了。
回头去翻 esp_afe_sr_iface.h,字段确实在:
typedef struct afe_fetch_result_t {
int16_t *data; // the target channel data of audio.
int data_size; // the size of data. The unit is byte.
int16_t *vad_cache; // the cache data of vad. It's only valid when vad_cache_size > 0.
// It is used to complete the audio that was truncated.
int vad_cache_size; // the size of vad_cache. The unit is byte.
...所以存语音段的时候,得先看 res->vad_cache_size > 0 把缓存那段补上,再写 res->data。
但我写代码的时候还是忘了。 现在 fetch_task 里只处理了 res->data,vad_cache 一直没接——也就是说每句话的开头仍然是缺的。这条留到后面补,先记在这里。
要不是那段注释,我大概会调好几天,还以为是麦克风的问题。
2.2 还没开始就卡住
代码写完,在 VS Code 里点构建,刷了一屏:
/bin/sh: 1: esp-idf-configdep: not found ninja: build stopped: subcommand failed. * 终端进程"ninja"启动失败(退出代码: 127)。
127 是"命令找不到",不是编译错误。 而且注意它是从 ninja 直接起的,不是 idf.py。
IDF master 现在把编译器包在 esp-idf-configdep 这个 wrapper 后面,每条编译命令都长这样:
esp-idf-configdep <真正的 riscv32-esp-elf-gcc> -D... -I... -c xxx.c
这个 wrapper 装在 ~/.espressif/tools/esp-idf-configdep/<版本>/.../bin/,只有激活 IDF 环境之后才在 PATH 上:
| 未 source | not found |
| source ~/esp/s31env.sh 之后 | ~/.espressif/tools/esp-idf-configdep/0.2.3/.../bin/esp-idf-configdep |
问题出在我用了 CMake Tools 扩展的构建按钮。它绕过 idf.py,直接在 build/ 里跑 ninja,继承的是 VS Code 启动时的环境——不带 IDF。前十几个目标能过(那些不走 wrapper),一编译 C 文件就全线 127。
解决办法是加个真正会 source 环境的 task:
{
"label": "idf: build",
"type": "shell",
"command": "source ~/esp/s31env.sh && idf.py build",
"options": {
"shell": { "executable": "/bin/bash", "args": ["-c"] }
},
"group": { "kind": "build", "isDefault": true }
}教训:退出码 127 一律先怀疑 PATH,别去看编译错误。IDE 里的构建按钮和你终端里的 shell 是两个世界。
2.3 绕得最久的一次:串口一个字节都没有
这一节是整个过程里最曲折的,也是我判断错得最离谱的一次。完整记下来。
2.3.1 起因:我自己把嘴堵上了
因为数据口要发裸 PCM,线上不能混进日志文本,所以我把日志全关了:
CONFIG_LOG_DEFAULT_LEVEL_NONE=y CONFIG_ESP_CONSOLE_NONE=y CONFIG_ESP_CONSOLE_SECONDARY_NONE=y CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y
烧完,串口一片死寂。我想着加几个 printf 看看跑到哪了——
printf 在 CONFIG_ESP_CONSOLE_NONE 下没有任何输出通道。 IDF 不给 stdout 挂 VFS 设备,写进去就丢了。ESP_LOGx 更彻底,被 LOG_DEFAULT_LEVEL_NONE 直接编译掉,指令根本不在 bin 里。
在这个配置下唯一能出字节的是 uart_write_bytes()——它走 UART 驱动,绕过 console。于是我写了个 dbg():
static void dbg(const char *fmt, ...)
{
char line[128];
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(line, sizeof(line), fmt, ap);
va_end(ap);
if (n > 0) uart_write_bytes(DATA_UART, line, n);
}还是没有。
2.3.2 一个误导性很强的现象
我把日志开回来(console 回到 UART0、115200),idf.py monitor 一看,全出来了——启动日志、dbg() 全都正常。
这里我下了错误结论:以为"日志配置生效了",把静默归因给了 printf 没通道这件事。
这个结论让我在波特率上白绕了好几轮。因为一旦把 DATA_BAUD 改回 3000000、日志再关掉,又什么都没有了。我于是怀疑 CP210x 吃不下 3M,退到 1500000 试——
两个速率的结果完全一样:恰好 664 字节。
这本该立刻点醒我。波特率配错的话,不同速率下收到的字节数不可能一模一样。那 664 字节其实是 ROM 引导输出——芯片固化的那段,永远 115200,不受任何配置影响。
所以"能看到 ROM 但看不到 app",只能证明线和 CP210x 是好的,不能证明链路可用。
2.3.3 心跳:把变量拆开
卡住的时候,我意识到自己一直在同时怀疑两件事:链路通不通,和 VAD 有没有触发。这两个搅在一起,任何观察都能有两种解释。
于是在发送任务里加了心跳——队列空闲 1 秒就发一个空帧(HEAD 紧跟 TAIL,共 8 字节):
if (xQueueReceive(utt_queue, &msg, pdMS_TO_TICKS(1000)) == pdTRUE) {
// 发整句 PCM
} else {
// 队列空:发空帧,证明板子还活着
uart_write_bytes(DATA_UART, (const char *)HEAD, 4);
uart_write_bytes(DATA_UART, (const char *)TAIL, 4);
}心跳不依赖 VAD、不依赖麦克风、不依赖 AFE。收到 0 个心跳,就一次性排除了整条音频链的所有解释。
结果确实是 0 个。问题在 UART 本身。
2.3.4 真正的根因:uart_set_pin()
线索在开着 console 时的启动日志里,那行我看过无数遍却没在意的:
I (1121) cpu_start: GPIO 59 and 58 are used as console UART I/O pins
是 console 初始化在给 UART0 做引脚 mux。
uart_param_config() 和 uart_driver_install() 都不碰 GPIO。console 开着的时候,是 console 顺手把 GPIO58/59 连到 UART0 上的;一旦 CONFIG_ESP_CONSOLE_NONE,没有任何人做这件事,uart_write_bytes() 写进去的字节没有物理出口。
之前每次开 console 就"好了",纯粹是搭了 console 引脚 mux 的便车。
#include "soc/uart_pins.h" uart_param_config(DATA_UART, &cfg); uart_set_pin(DATA_UART, U0TXD_GPIO_NUM, U0RXD_GPIO_NUM, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); // ← 缺的就是这行 uart_driver_install(DATA_UART, 1024, 0, 0, NULL, 0);
加上之后,10 秒收到 7 个心跳 + 2 帧 PCM,65248 字节原始数据全部解析成功,无坏帧。3 Mbps 稳定。
教训:症状是"任何波特率下都完全静默",和波特率配错的表象一模一样。区分点在于——波特率错了,不同速率的结果不会完全相同。当两个速率给出字节数完全一致的结果时,就该跳出速率这个方向了。
2.4 采样率静默停在 22050
这个坑的隐蔽程度不亚于上一个,而且它是"看起来在工作"的那种。
我按文档写的 input_format 是 "MNR":M=麦克风,R=播放参考,N=未使用,字符数就是通道数,所以这是 3 通道。
启动日志里有一行错误,被淹在一堆 INFO 里:
E (1439) I2S_IF: Not support channel 3 E (1439) i2s_common: i2s_channel_enable(1485): the channel has already enabled or not initialized
去翻 esp_codec_dev 的源码,platform/audio_codec_data_i2s.c:
static bool _i2s_valid_fmt(esp_codec_dev_sample_info_t *fs)
{
if (fs->channel == 0 ||
(fs->channel >> 1 << 1) != fs->channel) { // ← 拒绝奇数通道
ESP_LOGE(TAG, "Not support channel %d", fs->channel);
return false;
}(ch >> 1 << 1) != ch 就是"是不是奇数"。3 被拒。而板子的 BSP 把 I2S 配成 I2S_SLOT_MODE_STEREO,只有 2 通道。
被拒之后的连锁反应才是真正的坑:
它直接 return ESP_CODEC_DEV_NOT_SUPPORT,I2S 再也没被配置过,一直停在 bsp_audio_init(NULL) 的默认值——BSP_I2S_DUPLEX_CFG(22050),22050 Hz,不是 16000。
但 esp_codec_dev_open() 把这个错误吞掉,照样返回 0。调用侧判返回值完全看不出问题。
唯一的线索就是那行 E I2S_IF。日志一关就彻底隐形。
于是 AFE 按"16k 三通道"去解析实际是"22.05k 两通道"的数据。VAD 偶尔还会触发(噪声也有能量),所以看起来在工作,但唤醒词绝无可能识别。
改成偶数通道就解决:通过校验 → 走到 check_fs_compatible() → 因为扬声器没开(paired out_enable: 0)走第一个分支 set_fs() → I2S 真正被设成 16000。
验证标志是启动日志里这两行,有才算数:
I (1449) I2S_IF: STD: RX, sample_rate_hz: 16000, mclk_multiple: 256 I (1379) AFE: Input PCM Config: total 2 channels, sample rate:16000
教训:esp_codec_dev_open() 返回 0 不代表采样率生效了。必须核对 I2S_IF 打出来的实际 sample_rate_hz。
2.5 增益:默认值几乎等于没开
采样率对了、AEC 拿掉了,说话还是不太触发。加了 RMS/峰值探针一看:
安静: pk0=8 15 21 26 说话: pk0=217 200 130 102
信号确实在动,但满量程是 32768,217 只有 0.66%,约 −43 dBFS。VAD 和 WakeNet 拿到这种电平,跟静音没区别——不是"没听清",是根本达不到判决门限。
mic_init 里从头到尾没设过增益,ES8389 上电默认接近最小档。查 es8389.c 里的换算表,支持 0 ~ 36.5 dB:
esp_codec_dev_set_in_gain(mic, 30.0); // 落进 ES8389_MIC_GAIN_30_5DB,约 33.5 倍
改完底噪从 8~15 抬到 250~370,说话峰值到 4000~5600。声学信噪比约 25 dB,够用了。
一个补充发现:我为了看电平自己写了个整数开方算 RMS,后来才注意到 afe_fetch_result_t 里本来就有:
float data_volume; // VAD input mean square energy in dBFS, calculated before AGC.
现成的 dBFS 能量值,白写了。
教训:写探针之前先把要用的结构体字段从头读一遍。
2.6 VAD 档位:两次都调过头
增益上去之后,VAD 从"不触发"变成了"一直触发":
vad=0: pk0=301 250 249 ← 安静 vad=1: pk0=334 253 309 343 ← 和安静时同一水平
开机还没说话就连发三句。底噪被抬到 250~370,WebRTC VAD 分不出来了。
afe_config 里的旋钮:
| vad_mode | VAD_MODE_0(Normal) ~ VAD_MODE_4(最激进) |
| vad_min_speech_ms | 最短语音时长,默认 128ms |
| vad_min_noise_ms | 判句末需要的静音时长,默认 1000ms |
| vad_energy_threshold | 陷阱:只在使用 vad 模型时生效 |
最后一个看起来正对症,但注释写明 "only applied when a vad model is used"。我们走的是 WebRTC VAD(vad_model_name 为 NULL),设了没用。
我直接跳到 VAD_MODE_4,结果矫枉过正——pk0=5651 的真人声也判 0。而且我那一轮同时改了 vad_mode 和 vad_min_speech_ms 两个参数,导致分不清是谁拒的,白跑一轮。
还有个自己挖的坑:探针里 rms0 是 100 圈的聚合值,vad 却是打印那一瞬的单次采样,两者不可比。于是看到 rms0=410 却 vad=0 这种假矛盾,差点又误判。改成统计整窗命中数(vad=37/100)才读得懂。
教训:一次只动一个参数。探针的聚合口径要一致,否则自己会骗自己。
vad_min_noise_ms 默认 1000ms 还带来一个副作用:要连续 1 秒静音才判句末,所以句子普遍偏长,len=91136 已经逼近 3 秒上限(96000 字节),再长就要被静默截断。这个还没调。
阶段小结:现在是什么状态
麦克风 → I2S(16k/2ch) → AFE("MN", VAD_WebRTC) → 整句缓冲(PSRAM) → 队列 → UART0 @3Mbps| input_format | "MN" | 通道数必须偶数;第二字符不能是 R |
| 采样率 | 16000 | 靠偶数通道让 set_fs() 真正执行 |
| 输入增益 | 30.5 dB | 默认档等于没开 |
| 波特率 | 3000000 | CP210x 精确接受,实测无丢帧 |
| 引脚 | 显式 uart_set_pin | console 关掉后没人做 mux |
| 心跳 | 每秒一个空帧 | 日志全关后唯一的存活信号 |
app_main() 干干净净:
#include "audio_wake.h"
void app_main(void)
{
create_afe_task();
}不过折腾了两天,实在不想搞了,接下来换个ui方向,音频后面再继续
还欠着的
vad_cache 没接(2.1.4 挖到、写代码时又忘了的那个)。每句话开头仍然是缺的,这是当前最该补的一个。
heap_caps_malloc 没判 NULL。换缓冲区那处失败会静默崩溃,现象是"PCM 停了但心跳还在"——心跳至少让它变得可观测了。
句子偏长,vad_min_noise_ms 需要调,否则容易撞 3 秒上限被截断。
主机端还没有消费者。板子发的是 A55A + 裸PCM + 5AA5,得写对应的解析。
最后附上代码
#include "bsp/esp32_s31_korvo_1.h"
#include "esp_codec_dev.h"
#include "esp_heap_caps.h"
#include <stdlib.h>
#include <string.h>
#include "esp_afe_sr_iface.h"
#include "esp_afe_config.h"
#include "esp_afe_sr_models.h"
#include "esp_process_sdkconfig.h"
#include "model_path.h"
#include "driver/uart.h"
#include "soc/uart_pins.h" // U0TXD_GPIO_NUM / U0RXD_GPIO_NUM
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#define SAMPLE_RATE 16000
#define DATA_UART UART_NUM_0
#define DATA_BAUD 3000000
#define UTT_MAX_BYTES (SAMPLE_RATE * 2 * 3) // 最多攒 3 秒
static const esp_afe_sr_iface_t *afe_handle = NULL;
static esp_codec_dev_handle_t mic = NULL;
static QueueHandle_t utt_queue = NULL;
static const uint8_t HEAD[4] = {0xA5, 0x5A, 0xA5, 0x5A};
static const uint8_t TAIL[4] = {0x5A, 0xA5, 0x5A, 0xA5};
// 队列里传的是"一整句" —— 指针 + 长度
typedef struct {
uint8_t *buf;
int len;
} utt_msg_t;
static void data_uart_init(void)
{
uart_config_t cfg = {
.baud_rate = DATA_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
};
uart_param_config(DATA_UART, &cfg);
uart_set_pin(DATA_UART, U0TXD_GPIO_NUM, U0RXD_GPIO_NUM,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
uart_driver_install(DATA_UART, 1024, 0, 0, NULL, 0);
}
static void mic_init(int channel)
{
mic = bsp_audio_codec_microphone_init();
esp_codec_dev_sample_info_t fs = {
.sample_rate = SAMPLE_RATE,
.channel = channel,
.bits_per_sample = 16,
};
esp_codec_dev_open(mic, &fs);
esp_codec_dev_set_in_gain(mic, 30.0);
}
void feed_task(void *arg)
{
esp_afe_sr_data_t *afe_data = arg;
int audio_chunksize = afe_handle->get_feed_chunksize(afe_data);
int feed_channel = afe_handle->get_feed_channel_num(afe_data);
mic_init(feed_channel);
int16_t *buff = malloc(audio_chunksize * sizeof(int16_t) * feed_channel);
while (1) {
esp_codec_dev_read(mic, buff, audio_chunksize * sizeof(int16_t) * feed_channel);
afe_handle->feed(afe_data, buff);
}
vTaskDelete(NULL);
}
void fetch_task(void *arg)
{
esp_afe_sr_data_t *afe_data = arg;
bool in_speech = false;
// 当前正在攒的这句(从 PSRAM 要)
uint8_t *cur = heap_caps_malloc(UTT_MAX_BYTES, MALLOC_CAP_SPIRAM);
int cur_len = 0;
while (1) {
afe_fetch_result_t *res = afe_handle->fetch(afe_data);
if (!res || res->ret_value == ESP_FAIL) {
break;
}
if (res->vad_state == VAD_SPEECH) {
in_speech = true;
if (cur_len + res->data_size <= UTT_MAX_BYTES) {
memcpy(cur + cur_len, res->data, res->data_size);
cur_len += res->data_size;
}
} else {
if (in_speech) {
in_speech = false;
// 一句话结束:把这块 buf 交给队列,再换一块新 buf 继续攒
utt_msg_t msg = { .buf = cur, .len = cur_len };
if (xQueueSend(utt_queue, &msg, 0) == pdTRUE) {
cur = heap_caps_malloc(UTT_MAX_BYTES, MALLOC_CAP_SPIRAM);
} else {
cur_len = 0; // 队列满了,丢这句,复用旧 buf
continue;
}
cur_len = 0;
}
}
}
vTaskDelete(NULL);
}
#define HEARTBEAT_MS 1000
void send_task(void *arg)
{
utt_msg_t msg;
while (1) {
if (xQueueReceive(utt_queue, &msg, pdMS_TO_TICKS(HEARTBEAT_MS)) == pdTRUE) {
uart_write_bytes(DATA_UART, (const char *)HEAD, 4);
uart_write_bytes(DATA_UART, (const char *)msg.buf, msg.len);
uart_write_bytes(DATA_UART, (const char *)TAIL, 4);
heap_caps_free(msg.buf);
} else {
uart_write_bytes(DATA_UART, (const char *)HEAD, 4);
uart_write_bytes(DATA_UART, (const char *)TAIL, 4);
}
}
vTaskDelete(NULL);
}
void create_afe_task(void)
{
data_uart_init();
utt_queue = xQueueCreate(4, sizeof(utt_msg_t)); // 最多缓冲 4 句
srmodel_list_t *models = esp_srmodel_init("model");
afe_config_t *afe_config = afe_config_init("MN", models, AFE_TYPE_SR, AFE_MODE_HIGH_PERF);
afe_config->vad_mode = VAD_MODE_2;
afe_handle = esp_afe_handle_from_config(afe_config);
esp_afe_sr_data_t *afe_data = afe_handle->create_from_config(afe_config);
afe_config_free(afe_config);
xTaskCreatePinnedToCore(&feed_task, "feed", 8 * 1024, (void *)afe_data, 5, NULL, 0);
xTaskCreatePinnedToCore(&fetch_task, "detect", 4 * 1024, (void *)afe_data, 5, NULL, 1);
xTaskCreatePinnedToCore(&send_task, "send", 4 * 1024, NULL, 4, NULL, 0);
}
我要赚赏金
