这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 活动中心 » 板卡试用 » 【ESP32-S31-Korvo-1桌宠】语音

共2条 1/1 1 跳转至

【ESP32-S31-Korvo-1桌宠】语音

菜鸟
2026-09-01 16:48:24     打赏

本来想着做个桌宠,待机的时候一直听着,喊一声就醒,说完一句自动切好上传。听起来挺美的。

结果从"打开仓库不知道看什么",到"串口一个字节都收不到",中间踩了七八个坑。这篇把过程完整记下来,包括几次判断错误。


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 上


which esp-idf-configdep
未 sourcenot 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 通道。

被拒之后的连锁反应才是真正的坑

  1. 它直接 return ESP_CODEC_DEV_NOT_SUPPORT,I2S 再也没被配置过,一直停在 bsp_audio_init(NULL) 的默认值——BSP_I2S_DUPLEX_CFG(22050),22050 Hz,不是 16000。

  2. 但 esp_codec_dev_open() 把这个错误吞掉,照样返回 0。调用侧判返回值完全看不出问题。

  3. 唯一的线索就是那行 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_modeVAD_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默认档等于没开
波特率3000000CP210x 精确接受,实测无丢帧
引脚显式 uart_set_pinconsole 关掉后没人做 mux
心跳每秒一个空帧日志全关后唯一的存活信号

app_main() 干干净净:

#include "audio_wake.h"

void app_main(void)
{
    create_afe_task();
}

不过折腾了两天,实在不想搞了,接下来换个ui方向,音频后面再继续

还欠着的

  1. vad_cache 没接(2.1.4 挖到、写代码时又忘了的那个)。每句话开头仍然是缺的,这是当前最该补的一个。

  2. heap_caps_malloc 没判 NULL。换缓冲区那处失败会静默崩溃,现象是"PCM 停了但心跳还在"——心跳至少让它变得可观测了。

  3. 句子偏长,vad_min_noise_ms 需要调,否则容易撞 3 秒上限被截断。

  4. 主机端还没有消费者。板子发的是 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);
}



助工
2026-09-02 21:43:25     打赏
2楼

好详细的踩坑,感谢分享。


共2条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]