电子产品世界 » 论坛首页 » 活动中心 » 板卡试用 » [ESP32-S31Functioncoreboard-1测评]终篇-语音唤醒与

共1条 1/1 1 跳转至页

[ESP32-S31Functioncoreboard-1测评]终篇-语音唤醒与ASR接入,音频仲裁统一调度

助工
2026-11-14 17:40:38     打赏

  本篇是 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 系列,同时勾选其它唤醒模型容易出问题。

AwakeningWord.png

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

channel.png

  还有一个和硬件相关的点:本板是单颗单声道 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()。这里只做了两件事:

  1. event_type == "out_message" 的消息才 tts_player_enqueue();

  2. 其它(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 秒:

时刻日志间隔
178890playing text: ESP-Claw is snapping on it...—
180541TTS task created: ...+1651 ms(token 检查 + create 的 HTTPS 往返)
183006task still running, 2000/60000 ms+2465 ms(sleep 2s + 查询 0.46s)
185417task still running, 4000/60000 ms+2411 ms
187841task still running, 6000/60000 ms+2424 ms
190431task still running, 8000/60000 ms+2590 ms
192868task still running, 10000/60000 ms+2437 ms
195350speech_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,否则有栈溢出风险。


十一、踩坑与经验

#问题原因解决
1TTS 永远放不出声;播放中唤醒起不来把麦克风输入与喇叭输出做成了互斥输入/输出解耦,只保留各自方向内部互斥
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
6ASR 识别率极差、语速不对双声道交错 PCM 按"每 3 个样本取 1"降采样,实际得到 8kHz按步长 6 取左声道,得到真正的 16kHz
7偶发唤醒漏检AFE 任务栈放在 PSRAM,推理延迟抖动stack_in_ext = false,AFE 任务栈强制内部 RAM
8随机 Cache disabled 崩溃多任务并发读写 Flash统一走 esp_flash_dispatcher 串行化
9ASR 返回 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)
14Agent 突然只回一句 "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





关键词: esp32s31     ASR     TTS     BT    

共1条 1/1 1 跳转至页

回复

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