本篇是 ESP32-S31 Function Core Board 测评的第四篇。上篇《LLM 控制蓝牙》实现了在与 Agent 对话中控制蓝牙播放;本篇在此基础上增加语音合成(TTS)播放能力:Agent 的回复文本经百度千帆长文本在线合成转为音频 URL,再通过 HTTP 在线播放到本地 Codec;同时解决蓝牙播放与 HTTP 播放共用同一 pool 时的优先级抢占问题。大致流程如图:

主要技术点:
将原 bt_audio 组件改造为 audio_play,兼顾蓝牙与 HTTP 两路音频;
蓝牙/HTTP 双播放器共存:HTTP 播放期间暂停蓝牙,结束后恢复(信号量 + 标志位);
新增 HTTP 播放器(io_http reader → 解码 → 重采样 → 声道/位深转换 → io_codec_dev writer);
百度千帆 TTS 接入:token 缓存、create/query 任务轮询、合成 URL 交给 HTTP 播放器;
Agent 的回复文本加入语音合成队列;
main.c 集成 tts_player_init(),并顺手解决开机绿灯常亮过亮的问题。
一、组件改造:bt_audio → audio_play
因为组件不再只做蓝牙,还包含 HTTP 音频播放,故重命名为 audio_play。需同步修改 esp-claw/components/claw_capabilities/cap_bt_audio/CMakeLists.txt 中的组件名。
改造后的目录结构:
audio_play ├── include │ ├── app_http_audio.h │ ├── baidu_tts.h │ ├── cmd_reg.h │ ├── codec_defs.h │ ├── example_bt_audio.h │ ├── http_player.h │ ├── pool_reg.h │ └── stream_proc.h ├── src │ ├── app_http_audio.c │ ├── baidu_tts.c │ ├── cmd_reg.c │ ├── example_bt_audio.c │ ├── http_player.c │ ├── pool_reg.c │ └── stream_proc.c ├── CMakeLists.txt ├── idf_component.yml └── Kconfig
components/audio_play/CMakeLists.txt:
idf_component_register( SRC_DIRS "src" INCLUDE_DIRS "include" REQUIRES "esp_bt_audio" "esp_board_manager" "gmf_core" "gmf_audio" "gmf_io" "gmf_misc" PRIV_REQUIRES "wifi_manager" )
二、双播放器共存:蓝牙暂停/恢复机制
蓝牙播放与 HTTP 播放复用同一个 GMF pool。若两路同时运行,会争抢 pool 与 Codec 设备。采用策略:HTTP 播放优先——用户主动触发的 TTS 回复时效性强,蓝牙音乐可中断。实现:在 stream_proc.c 中引入 s_http_paused 标志、各 pipeline 的 active 标志,以及互斥锁 s_bt_mutex 保护状态,避免与 stream_proc_state_chg 回调竞态。新增两个接口:
stream_proc_pause_bluetooth():置位 s_http_paused,停止 bt2codec / local2bt pipeline;
stream_proc_resume_bluetooth():清标志,让 pipeline 事件回调恢复正常行为。
关键代码:
/* ========== HTTP 播放期间蓝牙暂停状态 ========== */
static volatile bool s_http_paused = false;
static volatile bool s_bt2codec_active = false;
static volatile bool s_codec2bt_active = false;
static volatile bool s_local2bt_active = false;
static SemaphoreHandle_t s_bt_mutex = NULL;
void stream_proc_pause_bluetooth(void)
{
ESP_LOGI(TAG, "Pause Bluetooth for HTTP playback");
if (s_bt_mutex) xSemaphoreTake(s_bt_mutex, portMAX_DELAY);
s_http_paused = true;
if (s_bt2codec_active && bt2codec_pipe) {
ESP_LOGI(TAG, "Stopping bt2codec pipeline");
esp_gmf_pipeline_stop(bt2codec_pipe);
}
if (s_codec2bt_active && codec2bt_pipe) {
/* 通话上行(HFP)一般不应暂停;如确有需要,取消注释下面代码 */
// esp_gmf_pipeline_stop(codec2bt_pipe);
}
if (s_local2bt_active && local2bt_pipe) {
ESP_LOGI(TAG, "Stopping local2bt pipeline");
esp_gmf_pipeline_stop(local2bt_pipe);
}
if (s_bt_mutex) xSemaphoreGive(s_bt_mutex);
}
void stream_proc_resume_bluetooth(void)
{
ESP_LOGI(TAG, "Resume Bluetooth after HTTP playback");
if (s_bt_mutex) xSemaphoreTake(s_bt_mutex, portMAX_DELAY);
/* 先清标志,让 pipeline 事件回调恢复正常行为 */
s_http_paused = false;
if (s_bt_mutex) xSemaphoreGive(s_bt_mutex);
} 在 stream_proc_state_chg 的 STARTED / STOPPED 分支中,若 s_http_paused 为真则延后 run() / reset(),待 HTTP 播放结束后由上层重新触发。这保证了蓝牙状态机不会在 HTTP 播放期间误操作 pipeline。
三、HTTP 播放器实现
3.1 pool 中注册 io_http reader,即在 pool_reg.c 的 pool_reg() 末尾新增 HTTP 流读取器:
// 7. HTTP IO - Reader(HTTP/HTTPS 音频流 → Pipeline) http_io_cfg_t http_rx_cfg = HTTP_STREAM_CFG_DEFAULT(); http_rx_cfg.dir = ESP_GMF_IO_DIR_READER; ret = esp_gmf_io_http_init(&http_rx_cfg, &io); ESP_GMF_RET_ON_ERROR(TAG, ret, return ret, "Failed to init HTTP reader"); ret = esp_gmf_pool_register_io(pool, io, NULL); ESP_GMF_RET_ON_ERROR(TAG, ret, return ret, "Failed to register HTTP reader"); ESP_LOGD(TAG, "Registered: io_http (reader)");
3.2 http_player 设计,http_player.h 对外提供 init / play / stop / is_busy / is_ready。核心是异步:http_player_play() 只负责创建后台 task 并立即返回,task 内部完成:
调用 stream_proc_pause_bluetooth() 暂停蓝牙;
创建 pipeline:io_http(reader) → aud_dec → aud_rate_cvt → aud_ch_cvt → aud_bit_cvt → io_codec_dev(writer);
根据 URI 后缀用 esp_gmf_audio_helper_get_audio_type_by_uri() 自动识别格式并 reconfig 解码器;
创建 GMF task 绑定 pipeline,run() 后用事件组等待 STOPPED/FINISHED/ERROR;
清理 pipeline/task,调用 stream_proc_resume_bluetooth() 恢复蓝牙,清 s_busy。
http_player.c部分代码:
static esp_gmf_err_t http_pipe_event_cb(esp_gmf_event_pkt_t *pkt, void *ctx)
{
if (!pkt) {
return ESP_GMF_ERR_OK;
}
if (pkt->type == ESP_GMF_EVT_TYPE_CHANGE_STATE) {
ESP_LOGI(TAG, "HTTP pipeline state: %d", pkt->sub);
if (pkt->sub == ESP_GMF_EVENT_STATE_STOPPED ||
pkt->sub == ESP_GMF_EVENT_STATE_FINISHED ||
pkt->sub == ESP_GMF_EVENT_STATE_ERROR) {
xEventGroupSetBits(s_evt, HTTP_PLAYER_BLOCK_BIT);
}
}
return ESP_GMF_ERR_OK;
}
/* 真正执行 HTTP 播放的 task */
static void http_player_task(void *arg)
{
char *uri = (char *)arg;
ESP_LOGI(TAG, "Start HTTP playback: %s", uri);
/* 1. 先暂停蓝牙 */
stream_proc_pause_bluetooth();
esp_gmf_pipeline_handle_t pipe = NULL;
esp_gmf_task_handle_t task = NULL;
esp_err_t ret = ESP_OK;
/* 2. 创建 HTTP pipeline:io_http(reader) → aud_dec → aud_rate_cvt → aud_ch_cvt → aud_bit_cvt → io_codec_dev(writer) */
const char *name[] = {"aud_dec", "aud_rate_cvt", "aud_ch_cvt", "aud_bit_cvt"};
ret = esp_gmf_pool_new_pipeline(s_pool, "io_http",
name, sizeof(name) / sizeof(char *),
"io_codec_dev", &pipe);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to create HTTP pipeline: %s", esp_err_to_name(ret));
goto _cleanup;
}
esp_gmf_pipeline_set_event(pipe, http_pipe_event_cb, NULL);
esp_gmf_pipeline_set_in_uri(pipe, uri);
/* 3. 根据 URI 后缀自动识别格式并配置解码器 */
{
esp_gmf_element_handle_t dec_el = NULL;
esp_gmf_pipeline_get_el_by_name(pipe, "aud_dec", &dec_el);
esp_gmf_info_sound_t info = {0};
esp_gmf_audio_helper_get_audio_type_by_uri(uri, &info.format_id);
esp_gmf_audio_dec_reconfig_by_sound_info(dec_el, &info);
}
/* 4. 创建 task 并绑定 */
esp_gmf_task_cfg_t cfg = DEFAULT_ESP_GMF_TASK_CONFIG();
cfg.name = "http_play_task";
cfg.thread.core = 1;
cfg.thread.stack = 5120;
cfg.thread.prio = 15;
cfg.thread.stack_in_ext = true;
ret = esp_gmf_task_init(&cfg, &task);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to init HTTP pipeline task");
goto _cleanup;
}
esp_gmf_pipeline_bind_task(pipe, task);
esp_gmf_task_set_timeout(task, 30000);
esp_gmf_pipeline_loading_jobs(pipe);
/* 发布到全局,供 http_player_stop() 使用 */
xSemaphoreTake(s_mutex, portMAX_DELAY);
s_pipe = pipe;
s_task = task;
xSemaphoreGive(s_mutex);
/* 5. 运行并等待完成 */
xEventGroupClearBits(s_evt, HTTP_PLAYER_BLOCK_BIT);
esp_gmf_pipeline_run(pipe);
xEventGroupWaitBits(s_evt, HTTP_PLAYER_BLOCK_BIT, pdTRUE, pdFALSE, portMAX_DELAY);
esp_gmf_pipeline_stop(pipe);
_cleanup:
/* 6. 清空全局句柄 */
xSemaphoreTake(s_mutex, portMAX_DELAY);
s_pipe = NULL;
s_task = NULL;
xSemaphoreGive(s_mutex);
/* 7. 销毁资源 */
if (task) {
esp_gmf_task_deinit(task);
task = NULL;
}
if (pipe) {
esp_gmf_pipeline_destroy(pipe);
pipe = NULL;
}
/* 8. 恢复蓝牙 */
stream_proc_resume_bluetooth();
/* 9. 清除 busy 标志 */
xSemaphoreTake(s_mutex, portMAX_DELAY);
s_busy = false;
xSemaphoreGive(s_mutex);
ESP_LOGI(TAG, "HTTP playback finished: %s", uri);
free(uri);
vTaskDelete(NULL);
}并发限制:s_busy 为真时 http_player_play() 返回 ESP_ERR_INVALID_STATE,即同一时刻只允许一路 HTTP 播放,避免与蓝牙恢复逻辑互相干扰。
esp_err_t http_player_play(const char *uri)
{
if (!uri || !s_pool || !s_evt) {
return ESP_ERR_INVALID_ARG;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (s_busy) {
xSemaphoreGive(s_mutex);
ESP_LOGW(TAG, "HTTP player is busy");
return ESP_ERR_INVALID_STATE;
}
s_busy = true;
xSemaphoreGive(s_mutex);
char *uri_copy = strdup(uri);
if (!uri_copy) {
xSemaphoreTake(s_mutex, portMAX_DELAY);
s_busy = false;
xSemaphoreGive(s_mutex);
return ESP_ERR_NO_MEM;
}
BaseType_t ok = xTaskCreate(http_player_task, "http_player", 8192, uri_copy, 10, NULL);
if (ok != pdPASS) {
free(uri_copy);
xSemaphoreTake(s_mutex, portMAX_DELAY);
s_busy = false;
xSemaphoreGive(s_mutex);
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}3.3 app_http_audio:等待就绪再播放,app_http_audio.c 在上层再包一层,解决“Wi-Fi 未连上 / pool 未就绪就播放”的时序问题。app_play_http_audio_async(url, wait_ready_ms) 创建小 task 轮询:
http_player_is_ready() == true(pool_reg + http_player_init 完成);
wifi_manager_get_status().sta_connected == true(STA 拿到 IP)。
两者满足后调用 http_player_play(url);wait_ready_ms == 0 表示无限等待。这样上层无需关心 wifi 与 pipeline 谁先就绪。
esp_err_t app_play_http_audio_async(const char *url, uint32_t wait_ready_ms)
{
if (!url || url[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
/* 懒创建锁 */
if (s_req_lock == NULL) {
s_req_lock = xSemaphoreCreateMutex();
if (s_req_lock == NULL) {
return ESP_ERR_NO_MEM;
}
}
/* 已有请求在等/在播 → 拒绝,避免并发抢占 */
xSemaphoreTake(s_req_lock, portMAX_DELAY);
if (s_req_active) {
xSemaphoreGive(s_req_lock);
ESP_LOGW(TAG, "An HTTP audio request is already in progress");
return ESP_ERR_INVALID_STATE;
}
s_req_active = true;
xSemaphoreGive(s_req_lock);
http_play_req_t *req = calloc(1, sizeof(*req));
if (!req) {
xSemaphoreTake(s_req_lock, portMAX_DELAY);
s_req_active = false;
xSemaphoreGive(s_req_lock);
return ESP_ERR_NO_MEM;
}
req->url = strdup(url);
req->wait_ready_ms = wait_ready_ms;
if (!req->url) {
free(req);
xSemaphoreTake(s_req_lock, portMAX_DELAY);
s_req_active = false;
xSemaphoreGive(s_req_lock);
return ESP_ERR_NO_MEM;
}
BaseType_t ok = xTaskCreate(http_play_waiter_task, "http_audio_wait",
4096, req, 8, NULL);
if (ok != pdPASS) {
free(req->url);
free(req);
xSemaphoreTake(s_req_lock, portMAX_DELAY);
s_req_active = false;
xSemaphoreGive(s_req_lock);
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}四、百度 TTS 接入
4.1 流程:
获取 access_token:GET /oauth/2.0/token?grant_type=client_credentials&client_id=...&client_secret=...,缓存并设过期时间(提前 5 分钟刷新);
创建任务:POST /rpc/2.0/tts/v1/create,body 含 text[]、lang=zh、format=mp3-48k、speed/pitch/volume,返回 task_id;
轮询查询:POST /rpc/2.0/tts/v1/query,body 含 task_ids[],直到 task_status == Success 取 task_result.speech_url,或 Failure 报错;
把 speech_url 交给 app_play_http_audio_async() 播放。
轮询策略:每 2 秒查询一次(避免过密触发限流),timeout_ms 建议 30000~60000。
baidu_tts.c部分代码
static esp_err_t _http_event_handler(esp_http_client_event_t *evt)
{
http_resp_t *resp = (http_resp_t *)evt->user_data;
if (evt->event_id == HTTP_EVENT_ON_DATA && resp) {
if (resp->len + evt->data_len + 1 > resp->cap) {
size_t new_cap = (resp->cap == 0) ? 1024 : resp->cap * 2;
while (new_cap < resp->len + evt->data_len + 1) new_cap *= 2;
char *nb = realloc(resp->buf, new_cap);
if (!nb) return ESP_ERR_NO_MEM;
resp->buf = nb;
resp->cap = new_cap;
}
memcpy(resp->buf + resp->len, evt->data, evt->data_len);
resp->len += evt->data_len;
resp->buf[resp->len] = '\0';
}
return ESP_OK;
}
static esp_err_t _http_post(const char *url, const char *post_body,
http_resp_t *resp)
{
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.event_handler = _http_event_handler,
.user_data = resp,
.timeout_ms = 15000,
// .crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) return ESP_FAIL;
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, post_body, strlen(post_body));
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "HTTP perform failed: %s", esp_err_to_name(err));
return err;
}
if (status != 200) {
ESP_LOGE(TAG, "HTTP status=%d, body=%s", status,
resp->buf ? resp->buf : "(null)");
return ESP_FAIL;
}
return ESP_OK;
}
static esp_err_t _ensure_token(void)
{
time_t now = time(NULL);
if (s_token.access_token[0] != '\0' && now < s_token.expire_at - 300) {
return ESP_OK; // 至少还有 5 分钟余量
}
char url[512];
snprintf(url, sizeof(url),
TOKEN_URL "?grant_type=client_credentials"
"&client_id=%s&client_secret=%s",
s_api_key, s_secret_key);
http_resp_t resp = {0};
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_GET,
.event_handler = _http_event_handler,
.user_data = &resp,
.timeout_ms = 15000,
// .crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
esp_err_t err = esp_http_client_perform(client);
esp_http_client_cleanup(client);
if (err != ESP_OK || !resp.buf) {
free(resp.buf);
return ESP_FAIL;
}
cJSON *root = cJSON_Parse(resp.buf);
free(resp.buf);
if (!root) return ESP_FAIL;
cJSON *tok = cJSON_GetObjectItem(root, "access_token");
cJSON *exp = cJSON_GetObjectItem(root, "expires_in");
if (!cJSON_IsString(tok) || !cJSON_IsNumber(exp)) {
cJSON_Delete(root);
return ESP_FAIL;
}
strlcpy(s_token.access_token, tok->valuestring, sizeof(s_token.access_token));
s_token.expire_at = now + (time_t)exp->valueint;
cJSON_Delete(root);
ESP_LOGI(TAG, "access_token refreshed, expires in %d s", exp->valueint);
return ESP_OK;
}
static esp_err_t _create_task(const char *text, char *task_id, size_t task_id_size)
{
cJSON *root = cJSON_CreateObject();
cJSON *text_arr = cJSON_CreateArray();
cJSON_AddItemToArray(text_arr, cJSON_CreateString(text));
cJSON_AddItemToObject(root, "text", text_arr);
cJSON_AddStringToObject(root, "lang", "zh");
cJSON_AddStringToObject(root, "format", "mp3-48k");
cJSON_AddNumberToObject(root, "speed", 5);
cJSON_AddNumberToObject(root, "pitch", 5);
cJSON_AddNumberToObject(root, "volume", 5);
char *body = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (!body) return ESP_ERR_NO_MEM;
char url[512];
snprintf(url, sizeof(url), CREATE_URL "?access_token=%s",
s_token.access_token);
http_resp_t resp = {0};
esp_err_t err = _http_post(url, body, &resp);
free(body);
if (err != ESP_OK) { free(resp.buf); return err; }
cJSON *rsp = cJSON_Parse(resp.buf);
free(resp.buf);
if (!rsp) return ESP_FAIL;
cJSON *id = cJSON_GetObjectItem(rsp, "task_id");
if (!cJSON_IsString(id) || id->valuestring[0] == '\0') {
ESP_LOGE(TAG, "create response missing task_id");
cJSON_Delete(rsp);
return ESP_FAIL;
}
strlcpy(task_id, id->valuestring, task_id_size);
ESP_LOGI(TAG, "TTS task created: %s", task_id);
cJSON_Delete(rsp);
return ESP_OK;
}
static esp_err_t _query_task(const char *task_id, char **out_url)
{
cJSON *root = cJSON_CreateObject();
cJSON *id_arr = cJSON_CreateArray();
cJSON_AddItemToArray(id_arr, cJSON_CreateString(task_id));
cJSON_AddItemToObject(root, "task_ids", id_arr);
char *body = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (!body) return ESP_ERR_NO_MEM;
char url[512];
snprintf(url, sizeof(url), QUERY_URL "?access_token=%s",
s_token.access_token);
http_resp_t resp = {0};
esp_err_t err = _http_post(url, body, &resp);
free(body);
if (err != ESP_OK) { free(resp.buf); return err; }
cJSON *rsp = cJSON_Parse(resp.buf);
free(resp.buf);
if (!rsp) return ESP_FAIL;
cJSON *tasks = cJSON_GetObjectItem(rsp, "tasks_info");
if (!cJSON_IsArray(tasks) || cJSON_GetArraySize(tasks) == 0) {
cJSON_Delete(rsp);
return ESP_FAIL;
}
cJSON *task = cJSON_GetArrayItem(tasks, 0);
cJSON *status = cJSON_GetObjectItem(task, "task_status");
if (!cJSON_IsString(status)) {
cJSON_Delete(rsp);
return ESP_FAIL;
}
const char *st = status->valuestring;
if (strcmp(st, "Success") == 0) {
cJSON *result = cJSON_GetObjectItem(task, "task_result");
cJSON *speech = cJSON_GetObjectItem(result, "speech_url");
if (cJSON_IsString(speech) && speech->valuestring[0] != '\0') {
*out_url = strdup(speech->valuestring);
cJSON_Delete(rsp);
return ESP_OK;
}
cJSON_Delete(rsp);
return ESP_FAIL;
} else if (strcmp(st, "Failure") == 0) {
ESP_LOGE(TAG, "TTS task failed");
cJSON_Delete(rsp);
return ESP_FAIL;
}
// Running 或 Created,尚未完成
cJSON_Delete(rsp);
return ESP_ERR_NOT_FINISHED;
}
esp_err_t baidu_tts_synthesize(const char *text, char **out_url, int timeout_ms)
{
if (!text || !out_url) return ESP_ERR_INVALID_ARG;
*out_url = NULL;
esp_err_t err = _ensure_token();
if (err != ESP_OK) return err;
char task_id[128] = {0};
err = _create_task(text, task_id, sizeof(task_id));
if (err != ESP_OK) return err;
int elapsed = 0;
const int interval = 2000; // 2 秒轮询一次,避免过密
while (elapsed < timeout_ms) {
vTaskDelay(pdMS_TO_TICKS(interval));
elapsed += interval;
err = _query_task(task_id, out_url);
if (err == ESP_OK) {
ESP_LOGI(TAG, "speech_url obtained after %d ms", elapsed);
return ESP_OK;
}
if (err != ESP_ERR_NOT_FINISHED) {
return err; // 真正的错误,直接返回
}
ESP_LOGI(TAG, "task still running, %d/%d ms", elapsed, timeout_ms);
}
ESP_LOGW(TAG, "TTS task timeout after %d ms", timeout_ms);
return ESP_ERR_TIMEOUT;
}4.2 TTS 播放队列,tts_player_init() 创建长度为 4 的队列和 tts_play_task;tts_player_enqueue(text, timeout_ms) 把文本入队。task 中:
void tts_play_task(void *arg)
{
tts_msg_t msg;
for (;;) {
if (xQueueReceive(s_tts_queue, &msg, portMAX_DELAY) != pdTRUE) {
continue;
}
ESP_LOGI(TAG, "playing text: %.80s%s", msg.text, strlen(msg.text) > 80 ? "..." : "");
char *speech_url = NULL;
esp_err_t err = baidu_tts_synthesize(msg.text, &speech_url, msg.timeout_ms);
free(msg.text);
msg.text = NULL;
if (err != ESP_OK || speech_url == NULL) {
ESP_LOGE(TAG, "Synthesize failed: %s", esp_err_to_name(err));
continue;
}
ESP_LOGI(TAG, "Playing: %s", speech_url);
app_play_http_audio_async(speech_url, msg.timeout_ms);
free(speech_url);
}
}4.3 密钥配置,baidu_tts_init(CONFIG_BAIDU_TTS_API_KEY, CONFIG_BAIDU_TTS_SECRET_KEY)代码中的CONFIG_BAIDU_TTS_API_KEY与CONFIG_BAIDU_TTS_SECRET_KEY,配合 Kconfig 的 string 类型配置
menu "Baidu TTS Configuration" config BAIDU_TTS_API_KEY string "Baidu TTS API Key" default "" help Enter your Baidu Qianfan TTS API Key. This will be compiled into the firmware. Avoid committing real keys to public repositories. config BAIDU_TTS_SECRET_KEY string "Baidu TTS Secret Key" default "" help Enter your Baidu Qianfan TTS Secret Key. endmenu
五、Agent回复信息加入队列
在baidu_tts.c中的tts_player_enqueue()需要在esp-claw/components/claw_capabilities/cap_im_local/src/cap_im_local.c中进行调用
// baidu_tts.c
esp_err_t tts_player_enqueue(const char *text, uint32_t timeout_ms)
{
if (!s_tts_queue || !text) return ESP_ERR_INVALID_STATE;
char *copy = strdup(text);
if (!copy) return ESP_ERR_NO_MEM;
tts_msg_t msg = { .text = copy, .timeout_ms = timeout_ms };
BaseType_t ret = xQueueSend(s_tts_queue, &msg, pdMS_TO_TICKS(100));
if (ret != pdTRUE) {
free(copy);
ESP_LOGW(TAG, "queue full, drop tts");
return ESP_ERR_TIMEOUT;
}
return ESP_OK;
}
// cap_im_local.c中的cap_im_local_send_text_impl()
if (s_local.log_outbound_messages) {
ESP_LOGI(TAG, "Outbound local IM channel=%s chat=%s msg=%s text=%.80s%s",
message.channel,
message.chat_id,
message.message_id,
text,
strlen(text) > 80 ? "..." : "");
tts_player_enqueue(text, 60000);
}六、main.c 集成与 LED 处理
在 app_main() 中,esp_board_manager_init() 之后调用 tts_player_init();同时因为开机绿灯常亮且过亮,初始化后直接 led_strip_clear() 熄灭:
ESP_ERROR_CHECK(esp_board_manager_init());
tts_player_init();
// ==== 立刻熄灭开机绿灯 ====
dev_led_strip_handles_t *led_handle = NULL;
esp_err_t led_ret = esp_board_manager_get_device_handle(
ESP_BOARD_DEVICE_NAME_LED_STRIP, (void **)&led_handle);
if (led_ret == ESP_OK && led_handle != NULL && led_handle->strip_handle != NULL) {
led_strip_clear(led_handle->strip_handle); // 熄灭所有 LED
ESP_LOGI(TAG, "Boot RGB LED cleared (off)");
} else {
ESP_LOGW(TAG, "Failed to get LED strip handle for boot clear");
} 最后,因为百度模型的链接涉及https,需要证书才能连接成功,目前的操作是关闭证书校验

我要赚赏金
