本篇是 ESP32-S31 Function Core Board 1测评的第五篇。前几篇分别实现了 LLM 控制蓝牙播放、Agent 回复文本经百度千帆 TTS 转成语音 URL 后 HTTP 播放。本篇在此基础上补齐"说—听—答"闭环:新增语音唤醒监听、本地提示音播放与语音识别(ASR),并把五类音频路径(蓝牙播放 / HTTP 播放 / 本地播放 / ASR 录音 / 唤醒监听)统一交给一个音频仲裁器调度。
整体链路:
说唤醒词 → 唤醒监听 pipeline 检测到唤醒(io_codec_dev → aud_rate_cvt(16k) → ai_afe/WakeNet9) → xTaskNotifyGive 通知 voice_ctrl 任务 → 停止监听,释放输入通道 → 播放提示音(本地文件 text2audio.wav,内容是"我一直都在") → 提示音播完、HTTP 输出通道释放 → ASR 录音 5s(申请输入通道,48k 双声道) → 上传任务降采样 48k stereo → 16k mono,补 WAV 头 → 百度 ASR 识别出文本(asr_player_wait_result 等到"结果就绪") → 先重启唤醒监听(Agent 思考 / TTS 播放期间也能继续唤醒) → 文本作为一条"本地 IM 用户消息"投递到 Claw 事件路由 → 规则 im_any_message_agent → Agent(LLM) → Agent 回复 → 规则 agent_out_message_send_message → local_send_message → 百度短文本 TTS(一次请求直接拿 mp3)→ 存 /ramfs → 本地播放 → 等待下一次唤醒
主要技术点:
五类音频路径统一仲裁:输出通道内部互斥、输入通道内部互斥,输入与输出不互斥(这是踩坑后修正的模型,见第二、三节);
蓝牙 pipeline 的引用计数式暂停 / 恢复(pause_ref),HTTP 播放优先;
唤醒监听复用 ai_afe(WakeNet9)元件,并把 AFE 任务强制放在内部 RAM;
本地提示音复用 HTTP 播放器管线,只把输入源从 io_http 换成 io_file;
ASR 两阶段设计:录音任务与上传任务解耦,网络不阻塞录音;
语音闭环:ASR 文本经 cap_im_local_emit_text() 当成一条本地 IM 用户消息投进事件路由,Agent 回复自动走 local_send_message → TTS 播报;
TTS 从"长文本异步接口"换成"短文本在线合成":一次请求直接拿 mp3 并本地播放,单句从 ~16.5s 降到 <1s;
提示语(working reply 等)只上屏不朗读,按事件 event_type 过滤,避免每轮先白等 20 秒念一句"正在处理";
新增唤醒"看护任务":唤醒失败 3 秒内自动重试,避免一次失败就永久失聪;
Flash 读写统一走 esp_flash_dispatcher 串行化,规避 Cache disabled 崩溃。
一、五类音频路径与音频仲裁器
1.1 路径枚举
application/edge_agent/components/audio_play/include/stream_proc.h:
typedef enum {
AUDIO_PATH_NONE = 0,
AUDIO_PATH_BT_SINK, /* 蓝牙音乐(输出) */
AUDIO_PATH_BT_SOURCE, /* 蓝牙发送(输出) */
AUDIO_PATH_HTTP, /* TTS / 提示音(输出) */
AUDIO_PATH_ASR, /* 麦克风录音(输入) */
AUDIO_PATH_WWE, /* 唤醒监听(输入) */
} audio_path_t;1.2 仲裁器状态
typedef struct {
SemaphoreHandle_t lock; /* 只保护状态字段,临界区内不做 pipeline 操作 */
audio_path_t out_owner; /* 输出通道占用者:NONE / HTTP */
audio_path_t in_owner; /* 输入通道占用者:NONE / ASR / WWE */
uint8_t pause_ref; /* 蓝牙被暂停的嵌套计数 */
} audio_arbiter_t;
/* 供蓝牙回调无锁查询"输出通道是否被 HTTP 占用",避免在蓝牙路径上再取 s_arb.lock */
static volatile bool s_http_out_held = false;另外有一把 s_pipe_lock,专门串行化所有 pipeline 操作(loading_jobs / run / stop / reset),它与 s_arb.lock 绝不嵌套,原因见 1.5。
1.3 仲裁规则
| HTTP(输出) | 已被 HTTP 持有 | pause_ref++、out_owner=HTTP;pause_ref 由 0→1 时暂停蓝牙 pipeline |
| ASR(输入) | WWE/ASR 已持有输入;或 HTTP 正在播放(避免把 TTS 录进去) | in_owner=ASR |
| WWE(输入) | ASR/WWE 已持有输入 | in_owner=WWE |
释放时:HTTP 路径 pause_ref--,由 1→0 时恢复蓝牙 pipeline;ASR/WWE 直接清 in_owner。
注意:HTTP 不会因为 WWE/ASR 占用麦克风而被拒绝,WWE 也不会因为 HTTP 正在播放而被拒绝。 麦克风与喇叭是两个独立方向,可以同时工作——这一条正是本篇最大的坑,详见第三节。
1.4 蓝牙的暂停与恢复
static void bt_pipelines_pause_locked(void)
{
if (s_bt2codec_active && bt2codec_pipe) {
esp_gmf_pipeline_stop(bt2codec_pipe);
}
if (s_codec2bt_active && codec2bt_pipe) {
esp_gmf_pipeline_stop(codec2bt_pipe);
esp_gmf_pipeline_reset(codec2bt_pipe);
}
if (s_local2bt_active && local2bt_pipe) {
esp_gmf_pipeline_stop(local2bt_pipe);
esp_gmf_pipeline_reset(local2bt_pipe);
}
}
static void bt_pipelines_resume_locked(void)
{
if (s_bt2codec_active && bt2codec_pipe) {
esp_gmf_err_t ret = esp_gmf_pipeline_reset(bt2codec_pipe);
if (ret == ESP_GMF_ERR_OK) {
ret = esp_gmf_pipeline_loading_jobs(bt2codec_pipe);
}
if (ret == ESP_GMF_ERR_OK) {
esp_gmf_pipeline_run(bt2codec_pipe);
} else {
ESP_LOGE(TAG, "Resume bt2codec failed: ret=0x%x", ret);
}
}
/* local2bt 同理(A2DP Source 模式下才有意义) */
}pause_ref 是引用计数,每 acquire 加一、release 减一,只有降为 0 时才真正恢复蓝牙,避免多路 HTTP 请求互相干扰。
1.5 锁的纪律:两把锁绝不嵌套
最初的实现里,audio_arbiter_acquire() 先取 s_arb.lock 再取 s_bt_mutex,而蓝牙事件回调 stream_proc_state_chg() 先取 s_bt_mutex 再调用 audio_arbiter_is_held_by()(内部取 s_arb.lock)——一正一反,构成 ABBA 死锁。死锁发生在蓝牙协议栈任务里,现象是"蓝牙事件突然全停、扫描模式不恢复、只能重启"。
修正方式是结构性的:两把锁永不嵌套,并且把会阻塞的 pipeline 操作挪到锁外。
esp_err_t audio_arbiter_acquire(audio_path_t path)
{
esp_err_t ret = ESP_OK;
bool need_pause = false;
xSemaphoreTake(s_arb.lock, portMAX_DELAY); /* ① 只做状态决策 */
if (path == AUDIO_PATH_HTTP) {
if (s_arb.out_owner == AUDIO_PATH_HTTP) {
ret = ESP_ERR_INVALID_STATE;
goto out;
}
if (s_arb.pause_ref == 0) {
need_pause = true; /* 只有 0→1 那一次才暂停蓝牙 */
}
s_arb.pause_ref++;
s_arb.out_owner = AUDIO_PATH_HTTP;
s_http_out_held = true;
} else if (path == AUDIO_PATH_ASR) {
...
s_arb.in_owner = AUDIO_PATH_ASR;
} else if (path == AUDIO_PATH_WWE) {
...
s_arb.in_owner = AUDIO_PATH_WWE;
}
out:
xSemaphoreGive(s_arb.lock);
if (ret == ESP_OK && need_pause && s_pipe_lock) { /* ② 慢操作放到锁外 */
xSemaphoreTake(s_pipe_lock, portMAX_DELAY);
bt_pipelines_pause_locked();
xSemaphoreGive(s_pipe_lock);
}
return ret;
}释放路径同理:先改状态、放锁,再在 s_pipe_lock 里恢复蓝牙。
二、踩坑其一:输入与输出被做成了互斥
2.1 最初的错误设计
第一版仲裁器把"麦克风"和"喇叭"当成互斥资源:
HTTP 申请输出时,只要 in_owner 是 ASR 或 WWE 就拒绝;
WWE 申请输入时,只要 out_owner 是 HTTP 就拒绝。
看起来"同一时刻只让一路音频工作"很安全,实际直接把语音闭环锁死了。
2.2 现象
W STREAM_PROC: HTTP rejected: input path (2) is active W STREAM_PROC: WWE rejected: HTTP is playing
唤醒监听是常驻的(待机时 in_owner 一直是 WWE),于是 TTS 回复永远被拒绝,设备只播提示音不播回答;
反过来,一旦有一次 TTS 正在播放,wake_word_start() 就会被拒,而代码只在日志里打一行 warning、不会重试,于是唤醒永久失效,直到重启。
2.3 正确的模型:方向独立
修正后只保留两组互斥:
输出通道内部互斥(HTTP 与蓝牙,蓝牙靠暂停让路);
输入通道内部互斥(ASR 与 WWE,两个麦克风读者不能同时读)。
麦克风与喇叭各自独立,互不阻塞。唯一的例外是保留"ASR 不抢正在播放的 TTS"这条,避免把喇叭声录进麦克风:
} else if (path == AUDIO_PATH_ASR) {
if (s_arb.in_owner == AUDIO_PATH_WWE || s_arb.in_owner == AUDIO_PATH_ASR) {
ret = ESP_ERR_INVALID_STATE; /* 输入通道内部互斥 */
goto out;
}
if (s_arb.out_owner == AUDIO_PATH_HTTP) {
ret = ESP_ERR_INVALID_STATE; /* 避免录进 TTS */
goto out;
}
s_arb.in_owner = AUDIO_PATH_ASR;
}2.4 配套:TTS 播放期间忽略唤醒
输入输出解耦后,唤醒监听的麦克风会听到喇叭声音。板子是单麦、没有 AEC 参考通道,为了避免"自己把自己唤醒",在 AFE 事件回调里加一道过滤——只忽略这一次唤醒事件,AFE 继续运行,播放结束后唤醒立刻可用,不需要重建 pipeline:
static void afe_event_cb(esp_gmf_obj_handle_t obj, esp_gmf_afe_evt_t *event, void *user_data)
{
switch (event->type) {
case ESP_GMF_AFE_EVT_WAKEUP_START:
ESP_LOGI(TAG, "WAKEUP_START");
/* 输出通道被 HTTP 占用时忽略,防止喇叭声自触发 */
if (audio_arbiter_is_held_by(AUDIO_PATH_HTTP)) {
ESP_LOGD(TAG, "Ignore wake event: HTTP audio is playing");
break;
}
if (s_notify_task) {
xTaskNotifyGive(s_notify_task);
}
break;
case ESP_GMF_AFE_EVT_WAKEUP_END:
ESP_LOGI(TAG, "WAKEUP_END");
break;
default:
break;
}
}三、唤醒监听(借鉴官方 WWE 示例)
唤醒监听借鉴了官方 WWE 示例的思路:用 ai_afe 元件(WakeNet 引擎)搭一条 io_codec_dev → aud_rate_cvt → ai_afe 的 pipeline。本项目在此基础上做了三处适配:强制 AFE 任务使用内部 RAM、唤醒事件用 xTaskNotifyGive 通知 voice_ctrl、以及启动失败时的清理与归还。
3.1 Pipeline 结构
io_codec_dev → aud_rate_cvt(→16k) → ai_afe
ai_afe 元件由 gmf_loader_setup_all_defaults() 注册。核心代码(wake_word.c,已省略部分日志):
esp_err_t wake_word_start(esp_gmf_pool_handle_t pool)
{
if (s_wn_running) return ESP_OK;
/* 1. 申请 WWE 输入通道 */
esp_err_t arb = audio_arbiter_acquire(AUDIO_PATH_WWE);
if (arb != ESP_OK) {
ESP_LOGW(TAG, "Cannot start WWE: %s", esp_err_to_name(arb));
return arb;
}
esp_gmf_err_t load_err = ESP_GMF_ERR_OK;
/* 2. 建 pipeline:codec 读 → 16k → AFE */
const char *name[] = {"aud_rate_cvt", "ai_afe"};
esp_gmf_pool_new_pipeline(pool, "io_codec_dev", name, 2, NULL, &s_wn_pipe);
if (s_wn_pipe == NULL) {
ESP_LOGE(TAG, "Create WWE pipeline failed");
audio_arbiter_release(AUDIO_PATH_WWE);
return ESP_FAIL;
}
/* 3. 采样率转 16k,并上报 ADC 实际输出格式 48k/2ch */
esp_gmf_element_handle_t rate_el = NULL;
esp_gmf_pipeline_get_el_by_name(s_wn_pipe, "aud_rate_cvt", &rate_el);
esp_gmf_rate_cvt_set_dest_rate(rate_el, 16000);
esp_gmf_info_sound_t info = {
.sample_rates = 48000, .channels = 2, .bits = 16,
.format_id = ESP_FOURCC_PCM,
};
esp_gmf_pipeline_report_info(s_wn_pipe, ESP_GMF_INFO_SOUND, &info, sizeof(info));
/* 4. 设置 AFE 事件回调与丢弃型输出端口 */
esp_gmf_element_handle_t afe_el = NULL;
esp_gmf_pipeline_get_el_by_name(s_wn_pipe, "ai_afe", &afe_el);
if (afe_el == NULL) { /* 清理并归还仲裁器 */ }
esp_gmf_afe_set_event_cb(afe_el, afe_event_cb, NULL);
esp_gmf_port_handle_t out_port = NEW_ESP_GMF_PORT_OUT_BYTE(
afe_out_acquire, afe_out_release, NULL, NULL, 2048, 100);
esp_gmf_pipeline_reg_el_port(s_wn_pipe, "ai_afe", ESP_GMF_IO_DIR_WRITER, out_port);
/* 5. AFE 任务强制内部 RAM */
esp_gmf_task_cfg_t cfg = DEFAULT_ESP_GMF_TASK_CONFIG();
cfg.thread.stack = 5120;
cfg.thread.prio = 5;
cfg.thread.core = 0;
cfg.thread.stack_in_ext = false;
esp_gmf_task_init(&cfg, &s_wn_task);
if (s_wn_task == NULL) goto fail;
esp_gmf_pipeline_bind_task(s_wn_pipe, s_wn_task);
/* 6. AFE/wakenet 模型在这里加载,是最常见的失败点(model 分区未烧、内部 RAM 不足) */
load_err = esp_gmf_pipeline_loading_jobs(s_wn_pipe);
if (load_err != ESP_GMF_ERR_OK) {
ESP_LOGE(TAG, "Load AFE jobs failed: 0x%x", (unsigned)load_err);
goto fail;
}
esp_gmf_pipeline_run(s_wn_pipe);
s_wn_running = true;
return ESP_OK;
fail:
/* 关键:失败必须如实返回,且归还输入通道,否则上层会以为在监听、永远不再重试 */
if (s_wn_pipe) {
esp_gmf_pipeline_stop(s_wn_pipe);
esp_gmf_pipeline_destroy(s_wn_pipe);
s_wn_pipe = NULL;
}
if (s_wn_task) {
esp_gmf_task_deinit(s_wn_task);
s_wn_task = NULL;
}
s_wn_running = false;
audio_arbiter_release(AUDIO_PATH_WWE);
return ESP_FAIL;
}3.2 唤醒事件通知
见 2.4 节的 afe_event_cb:WAKEUP_START 时若输出通道空闲,就 xTaskNotifyGive 唤醒 voice_ctrl 任务。
3.3 唤醒词选择
唤醒词最好只选 wn9 系列,同时勾选其它唤醒模型容易出问题。

另外注意:AFE 的 Channel Allocation 必须设置为"MR"(Mic + Reference 两通道),否则唤醒不了。

还有一个和硬件相关的点:本板是单颗单声道 mic、没有 AEC 参考通道,如果打开 AEC,参考通道只能拿到 mic 自身的副本,等于"自己抵消自己",唤醒会一直不触发。建议关闭 AEC(CONFIG_GMF_AI_AUDIO_AFE_AEC_ENABLE=n);将来要边放音边唤醒,再补真实的回采参考通道。
3.4 分区表调整
唤醒模型需要一个名为 model 的 spiffs 分区。本项目不做 OTA,因此屏蔽 ota_1、只保留单 app 分区,腾出空间给模型:
# Name, Type, SubType, Offset, Size nvs, data, nvs, 0xA000, 0x6000 otadata, data, ota, 0x10000, 0x2000 phy_init, data, phy, 0x12000, 0x1000 ota_0, app, ota_0, , 5M #ota_1, app, ota_1, , 5M system, data, fat, , 2600K storage, data, fat, , 3M model, data, spiffs, , 4M
四、本地提示音播放
提示音场景需要播放本地文件(text2audio.wav,内容是"我一直都在"),复用 HTTP 播放器同一套管线,只把输入源换成 io_file:
esp_err_t play_local_file_async(const char *file_path)
{
if (!file_path || !s_pool || !s_evt) return ESP_ERR_INVALID_ARG;
/* 1. 申请输出通道(会暂停蓝牙) */
esp_err_t arb_ret = audio_arbiter_acquire(AUDIO_PATH_HTTP);
if (arb_ret != ESP_OK) return arb_ret;
char *uri_copy = strdup(file_path);
if (!uri_copy) {
audio_arbiter_release(AUDIO_PATH_HTTP);
return ESP_ERR_NO_MEM;
}
if (xTaskCreate(local_player_task, "local_player", 8192, uri_copy, 10, NULL) != pdPASS) {
free(uri_copy);
audio_arbiter_release(AUDIO_PATH_HTTP);
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}local_player_task 关键片段:
static void local_player_task(void *arg)
{
char *uri = (char *)arg;
const char *name[] = {"aud_dec", "aud_rate_cvt", "aud_ch_cvt", "aud_bit_cvt"};
esp_gmf_pool_new_pipeline(s_pool, "io_file", name, 4, "io_codec_dev", &pipe);
esp_gmf_pipeline_set_event(pipe, http_pipe_event_cb, NULL);
esp_gmf_pipeline_set_in_uri(pipe, uri);
/* 统一输出格式:48k/2ch(见下文"为什么必须统一") */
http_player_force_output_format(pipe);
/* 创建 task、绑定、run、等完成(带超时) */
...
EventBits_t local_bits = xEventGroupWaitBits(s_evt, HTTP_PLAYER_BLOCK_BIT, pdTRUE, pdFALSE,
pdMS_TO_TICKS(HTTP_PLAYER_FINISH_TIMEOUT_MS));
if ((local_bits & HTTP_PLAYER_BLOCK_BIT) == 0) {
ESP_LOGW(TAG, "Local playback timeout, force stop");
}
esp_gmf_pipeline_stop(pipe);
_cleanup_local:
/* 先释放仲裁器(恢复蓝牙),再销毁 pipeline/task */
audio_arbiter_release(AUDIO_PATH_HTTP);
...
}4.1 两个容易忽略的细节
① 输出格式必须统一成 48kHz / 2ch。 板子上 DAC 与 ADC 共用同一个 I2S 端口(全双工),且 codec 固定开在 48k。如果播放管线让 aud_rate_cvt 用 Kconfig 默认的 44.1k,就会把共享的 I2S 时钟改掉,正在监听的 48k 麦克风跟着跑偏。提示音本身是 24kHz 单声道,正好靠这一级重采样统一到 48k/2ch:
static void http_player_force_output_format(esp_gmf_pipeline_handle_t pipe)
{
esp_gmf_element_handle_t rate_el = NULL;
esp_gmf_pipeline_get_el_by_name(pipe, "aud_rate_cvt", &rate_el);
if (rate_el != NULL) {
esp_gmf_rate_cvt_set_dest_rate(rate_el, 48000);
}
esp_gmf_element_handle_t ch_el = NULL;
esp_gmf_pipeline_get_el_by_name(pipe, "aud_ch_cvt", &ch_el);
if (ch_el != NULL) {
esp_gmf_ch_cvt_set_dest_channel(ch_el, 2);
}
}② 等待完成一定要有超时。 第一版用 portMAX_DELAY 等 pipeline 结束事件,一旦 pipeline 卡住(网络流卡死、事件丢失),AUDIO_PATH_HTTP 就永远不释放:蓝牙一直处于暂停、后续 TTS 一直"忙"、唤醒一直被拒,只能重启。现在改为 180s 超时强制收尾,并且先 release 仲裁器、再销毁 pipeline,避免销毁过程中还需要恢复蓝牙时碰到已释放的对象。
五、语音识别(ASR)
ASR 采用两阶段设计——录音与网络上传解耦,避免网络阻塞录音路径。
5.1 录音任务
io_codec_dev → aud_ch_cvt → io_file
录到 /ramfs/asr.pcm(PSRAM,掉电即失),48kHz/2ch/16bit。
static void asr_record_task(void *arg)
{
/* 1. 申请 ASR 输入通道;HTTP 正在播放则拒绝 */
esp_err_t arb_ret = audio_arbiter_acquire(AUDIO_PATH_ASR);
if (arb_ret != ESP_OK) {
strlcpy(s_last_result, "当前正在播放,无法录音", sizeof(s_last_result));
vTaskDelete(NULL);
return;
}
/* 2. 确保 HTTP 已停止 */
app_stop_http_audio();
vTaskDelay(pdMS_TO_TICKS(200));
/* 3. 构建 pipeline 并绑定 ADC 设备 */
const char *name[] = {"aud_ch_cvt"};
esp_gmf_pool_new_pipeline(s_pool, "io_codec_dev", name, 1, "io_file", &pipe);
esp_gmf_io_codec_dev_set_dev(ESP_GMF_PIPELINE_GET_IN_INSTANCE(pipe),
adc_handle->codec_dev);
esp_gmf_info_sound_t info = {
.sample_rates = CODEC_ADC_SAMPLE_RATE, /* 48k */
.channels = CODEC_ADC_CHANNELS, /* 2 */
.bits = CODEC_ADC_BITS_PER_SAMPLE, /* 16 */
.format_id = ESP_FOURCC_PCM,
};
esp_gmf_pipeline_report_info(pipe, ESP_GMF_INFO_SOUND, &info, sizeof(info));
esp_gmf_pipeline_set_out_uri(pipe, RECORD_FILE_PATH);
esp_gmf_pipeline_run(pipe);
/* 4. 主任务计时(不阻塞 pipeline 工作线程) */
TickType_t t0 = xTaskGetTickCount();
while (!g_record_finished && (xTaskGetTickCount() - t0) < pdMS_TO_TICKS(duration_ms + 500)) {
vTaskDelay(pdMS_TO_TICKS(50));
}
/* 5. 停止、投递上传 job、释放输入通道 */
esp_gmf_pipeline_stop(pipe);
esp_gmf_pipeline_destroy(pipe);
asr_upload_job_t job = {0};
strlcpy(job.path, RECORD_FILE_PATH, sizeof(job.path));
job.duration_ms = duration_ms;
if (xQueueSend(s_upload_queue, &job, 0) != pdTRUE) {
remove(RECORD_FILE_PATH); /* 上传队列满则丢弃本次录音 */
}
audio_arbiter_release(AUDIO_PATH_ASR);
}5.2 上传任务(独立 task)
static void asr_upload_task(void *arg)
{
asr_upload_job_t job;
for (;;) {
xQueueReceive(s_upload_queue, &job, portMAX_DELAY);
/* 1. 读 PCM 到 PSRAM */
FILE *f = fopen(job.path, "rb");
fseek(f, 0, SEEK_END);
long file_size = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *raw_buf = heap_caps_malloc(file_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
uint8_t *wav_buf = heap_caps_malloc(file_size / 3 + 44,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
fread(raw_buf, 1, file_size, f);
fclose(f);
/* 2. 降采样:48k 双声道 → 16k 单声道(交错存放,步长 6 取左声道) */
int16_t *src = (int16_t *)raw_buf;
int16_t *dst = (int16_t *)(wav_buf + 44);
size_t src_samples = file_size / 2;
size_t dst_samples = 0;
for (size_t i = 0; i < src_samples; i += 6) {
dst[dst_samples++] = src[i];
}
/* 3. 补 WAV 头,按 16kHz 单声道声明 */
write_wav_header(wav_buf, dst_samples * 2, 16000);
/* 4. 百度 ASR */
char *text = NULL;
esp_err_t ret = baidu_asr_recognize(wav_buf, dst_samples * 2 + 44, &text);
if (ret == ESP_OK && text) {
strlcpy(s_last_result, text, sizeof(s_last_result));
free(text);
}
free(raw_buf);
free(wav_buf);
remove(job.path);
}
}降采样步长是最容易写错的地方:PCM 是双声道交错存放(L0 R0 L1 R1 …),48kHz 下每秒 48000 帧、96000 个 int16 样本。要得到 16kHz 单声道,就是每 3 帧取一个左声道,即样本下标步长为 6。如果误按"每 3 个样本取 1 个",相当于每 1.5 帧取一个,得到的其实是 8kHz 数据却按 16kHz 播放,语速翻倍、识别率骤降。
另外 wav_buf 按 file_size / 3 申请是宽松值,理论只需要 file_size / 6 + 44 字节,想省内存可以改小。
5.3 百度 ASR 调用
esp_err_t baidu_asr_recognize(const uint8_t *pcm_data, size_t len, char **out_text)
{
/* 复用 TTS 的 access_token */
char token[256] = {0};
baidu_tts_get_token(token, sizeof(token));
char url[512];
snprintf(url, sizeof(url),
"%s?cuid=esp32_dev_001&token=%s&dev_pid=%d&format=wav&rate=16000&channel=1",
ASR_URL, token, 1537 /* 普通话带标点 */);
esp_http_client_set_header(client, "Content-Type", "audio/wav;rate=16000");
esp_http_client_set_post_field(client, (const char *)pcm_data, len);
esp_http_client_perform(client);
/* 解析 JSON:err_no == 0 时取 result[0] */
}5.4 RAMFS 与录音时长
录音时长与 RAMFS 容量直接相关:5 秒 48kHz/2ch/16bit ≈ 960KB,因此必须在 PSRAM 上开一个足够大的 RAMFS。
#define APP_FS_RAMFS_MAX_BYTES (3 * 1024 * 1024)
static esp_err_t app_fs_init_ramfs(void)
{
ramfs_config_t config = {
.base_path = s_ramfs_base_path,
.max_files = APP_FS_RAMFS_MAX_FILES,
.max_bytes = APP_FS_RAMFS_MAX_BYTES,
.caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
};
ESP_RETURN_ON_ERROR(ramfs_register(&config), TAG, "Failed to mount RAMFS at %s",
s_ramfs_base_path);
return ESP_OK;
}esp_err_t err = asr_player_record_and_recognize(5000); /* 5 秒 */
5.5 识别结果的"就绪通知":别被 busy 骗了
录音任务一结束就把 s_asr_busy 置 false,而真正的百度 ASR 请求是在 upload task 里完成的——日志里 ASR done 比 Upload task: done, result=... 早 1~2 秒。如果这时去读 s_last_result,要么读到空串,要么读到上一次的旧结果。
所以给 asr_player 增加一个"结果就绪"信号量:upload task 的每条退出路径(成功 / 录音为空 / 识别失败 / 上传队列满 / 仲裁被拒)都调用 asr_publish_result(ok) 通知,上层用新接口阻塞等待:
esp_err_t asr_player_wait_result(char *out_buf, size_t max_len, uint32_t timeout_ms); /* 返回 ESP_OK:识别成功,out_buf 是识别文本 * 返回 ESP_FAIL:识别失败,out_buf 是失败原因("录音为空"/"识别失败"…) * 返回 ESP_ERR_TIMEOUT:等超时 */
这样上层的 Agent 桥接既能拿到本次的识别文本,也不会把"录音为空"这种错误文案当成用户说的话。
调试时如果百度返回 err_no=3301(speech quality error),基本都是录音音量太小或有效语音太短:日志里会打印 Audio Max Amplitude,可以先确认提示音完全停止后再开始录音,再考虑提高 ADC 增益。
六、语音闭环:把识别文本送进 Agent
识别出文本只是半程,真正"听懂"还要把这句话交给 Agent,再把 Agent 的回复念出来。本项目没有直接调 Agent 的 API,而是走 Claw 的事件路由——这正好是网页聊天用的同一条入口。
6.1 Agent 的对话入口是事件路由
Web UI 和语音最终都汇到同一条链:
用户消息
→ cap_im_local_emit_user_message()
→ claw_event_router_publish_message("local_gateway", channel, chat_id, text, sender, msg_id)
→ 规则 im_any_message_agent(动作 run_agent)
→ claw_core 里跑 LLM
→ 发布 out_message 事件
→ 规则 agent_out_message_send_message(动作 send_message)
→ 按 channel 找到 send cap:local_send_message
→ cap_im_local_send_text_impl() → TTS 播报也就是说,语音接入不需要碰 Agent / TTS 的代码,只要把 ASR 文本"伪装"成一条本地 IM 用户消息发进去,后面的 Agent 回复和语音播报就自动打通了。
6.2 桥接实现:回调解耦 + main.c 投递
audio_play 组件不直接依赖 Claw 组件,用一个回调把文本交出去(voice_ctrl.h):
typedef void (*voice_ctrl_asr_text_cb_t)(const char *text); void voice_ctrl_set_asr_text_callback(voice_ctrl_asr_text_cb_t cb);
main.c 里注册回调,把文本投进事件路由:
static void on_voice_asr_text(const char *text)
{
if (!text || !text[0]) {
return;
}
/* channel 必须是已绑定 send cap 的 "web";chat_id 固定,让语音对话独立成一个会话 */
esp_err_t err = cap_im_local_emit_text("web", "voice", "voice_user", NULL, text);
if (err == ESP_OK) {
ESP_LOGI(TAG, "Voice text -> agent: %s", text);
} else {
ESP_LOGW(TAG, "Publish voice text failed: %s", esp_err_to_name(err));
}
}
...
voice_ctrl_set_asr_text_callback(on_voice_asr_text);voice_ctrl 在拿到 ASR 结果后回调(同时先把唤醒监听恢复):
char asr_text[512] = {0};
esp_err_t rerr = asr_player_wait_result(asr_text, sizeof(asr_text), VOICE_ASR_RESULT_MS);
s_interaction_active = false;
/* 先恢复监听:Agent 思考 / TTS 播放期间仍可被唤醒 */
wake_word_start(s_pool);
if (rerr == ESP_OK && asr_text[0]) {
ESP_LOGI(TAG, "ASR result -> agent: %s", asr_text);
if (s_asr_text_cb) {
s_asr_text_cb(asr_text);
}
}这里有四个容易踩的点:
channel 必须是 web:app_claw.c 里只注册了 "web" → "local_send_message" 这个绑定,换成没绑定的通道,回复就送不回来;想单独用 voice 通道,就在那里加一行 claw_event_router_register_outbound_binding("voice", "local_send_message");
chat_id 固定 "voice":规则用 session_policy: chat,这样语音对话有独立的会话上下文,不会和网页里打字的内容混在一起;
先重启唤醒、再投递文本:投递只是发布事件,但 Agent 可能要跑好几秒,没必要等到它回答完才恢复监听;
ASR 失败不投递:wait_result 返回错误时文本是"录音为空"之类的失败原因,投进去 Agent 会当成用户说的话。
6.3 Agent 的回复怎么变成语音
Agent 回复走 agent_out_message_send_message 规则 → send_message 动作 → local_send_message capability → cap_im_local_send_text_impl()。这里只做了两件事:
event_type == "out_message" 的消息才 tts_player_enqueue();
其它(working reply / attachment saved / agent stage 等提示语)只上屏、不朗读。
const char *event_type = cap_im_local_json_string(root, "event_type");
bool speak = true;
if (event_type && event_type[0]) {
speak = (strcmp(event_type, "out_message") == 0); /* 只有 Agent 最终回复要念 */
}
...
if (s_local.log_outbound_messages) {
ESP_LOGI(TAG, "Outbound local IM ... tts=%s ...", speak ? "on" : "off");
if (speak) {
tts_player_enqueue(text, 60000);
}
}改之前,每轮对话会把"ESP-Claw is snapping on it..."这条占位语也念一遍——先白等 16 秒合成、再放 5 秒,真实回复只能排在它后面。现在占位语只留在网页上,语音从真正的回复开始。
6.4 时序与仲裁
唤醒 → 停监听 → 提示音 → ASR 录音 → 等识别就绪 → 重启监听 → 投递 Agent → Agent 思考 → TTS 播放
TTS 播放时唤醒监听正在跑:输入 / 输出方向独立,允许同时;
播放期间 AFE 忽略唤醒事件,避免喇叭声自触发;播放结束立刻恢复可唤醒;
ASR 录音期间仲裁器会拒绝 HTTP 输出,不会把 TTS 的声音录进麦克风。
七、TTS 延迟优化:把长文本接口换成短文本接口
7.1 先量一下时间都花在哪
第一版用的是百度长文本在线合成(/rpc/2.0/tts/v1/create 创建任务 + /query 轮询),实测一句 30 字左右的回复要 16.5 秒:
| 178890 | playing text: ESP-Claw is snapping on it... | — |
| 180541 | TTS task created: ... | +1651 ms(token 检查 + create 的 HTTPS 往返) |
| 183006 | task still running, 2000/60000 ms | +2465 ms(sleep 2s + 查询 0.46s) |
| 185417 | task still running, 4000/60000 ms | +2411 ms |
| 187841 | task still running, 6000/60000 ms | +2424 ms |
| 190431 | task still running, 8000/60000 ms | +2590 ms |
| 192868 | task still running, 10000/60000 ms | +2437 ms |
| 195350 | speech_url obtained after 12000 ms | +2482 ms |
拆开看:服务端异步任务本身排队约 10 秒,2 秒一次的轮询又等了 12 秒,每次 HTTPS 还要 0.4~0.5 秒;拿到 URL 之后,HTTP 播放器还要从 BOS 把 28 KB 的 mp3 再下载一遍。
官网"在线调试"快,是因为那边用的是短文本在线合成——一次请求同步返回音频,没有排队、没有轮询。
7.2 短文本在线合成:一次请求直接拿音频
#define SHORT_TTS_URL "https://tsn.baidu.com/text2audio"
esp_err_t baidu_tts_synthesize_short(const char *text, const char *out_path, int timeout_ms)
{
char token[256] = {0};
baidu_tts_get_token(token, sizeof(token)); /* 和 ASR 复用同一个 access_token */
char *tex = _url_encode(text); /* 中文和标点必须 URL 编码 */
snprintf(body, body_cap,
"tex=%s&tok=%s&cuid=esp32s31_edge_agent&ctp=1&lan=zh"
"&spd=5&pit=5&vol=5&per=0&aue=3", /* aue=3 → 输出 mp3 */
tex, token);
/* POST 表单到 SHORT_TTS_URL:
* 成功 → 响应体就是 mp3 二进制;
* 失败 → 响应体是 JSON(err_no / err_msg)。 */
...
}两个细节:
成功/失败要能区分:成功是 mp3 二进制、失败是 JSON,用 Content-Type 里有没有 "json" + 首字节是不是 { 双重判断,失败时把 err_no 打进日志;
音频落盘再本地播放:写到 /ramfs/tts_reply.mp3,用 play_local_file_async() 播放,省掉"从 BOS 再下载一遍"的那次连接。
7.3 tts_play_task 改造:短文本优先 + 长文本兜底
size_t text_len = strlen(msg.text);
bool played = false;
if (text_len <= 900) { /* ≤900 字节(约 300 汉字)走短文本 */
if (baidu_tts_synthesize_short(msg.text, "/ramfs/tts_reply.mp3", 10000) == ESP_OK &&
play_local_file_async("file:///ramfs/tts_reply.mp3") == ESP_OK) {
played = true;
_wait_playback_done(120000); /* 播完再合成下一句,避免覆盖正在播放的文件 */
}
}
if (!played) { /* 长回复 / 短接口失败 → 原长文本接口兜底 */
char *speech_url = NULL;
if (baidu_tts_synthesize(msg.text, &speech_url, msg.timeout_ms) == ESP_OK) {
app_play_http_audio_async(speech_url, msg.timeout_ms);
free(speech_url);
_wait_playback_done(120000);
}
}短句走短文本接口,长回复仍走长文本接口,超长内容不受影响;
短接口失败自动回退,不会因为接口异常变成"哑巴";
共用一个 /ramfs/tts_reply.mp3,所以必须"播完一句再合成下一句",否则下一句会覆盖正在播放的文件;
长文本兜底路径也顺手优化:轮询 2000 ms → 500 ms,并改成"先查询再 sleep",少等一轮。
7.4 效果
| 单句合成 | ~16.5 s | 首句 1~2 s(含 TLS 握手),之后 0.3~0.8 s |
| 播放前下载 | 有(BOS 再下一遍) | 无(直接本地文件) |
| 占位语额外开销 | ~21 s(16.5s 合成 + 5s 播放) | 0(只上屏不朗读) |
I BAIDU_TTS: playing text (len=33): 你好!我是 ESP-Claw,你的设备端 AI 助手。... I BAIDU_TTS: short TTS ok: xxxxx bytes -> /ramfs/tts_reply.mp3 (620 ms) I HTTP_PLAYER: Start local file playback: file:///ramfs/tts_reply.mp3
到这里,"说一句话 → 听到 Agent 的语音回答"就真正闭环了,体感延迟主要剩下 ASR(1~2 s)+ LLM(3~10 s,取决于网络)。
八、语音控制编排
voice_ctrl.c 把唤醒监听、本地播放、ASR、TTS 串成完整闭环。下面代码中 VOICE_WAIT_PROMPT_MS = 15s、VOICE_ASR_MAX_MS = 90s、VOICE_ASR_RESULT_MS = 30s、VOICE_WWE_HEALTH_PERIOD_MS = 3s、VOICE_INTERACTION_STALE_MS = 120s。
static void voice_ctrl_task(void *arg)
{
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
s_interaction_active = true;
s_interaction_start = xTaskGetTickCount();
/* 1. 收到唤醒,停监听,释放输入通道 */
wake_word_stop();
vTaskDelay(pdMS_TO_TICKS(100));
/* 2. 播提示音(异步),带超时地等 HTTP 输出通道释放 */
esp_err_t perr = play_local_file_async("file:///fatfs/text2audio.wav");
if (perr == ESP_OK) {
voice_wait_http_released(VOICE_WAIT_PROMPT_MS); /* 15s 超时 */
vTaskDelay(pdMS_TO_TICKS(100));
} else {
ESP_LOGW(TAG, "Local playback start failed: %s", esp_err_to_name(perr));
}
/* 3. 启动 ASR 录音 5s */
esp_err_t err = asr_player_record_and_recognize(5000);
if (err != ESP_OK) {
ESP_LOGW(TAG, "ASR start failed: %s", esp_err_to_name(err));
s_interaction_active = false;
continue; /* 唤醒交给看护任务恢复 */
}
/* 4. 等录音结束(带超时) */
TickType_t asr_start = xTaskGetTickCount();
while (asr_player_is_busy()) {
if ((xTaskGetTickCount() - asr_start) > pdMS_TO_TICKS(VOICE_ASR_MAX_MS)) {
ESP_LOGW(TAG, "ASR busy timeout");
break;
}
vTaskDelay(pdMS_TO_TICKS(100));
}
/* 5. 等识别结果就绪(录音结束 ≠ 识别结束) */
char asr_text[512] = {0};
esp_err_t rerr = asr_player_wait_result(asr_text, sizeof(asr_text),
VOICE_ASR_RESULT_MS);
s_interaction_active = false;
/* 6. 先重启监听,Agent 思考 / TTS 播放期间仍可被唤醒 */
esp_err_t ret = wake_word_start(s_pool);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Restart WWE failed: %s, will retry in background",
esp_err_to_name(ret));
}
/* 7. 识别成功才投递给 Agent("录音为空"等失败原因不投递) */
if (rerr == ESP_OK && asr_text[0] && s_asr_text_cb) {
s_asr_text_cb(asr_text);
}
}
}8.1 所有等待都必须有超时
最初提示音播放完用 while (audio_arbiter_is_held_by(AUDIO_PATH_HTTP)) vTaskDelay(100); 死等,一旦 HTTP 所有权泄漏,voice_ctrl 就永久卡住,唤醒状态机整体停摆。现在改成带超时的 voice_wait_http_released(),ASR 等待同样加了 90s 上限。
8.2 唤醒看护任务:失败自愈
即使加了超时,仍可能出现"某次 wake_word_start() 失败后再也没人重试"的情况——那就还是永久失聪。所以再加一个低优先级看护任务,只要没在交互、没在录音、唤醒又没在跑,就每 3 秒重试一次:
static void voice_health_task(void *arg)
{
for (;;) {
vTaskDelay(pdMS_TO_TICKS(VOICE_WWE_HEALTH_PERIOD_MS)); /* 3s */
if (wake_word_is_running()) continue;
if (asr_player_is_busy()) continue;
if (audio_arbiter_is_held_by(AUDIO_PATH_ASR)) continue;
/* 交互进行中不抢跑,避免把 WWE 重新占住输入通道、导致后面的 ASR 被拒;
* 超过 2 分钟视为状态残留,强制接管 */
if (s_interaction_active) {
if ((xTaskGetTickCount() - s_interaction_start) <
pdMS_TO_TICKS(VOICE_INTERACTION_STALE_MS)) {
continue;
}
ESP_LOGW(TAG, "Interaction state stale, force recover WWE");
s_interaction_active = false;
}
esp_err_t err = wake_word_start(s_pool);
if (err == ESP_OK) {
ESP_LOGI(TAG, "WWE auto-restarted");
}
}
}8.3 关键点小结
交互期间(唤醒→提示音→录音)看护任务不能抢跑,否则会把 in_owner 重新置成 WWE,紧接着的 ASR 申请就会被仲裁器拒绝;
s_interaction_active 还要有"过期时间",防止状态机异常时永久不恢复;
wake_word_start() 失败时不再"打了日志就算了",而是交给看护任务重试;
ASR 要等 asr_player_wait_result() 的"结果就绪",投递前先恢复唤醒监听,识别失败(文本是"录音为空"之类)不要投递给 Agent。
九、Flash 操作调度
本项目 Wi-Fi + BT 协议栈常驻、多任务并发(ASR/WWE/TTS/HTTP/UI)、频繁 NVS 读写(配置、登录态),而 ESP32 在 Flash 擦写期间会挂起 Cache 暂停取指。若无 dispatcher 串行化,极易触发 Cache disabled but cached memory region accessed 崩溃,且现象随机、难以定位。
所以在 main.c 中,把所有 Flash 操作经队列交给唯一一个 dispatcher task 串行执行,彻底消除多任务竞争;并放在 init_nvs() 之前,确保从第一次 NVS 访问开始就受保护。
const esp_flash_dispatcher_config_t cfg = {
.task_stack_size = 2048,
.task_priority = 10,
.task_core_id = tskNO_AFFINITY,
.queue_size = 1, // 已废弃,保留仅为兼容
};
ESP_ERROR_CHECK(esp_flash_dispatcher_init(&cfg));十、工程结构与初始化
本篇涉及的代码都集中在 components/audio_play 组件里:
audio_play ├── include │ ├── stream_proc.h /* 音频路径枚举 + 仲裁器接口 */ │ ├── wake_word.h /* 唤醒监听 */ │ ├── voice_ctrl.h /* 语音状态机 + ASR 文本回调 */ │ ├── asr_player.h /* 录音 + ASR + 结果就绪通知 */ │ ├── http_player.h /* HTTP / 本地播放 */ │ ├── baidu_tts.h /* TTS(短文本接口 + 长文本兜底) */ │ └── example_bt_audio.h /* 音频公共初始化 + 蓝牙任务 */ └── src ├── stream_proc.c /* 三条蓝牙 pipeline + 音频仲裁器 */ ├── wake_word.c ├── voice_ctrl.c /* 状态机 + 看护任务 + 文本回调 */ ├── asr_player.c ├── baidu_asr.c ├── http_player.c ├── baidu_tts.c /* 短文本在线合成 + 长文本兜底 */ ├── pool_reg.c /* 注册 GMF Element / IO */ └── example_bt_audio.c /* app_audio_init() + 蓝牙协议栈任务 */
Agent 那侧的对话入口(事件路由)在 components/claw_capabilities/cap_im_local,本轮只加了"按 event_type 决定是否朗读"的过滤,以及 main.c 里一行回调注册。
随着音频路径变多,本项目的音频初始化也做了一次拆分:把与蓝牙无关的公共音频初始化从 bt_audio_task 中抽出来,统一由 app_main 调用,蓝牙任务只保留协议栈初始化。
/* main.c:音频子系统在 WiFi / agent 之前初始化 */ ESP_ERROR_CHECK(esp_board_manager_init()); tts_player_init(); /* ... LED 熄灭、文件系统挂载 ... */ ESP_ERROR_CHECK(app_audio_init()); /* codec + GMF pool/pipeline + 播放器 + 唤醒 */ voice_ctrl_set_asr_text_callback(on_voice_asr_text); /* 语音识别文本 → 事件路由 → Agent */ ESP_ERROR_CHECK(wifi_manager_init()); /* ... http_server / wifi 连接 / app_claw_start ... */ xTaskCreate((TaskFunction_t)bt_audio_task, "bt audio task", 8192, NULL, 5, NULL);
/* example_bt_audio.c */
esp_err_t app_audio_init(void)
{
/* 1. 打开 DAC:48kHz/16bit/2ch */
/* 2. 打开 ADC:48kHz/16bit/2ch,输入增益 40dB */
/* 3. esp_gmf_pool_init() + pool_reg()(注册 Element/IO、加载 ESP-SR 模型) */
/* 4. stream_proc_init()(三条 BT pipeline + 音频仲裁器) */
/* 5. http_player_init() / asr_player_init() */
/* 6. voice_ctrl_init()(启动唤醒监听) */
return ESP_OK;
}注意:app_audio_init() 会加载 ESP-SR 模型、创建多个 pipeline 与任务,搬到 main task 后需要把 CONFIG_ESP_MAIN_TASK_STACK_SIZE 从 6144 调到 10240,否则有栈溢出风险。
十一、踩坑与经验
| 1 | TTS 永远放不出声;播放中唤醒起不来 | 把麦克风输入与喇叭输出做成了互斥 | 输入/输出解耦,只保留各自方向内部互斥 |
| 2 | 唤醒失败后永久失聪 | wake_word_start() 失败只打日志、不归还输入通道、不重试 | 失败路径清理并 release,成功才置 s_wn_running;加看护任务 3s 重试 |
| 3 | 蓝牙事件突然全停、只能重启 | s_arb.lock 与蓝牙回调锁反向嵌套(ABBA 死锁) | 两把锁不嵌套,pipeline 慢操作移到锁外 |
| 4 | 提示音播完 TTS 就不响了 | HTTP 输出通道所有权未释放 / 等待无超时 | 完成等待加 180s 超时,先 release 再销毁 pipeline |
| 5 | 播放时麦克风数据跑偏 | DAC/ADC 共用同一 I2S 端口,播放管线默认 44.1k 改了共享时钟 | 播放管线强制 48k/2ch |
| 6 | ASR 识别率极差、语速不对 | 双声道交错 PCM 按"每 3 个样本取 1"降采样,实际得到 8kHz | 按步长 6 取左声道,得到真正的 16kHz |
| 7 | 偶发唤醒漏检 | AFE 任务栈放在 PSRAM,推理延迟抖动 | stack_in_ext = false,AFE 任务栈强制内部 RAM |
| 8 | 随机 Cache disabled 崩溃 | 多任务并发读写 Flash | 统一走 esp_flash_dispatcher 串行化 |
| 9 | ASR 返回 err_no=3301(speech quality error) | 录音音量太小或有效语音太短 | 看日志里的 Audio Max Amplitude,适当提高 ADC 增益 / 延长录音;并确保提示音完全停止后再开始录音 |
| 10 | 识别成功但 Agent 毫无反应 | s_asr_busy 在录音结束就置 false,真正的识别结果晚到;且 s_last_result 没有消费方 | 加 asr_player_wait_result() 就绪通知,并在 voice_ctrl 里回调投递到事件路由 |
| 11 | 每轮先念一句"正在处理",白耗 21 秒 | 所有出站消息都进 TTS,占位语也被朗读 | 按事件 event_type 过滤,只朗读 out_message |
| 12 | 单句 TTS 要 16 秒 | 长文本异步接口 + 2s 轮询,还要再从 BOS 下载一次 | 短句走短文本同步接口 + 落盘本地播放,长文本仅兜底 |
| 13 | 连续多句回复时语音被"截断/跳句" | 下一句合成会覆盖正在播放的 tts_reply.mp3 | 播完一句再合成下一句(_wait_playback_done) |
| 14 | Agent 突然只回一句 "ESP_FAIL",还被念了出来 | 长期记忆索引解析失败 → 长期记忆 provider 失败 → claw_core 把整轮请求判失败 → 错误码又被当回复文本发布 | provider 失败降级为跳过(返回 ESP_ERR_NOT_FOUND);失败回复改成可读提示 |
| 15 | 重启后依旧回 "ESP_FAIL",永久不恢复 | 索引文件被掉电写坏后,启动时只补"缺失"文件、不修复已存在的坏文件 | load 失败先重试一次,仍失败则备份 .bad 并重建空索引(自愈);索引写盘改 tmp → 删旧 → rename,避免半截 JSON |
第 14、15 条表面上像"语音问题"(TTS 把 ESP_FAIL 念出来了),实际根因在存储层:一个被掉电写坏的小 JSON 文件,足以让整轮 Agent 请求失败。排查这类问题要顺着日志往上找"第一个真正的错误",而不是盯着最后那个"念错"的现象。
十二、小结
到这里,"说唤醒词 → 提示音 → 录音 → 识别 → 投递事件路由 → Agent → 回复 → TTS 播报 → 继续监听"的完整语音闭环就跑通了。
两个最值钱的教训:
多路音频共享一套 Codec 与 GMF Pool 时,先把"通道所有权"和"方向独立性"想清楚,再谈互斥:输入与输出是两个方向,不该互斥;真正该互斥的是同一方向上的多个占用者;
"引擎对了,体验才对":同样一句回复,长文本异步 TTS 要 16 秒,换成短文本同步接口 + 本地播放只要 0.6 秒;而占位语这类"非回复"内容干脆不该进 TTS。
另外,所有等待都要有超时、所有失败都要能自愈,否则嵌入式设备很容易"一次异常、永久异常"。
最终完成项目:基于ESP-Claw的桌面智能语音终端——集AI语音助手与蓝牙音乐播放于一体
语音唤醒与 ASR 接入,音频仲裁统一调度代码esp-claw_FinalVersion.zip
我要赚赏金
