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

共1条 1/1 1 跳转至

【ESP32-S31-Korvo-1】成果贴

菜鸟
2026-09-11 16:49:11     打赏

1. 项目介绍

这是一只跑在 ESP32-S31-Korvo-1 上的桌面宠物。平时它待机,屏幕循环播放待机动画,麦克风常开做本地监听,不联网;摄像头连续看到人脸、或者听到唤醒词,任意一个满足就进入聆听,本地 AFE 开始降噪、VAD 盯住语音的起止边界;判定说完了,把裁好的片段送上云走 ASR 和 LLM;拿到答案后用头顶的气泡把文字显示出来,桌宠按内容切姿态。超时后切「睡觉」姿态继续待机。

三个技术重点:

重点说明难度
人脸感知复用 esp-dl 人脸检测,做「有没有人」的触发信号中,有成熟框架
桌宠 UI6 组姿态动画 + 中文气泡,跑在 800×480 屏上中,卡在内存和素材管线
语音处理本地降噪 + 精准截取有效语音段(VAD 端点检测)高,本项目主要难点

第三条是核心。云端的 ASR/LLM 都是现成服务,调通不难;真正难的是在本地判断「这一句从哪开始、到哪结束」——切早了丢字,切晚了带一堆静音上传。这部分我踩的坑最多。


2. 硬件方案说明

三个需求:能采多路音频、能跑得动一块像样的屏、能接摄像头。Korvo-1 是官方定位「智能音频与人机交互」的开发板,这三样是标配,不用外接任何模块

主控 ESP32-S31。 RISC-V 双核。对本项目最关键的是片内 16 MB PSRAM芯片内部 RAM 一共才 350 KB 左右,上面任何一项单拎出来都可能装不下。所以原则是:大块、非实时的缓冲一律 heap_caps_malloc(..., MALLOC_CAP_SPIRAM),内部 RAM 只留给任务栈和驱动。CONFIG_SPIRAM=y 是必须的,不是优化项——AFE 的 ringbuf 分配失败会直接 NULL 解引用。

音频 ES8389。 输入输出都归它管,本项目只用了输入这一半。BSP 里 bsp_audio_codec_microphone_init() 一个函数把 I2C、I2S、codec 寄存器全配好。两个硬件事实会以「能跑但不对」的形式坑人:一是 MCLK 引脚在 S31 上没连,官方推荐采样率 16 kHz,正好是语音识别的标准值,但不能随手改成 44.1k;二是 I2S 是 I2S_SLOT_MODE_STEREO,只有 2 通道,这条约束直接决定了 AFE 配置字符串只能写两个字符。

还有增益:ES8389 上电默认接近最小档,实测说话峰值只有约 217/32768(-43 dBFS),送进 VAD 和唤醒词模型跟静音没区别。每次开麦后必须显式设:

esp_codec_dev_set_in_gain(mic, 30.0);   // 落进 ES8389_MIC_GAIN_30_5DB 档,约 33.5 倍,上限 36.5 dB

显示 4.3 英寸 800×480。 RGB 并口 16 bit 直连。代价是吃内存,全屏双缓冲 1.5 MB 只能放 PSRAM,bsp_display_cfg_t 里 buff_spiram = true 不开必然分配失败。另外这块板的 BSP_LCD_BACKLIGHT 是 GPIO_NUM_NC,背光要走 bsp_display_backlight_on()——第一次点屏我以为板子坏了,LVGL 日志一切正常就是屏幕黑的。

摄像头 OV3660。 通过 esp_video 走标准 V4L2,open/ioctl/mmap 那一套。选格式有个不起眼的性能点:OV3660 在这块板上原生就有 RGB565_BE 240×240@24fps 这一档。按习惯申请小端的 V4L2_PIX_FMT_RGB565,esp_video 会走 data_reprocessing 的字节交换分支,白白多一遍全帧拷贝;要 V4L2_PIX_FMT_RGB565X 就是原样搬运。

分区规划。 16 MB Flash 要装固件、语音模型、桌宠素材、中文字体:

nvs,      data, nvs,     ,         24K,
phy_init, data, phy,     ,         4K,
factory,  app,  factory, ,         4M,
model,    data, spiffs,  ,         6M,
storage,  data, spiffs,  ,         2M,

model 分区必须叫这个名字——esp-sr 只有在「自定义分区表 + 存在 model 分区」时才会打包烧录 srmodels.bin,否则唤醒词模型是空的。factory 给 4 M,当前固件 3 253 632 字节已经用掉四分之三,大头是嵌进 .rodata 的素材和字体。storage 的 2 M 空着,是给字体搬家预留的。


3. 系统设计思路

整个系统围绕一条分界线展开:本地负责「什么时候说」,云端负责「说了什么」。

本地(ESP32-S31)云端
常开监听、降噪、VAD 判定起止、裁剪片段
只上传裁好的这一段ASR 识别 → LLM 回答
收到回答文字,上屏 + 切姿态

这么分有三个理由:一段 5 秒录音里真正说话的可能只有 1.5 秒,本地裁掉静音,上行数据量砍掉三分之二;不触发就不联网,没有一个字节离开桌面,这是常开监听类产品必须给的交代;待机时射频完全关着,只有一条音频链路在跑。反过来,ASR 和 LLM 坚决不下放本地——ESP32 跑得动关键词识别,跑不动通用语音识别和大语言模型。

四态状态机,桌宠姿态直接映射:

typedef enum {
    APP_STATE_IDLE = 0,   // 待机,常开监听
    APP_STATE_LISTENING,  // 已触发,正在收语音
    APP_STATE_THINKING,   // 语音已发出,等云端结果
    APP_STATE_SPEAKING,   // 正在应答(当前是气泡显示,留给以后的语音播报)
} app_state_t;

转移由两类信号驱动——模块上报的事实,和中心决策出的状态变化

模块上报的事实决策模块响应
看到人脸 / 唤醒词命中 / 语音开始IDLE → LISTENING切「对话」姿态,开始攒 PCM
语音结束LISTENING → THINKING切「思考」姿态,上传片段
收到回复文本THINKING → SPEAKING气泡显示文字 + 切姿态
气泡展示够时长SPEAKING → IDLE回待机动画
无人 + 超时→ IDLE切「睡觉」姿态

三路输入信号地位平等,它们都只是「事实上报」,谁也不能直接改变系统行为,改变行为的权力只在状态机手里。这条规矩让以后加触发方式(触摸屏、定时问候)变成纯增量的事。另一条界线是:语音数据本身不走事件总线,只走独立 ringbuf——总线传的是几十字节的控制信息,PCM 是几十上百 KB 的数据流,塞进事件队列会立刻把内存打爆。

组件划分。 main 只做开机编排,下面挂 ui(界面)、audio(采集 + VAD)、face_id(人脸感知)三个平级组件;横向依赖只有两条:audio 把语音片段交给 wifi_connect,wifi_connect 拿到回答后调 pet_ui_say() 上屏。方向始终是「网络层 → 界面层」,反过来 ui 不认识 wifi_connect,不成环。

依赖声明上守一条规矩:头文件里只暴露标量,实现依赖全塞 PRIV_REQUIRES。pet_ui.h 里只有 void pet_ui_say(const char *) 这种干净接口,调用方不会被迫把 LVGL 拖进自己的编译单元。

栈的两个经验值。 CONFIG_ESP_MAIN_TASK_STACK_SIZE 提到 16384——HTTPS 握手时 mbedtls 加载全量 CA bundle 非常吃栈,默认 3584 直接爆;人脸检测任务给 8192——esp-dl 的 run() 里 std::list/std::vector 用得不少,4 K 会在后处理爆栈,现象是 Guru Meditation 不是返回错误码。还有一条:上云那段不能用 cJSON 拼 body,它内部用普通 malloc 优先要内部 RAM,拿去拼 128 KB 的 JSON 必然失败,只在解析几 KB 的返回值时才用它。


4. 功能框图、软件流程图

system_diagram.png

开机流程。 app_main() 只有四行,但顺序不能换:

void app_main(void)
{
    pet_ui_init();
    xTaskCreatePinnedToCore(pet_ui_start, "pet", 16384, NULL, 5, NULL, 1);
    wifi_connect_blocking();
    button_init();
}

一次完整对话的时序:

#谁做什么
1–2麦克风 / AFE持续把 PCM 帧喂给 VAD
3–4VAD / 屏幕判定 VAD_SPEECH,开始攒缓冲,切「对话」姿态
5–7VAD / 屏幕判定静音,整句入队,切「思考」姿态
8–9网络 / 云端加 WAV 头 + base64 上传,omni 模型转写返回文字
10–11网络 / 云端拿转写文本再请求一次,文本模型生成回答
12屏幕气泡显示回答文字,切姿态

第 8 到 11 步是两次独立的 HTTP 请求,不是一次,原因见第 5 节。

VAD 断句逻辑只有两个状态,靠一个 in_speech 布尔量区分。静音态下每帧只看 res->vad_state,一旦等于 VAD_SPEECH 就翻进语音态;语音态下每帧 memcpy 进当前缓冲,累计上限 3 秒,超上限停止追加但不切状态(宁可截断也不能丢掉句尾);直到 vad_state 不再是 VAD_SPEECH,把整句丢进队列,换一块新缓冲翻回静音态。排队的整句由发送任务取走上传。

人脸检测去抖:DQBUF 取一帧,先看 flags 有没有 V4L2_BUF_FLAG_DONE,没有就说明内容是垃圾直接丢——但 buffer 仍要 QBUF 还回去,否则队列很快空掉、DQBUF 一直阻塞。帧是好的就送进 run(),最大人脸框面积够 32×32 则 miss 清零、++hit,连着 6 帧置标识;不够则 hit 清零、++miss,连着 24 帧才清标识。24 fps 下大概是「0.25 秒才认,1 秒才放」——人低头一下、手挡一下不该让桌宠断线,所以放的阈值比认的大得多


5. 实现过程说明

环境与构建。 S31 是预览芯片,必须 IDF master 且 set-target 带 --preview:

source ~/esp/s31env.sh
idf.py --preview set-target esp32s31
idf.py build

本地构建不用 Docker——官方镜像只会比本地 master 更旧。组件依赖三个:BSP esp32_s31_korvo_1、语音前端 esp-sr、解 JSON 的 cjson(本来靠 esp-sr 的私有依赖捎带,显式写出来免得哪天悄无声息地断掉)。

桌宠 UI 的素材管线。 源素材是 PNG 序列帧,直接塞固件不现实(解码要 libpng)。离线脚本把每组动作烤成 xd_<动作>.h(帧表 + 256 色调色板)和 xd_<动作>.bin(逐帧 zlib 压缩的索引数据)。四个决策:调色板量化到 254 色 + 1 个透明索引,每像素只要 1 字节;逐帧独立压缩,播放时只解当前帧,解压缓冲只要一帧大小;只存内容矩形不存整张画布,透明边缘完全不进文件;EMBED_FILES 直接嵌进 .rodata,运行时内存映射,不需要文件系统。解压用 esp_rom 自带的 miniz,不用额外引组件。

已收的三组素材:待机 24 帧 251 421 B,睡觉 22 帧 220 253 B,玩球 24 帧 247 976 B,帧间隔都是 80 ms。6 组全上齐约 1.5 MB。

LVGL 的 LV_COLOR_FORMAT_RGB565A8 是颜色区和 alpha 区分开存的,这个布局很友好:每帧只要把 alpha 整块清零,颜色区根本不用清(透明处看不见),省下的那次 245 KB memset 在 80 ms 一帧的节奏下是实打实的。

中文显示。 第一版气泡是方块和空白——LVGL 默认字体 Montserrat 没有汉字。但换成自带的 SourceHanSansSC 也不行,那是个约一千字的固定子集,大模型回答什么词无法预测,必然缺字,而且缺字表现为空白,比方块更难排查。


编译期字表运行时光栅化(Tiny TTF)
字符集编译时定死由字体文件决定
缺字显示空白,无法补救只要字体里有就能显示
Flash 占用大(整个 TTF)

最后走 Tiny TTF,字体用脚本从系统 Noto Sans CJK 子集化,保留 GB2312 全部 6763 字,1.44 MB。同一字号只创建一次,用 4 个槽位缓存——每个字号带一份独立字形缓存,开多了内存立刻见底。

语音采集走了两个个版本。 

第一版是纯数据导出固件:怀疑音频质量本身有问题但板子上没法看波形,于是关掉所有日志、把 UART0 独占出来,AFE 出来的整段 PCM 加帧头帧尾甩到 PC 上离线分析。速率算过账:16 kHz×16 bit 单声道 = 32 KB/s,8N1 每字节 10 bit,理论只需要 320 000 波特;设 3 000 000 纯粹是抗抖动,UART0 物理上没有硬件流控,掉一个字节后面整段错位,所以不是越高越好。这一版最有价值的产出不是数据而是心跳机制

现在的主线是 AFE + WebRTC VAD 自动断句,三条任务分工:

任务核栈职责
feed_task08 KB从麦克风读 PCM,喂给 AFE
fetch_task14 KB从 AFE 取帧,按 VAD 状态攒句子
send_task04 KB从队列取整句,上传

人脸感知用 esp-dl 的 HumanFaceDetect,走 V4L2。一个明确的取舍:只做「在不在」,不做「是谁」——桌宠要的是一个触发信号,认身份是 human_face_recognition 的活,那需要注册流程、特征库、比对阈值,是另一个量级的工程量。模型加载用 lazy_load=false 一次性搬完权重,默认的懒加载会拖到第一次 run() 才去 flash 里搬,第一帧卡好几百毫秒,看起来像摄像头没起来。esp-dl 的 API 是 C++,所以这个组件用 .cpp,对外三个符号用 extern "C" 导出。

回答呈现与姿态联动。 回答是文字上屏,入口就 pet_ui_say() 一个函数,内部自己 bsp_display_lock(),可以从任何任务调——wifi_client.c 就是从 "rec" 任务里直接调的,LVGL 会把字符串拷进自己的堆,传栈上的数组也安全。姿态联动的接口就是状态机广播的状态变化,ui 收到后切当前动画组的帧表指针即可——所有姿态的帧表结构完全一样,切换只是换一个指针,零额外开销。

板子上的喇叭这一版没有用。真要加语音播报,除了 TTS 本身,还得同时处理一件事:喇叭一开,AFE 的参考通道就有了独立信号,配置字符串的第二个字符要从 "N" 改回 "R" 把 AEC 打开,否则喇叭放出来的声音会被麦克风收回去,形成自问自答。


6. 关键代码解析

AFE 配置:两个字符,两个坑。

afe_config_t *afe_config = afe_config_init("MN", models, AFE_TYPE_SR, AFE_MODE_HIGH_PERF);
afe_config->vad_mode = VAD_MODE_2;

字符串里每个字符代表一个通道:M=麦克风,R=播放参考,N=未使用,字符数就是通道数。"MN" 这两个字符都是实测定下来的。API 设计很紧凑,但也意味着一个字符写错就是整条链路的静默失效。另外 vad_energy_threshold 这个字段看名字最像要调的东西,但它在这条路上无效——只在用 VAD 模型时生效,我们走的是 WebRTC VAD,能调的只有 vad_mode。

VAD 攒句子的核心循环:

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;
        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;
    }
}

三个点:队列传的是指针不是数据,utt_msg_t 只有 {buf, len},一句 3 秒是 96 000 字节,按值拷贝进队列不可能,所有权随指针转移,fetch_task 分配、send_task 释放;交出缓冲后立刻换新的,不能等 send_task 用完再要,那样就阻塞了,换新块的代价是一次 PSRAM 分配,比阻塞便宜得多;队列满了就丢当前这句并复用旧 buf,这是刻意的降级——队列满说明上传跟不上采集,继续攒只会 OOM,丢最新的一句、保住已排队的,比全盘崩掉好。注意这一支 continue 之前没有重新分配,避免泄漏。

发送必须是独立任务。 uart_write_bytes 的 TX buffer 设成 0 时会阻塞到发完,3 Mbps 下一句 96 000 字节约 0.32 秒。如果把发送放进 fetch_task,这 0.32 秒里它不调 fetch(),AFE 输出侧开始堆积,而 feed_task 还在源源不断往里灌,ringbuf 很快溢出丢帧,结果是下一句被截断——因果链很长,现象只有一行 Ringbuffer of AFE(FEED) is full。同理云端请求那条路也是阻塞的(最长 40 秒),也必须独立。

音频上云:base64 必须整块编码。 内存走向以 3 秒 96 000 字节为例:加 44 字节 WAV 头后 96 044 字节,base64 膨胀 4/3 再加 JSON 外壳约 128 100 字节,两块都从 PSRAM 要。

「44 不是 3 的倍数」是这里的重点:base64 每 3 字节编成 4 字节,如果把 WAV 头和 PCM 数据分两次编码再拼接,第一段末尾会补 = padding,拼起来就是非法 base64。服务端解出来是垃圾,但它不会告诉你 base64 坏了,只会返回一段驴唇不对马嘴的识别结果。编码时直接把 base64 写进 body 中段,省一次整块拷贝:

memcpy(body, PREFIX, prefix_len);
mbedtls_base64_encode((unsigned char *)body + prefix_len, b64_cap, &b64_len, wav, wav_len);
memcpy(body + prefix_len + b64_len, SUFFIX, suffix_len);

WAV 头那 44 字节里的采样参数必须和 esp_codec_dev 打开麦克风时一致,对不上服务端会按错误采样率解析——表现是识别出一堆不相干的字,不报错

人脸检测里 buffer 必须无条件归还:

if (buf.flags & V4L2_BUF_FLAG_DONE) {
    bool seen = largest_face_area(det, s_buf[buf.index]) >= MIN_BOX_AREA;
    if (seen) { miss = 0; if (!s_present && ++hit >= HIT_N) { s_present = true; hit = 0; } }
    else      { hit = 0;  if (s_present && ++miss >= MISS_N) { s_present = false; miss = 0; } }
}
if (ioctl(s_fd, VIDIOC_QBUF, &buf) != 0) {   // 无论如何都要还
    ESP_LOGE(TAG, "QBUF failed");
}

只有 2 个 buffer,坏帧不还回去,两帧之后队列就空了,DQBUF 永久阻塞,人脸检测静默死掉且不报任何错。QBUF 放在 if 外面是刻意的。hit/miss 互相清零也是关键,不清零的话计数会跨越多次进出累积,去抖就失效了。

7. 功能展示

人脸识别不太好演示,没法展示效果;需要优化也很明显,单次回复时间太长,不过我也没有很好的优化思路;屏幕也限制了回答的文本长度;

8. 技术难点与解决方案 / 项目总结

下面每一条都实际卡住过我,共同点是:全部以「能跑但不对」的形式出现,没有一个会直接报错崩溃。

一:AFE 通道数必须是偶数,否则采样率静默降级。 现象是程序跑得好好的、VAD 偶尔还触发,但唤醒词绝无可能识别。因果链有五层:afe_config_init("MNR", ...) 要了 3 通道,esp_codec_dev 的 _i2s_valid_fmt() 用 (channel >> 1 << 1) != channel 拒绝奇数,直接 return NOT_SUPPORT;被拒之后 I2S 再也没被配置过,一直停在默认的 22050 Hz;但 esp_codec_dev_open() 把这个错误吞掉照样返回 0;于是 AFE 按「16k 三通道」解析实际是「22.05k 两通道」的数据;因为噪声也有能量,VAD 偶尔触发,看起来「在工作」。唯一线索是一行 E I2S_IF: Not support channel 3,日志一关就彻底隐形。改成偶数通道即解决。推广出来的教训:这条链路上返回 0 不代表采样率生效了,必须核对 I2S_IF 打出来的实际 sample_rate_hz。

二:AEC 把人声当回声减掉了。 通道数改对后采样率正常了,但说话完全不触发 VAD,只有开机第一下能过。这个细节信息量很大——它说明有个会随时间收敛的东西在起作用,能收敛的只有自适应滤波器。在 feed_task 里加各通道峰值探针,量出来 pk0=3750 pk1=3741、pk0=8 pk1=7、pk0=22 pk1=21,两通道每一档只差 1,是同一路信号——ES8389 把单麦数据同时送到了左右两个 slot,于是 AEC 拿人声当回声减人声,减得干干净净;开机那一下能过是因为滤波器还没收敛。改用 "MN" 把 AEC 从管线里摘掉,验证标志是启动日志里的 0 playback 和管线里没有 AEC。

三:麦克风增益默认接近最小。 VAD 灵敏度怎么调都不对,调松了全是噪声,调紧了说话不响应。根因是信号只有 -43 dBFS。教训是:调参数没效果时,先怀疑输入而不是参数——被调的东西输入就是错的,调什么都没用。

四:VAD 模式选档。 默认 VAD_MODE_0(五档里最松)会把底噪当语音,开机没说话就连发三句。实测底噪 rms ≈ 35,说话 rms 192~410,中间有足够间隔,把 vad_mode 提到 VAD_MODE_2 后安静段就干净了。

五:内存峰值与 cJSON 陷阱。 上传音频时分配失败,因为 cJSON 用普通 malloc 优先要内部 RAM,而内部一共才 350 KB。解法是三条叠加:大 body 全程手工拼字符串不碰 cJSON、所有大块缓冲显式 MALLOC_CAP_SPIRAM、两步请求之间释放中间产物。

六:中文字体的取舍。 分两层,默认字体没汉字好查;换成 LVGL 自带 CJK 后偶尔缺字就隐蔽了——那是个约一千字的 demo 子集,而且缺字表现为空白不是方块,混在正常文字里很难第一眼发现。改用 Tiny TTF 运行时光栅化解决,代价是 1.44 MB flash,后续要搬进 SPIFFS。


总结。 Korvo-1 的硬件配置和这个项目契合度确实高,双麦 + 大屏 + 摄像头一次到位,BSP 覆盖面也比我预期的广,省下的时间是实打实的。代价是它是预览芯片,工具链得跟 master,esp-sr 的 AFE 能力很强。

已知的局限也列几条:回答只上屏不出声,板载喇叭还空着,TTS 和播放是下一步最该补的一块;人脸只做检测不做识别;对话没有上下文,每次请求都是独立的;两次云端请求串行,延迟太长;



共1条 1/1 1 跳转至

回复

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