根因分析报告/ TuyaOS AI · T5/ 音频播放器

播放器从「永久卡死」到「隔夜唤醒无声」的三层根因与修复

论坛工单 t=9433 反馈的 wukong_audio_player_stop/play_data 概率性永久阻塞,加超时后转为「待机唤醒后无声」。本报告自底向上定位三层根因,记录已落地的修复,并对调用方代码结构给出更清晰的重构思路。

平台
T5 · arm-none-eabi
SDK
tuyaos 3.13.3
关键错误码
-26369 / -2
分支
tuyaos-ai-develop
日期
2026-06-30
01

结论速览

这不是单点 bug,而是三层缺陷叠加。给 stop/start 加 2s 超时只解开了「调用线程」,没解开「播放线程」——超时把「永久卡死」转换成了「状态机错乱 + 静音」。真正能根除的是最底层 tkl_ao_put_frame 在 ring buffer 满时的无限重试

一句话 待机/唤醒后音频 DAC 不消费 → 播放线程卡死在 tkl_ao_put_frame 的无限重试里 → 不再消费命令队列(深度仅 2)→ stop 投递失败(-26369)→ 状态停在 PLAYING → start 被拒(-2)→ 彻底无声。
02

现象与日志解码

工单演进:永久卡死 → 应用 2s/1s 超时 → 隔夜待机再唤醒「没声音」,唤醒处约 1 秒卡顿,之后不再卡顿。两条关键日志直接坐实了根因。

-26369
OPRT_OS_ADAPTER_QUEUE_SEND_FAIL
tuya_error_code.h:529

tal_queue_post 投递失败——播放命令队列(深度仅 2)已被塞满,说明播放线程不再消费。

-2
player not stopped
tuya_ai_player_start()

start 检测到 player->state != AI_PLAYER_STOPPED 直接拒绝 → 播放器根本没启动 → 无声。

03

三层根因

从业务到底层,缺陷逐层叠加。前两层是之前讨论的「重入/自死锁」,而本次工单更新暴露的第三层(底层无限重试)才是「隔夜无声」的主因

业务层L1

事件回调链里同步调 stop / play

同步事件 ty_publish_event__player_event → 业务处理直接调阻塞的 wukong_audio_player_stop

播放器层L2

播放线程内重入忙等 + 队列深度仅 2

__cmd_player_stopplaylist_cbtuya_ai_player_start 在播放线程自身忙等它自己处理消息 → 自死锁。

底层L3

tkl_ao_put_frame 在 ring buffer 满时无限重试

本次主因 ▸ DAC 不消费时 ret==0 → sleep(20) → goto retry 永不退出,播放线程被永久 wedge。

L3 — 决定性证据

位置:vendor/T5/tuyaos/tuyaos_adapter/src/driver/tkl_audio.c

tkl_ao_put_frame() — 修复前
while (remaining_size > 0) {
    chunk_size = (remaining_size > spk_ringbuf_size) ? spk_ringbuf_size : remaining_size;

write_spk_retry:
    ret = bk_voice_write_frame_data(g_voice_write_handle, ..., chunk_size);
    if (ret == 0) {              // ring buffer 满
        tkl_system_sleep(20);
        goto write_spk_retry;     // ← 无限重试,没有任何超时 / 退出!
    }
    if (ret < 0) { ... return ret; }
    offset += chunk_size;  remaining_size -= chunk_size;
}
为什么是它 ring buffer 仅 DRIVER_SPEAK_FIFO_FRAME_NUM(2) × 20ms = 40ms。正常播放时「满」最多等约 40ms 即被 DAC 排空;一旦待机唤醒后 DAC 实际未恢复消费,bk_voice_write_frame_data 持续返回 0,这段循环就永久卡住播放线程。这正是工单里反复提醒的「检查 consumer.write 底层,确保不会无限阻塞」。
04

无声的完整因果链

把三层串起来,隔夜待机 → 唤醒 → 无声,逐步如下:

发生了什么结果
1待机唤醒后音频 DAC 未真正恢复,bk_voice_write_frame_data 一直返回 0L3 卡点触发
2ai_player 线程永久卡在 tkl_ao_put_frame 重试循环,回不到主循环不再消费消息队列
3唤醒走 wukong_audio_play_datatuya_ai_player_stop 投递 STOP,队列(深 2)已满,post(...,0) 失败ret = -26369
4STOP 没执行,player->state 停在非 STOPPED状态机错位
5紧接着 tuya_ai_player_startstate != STOPPED 拒绝;feed 同理被拒ret = -2 → 无声
注意 把超时改成 1s 也救不了这个场景——真正的阻塞在播放线程的 write,而 stoptal_queue_post 阶段就直接失败返回了,根本走不到那个超时忙等。所以表现是「直接没声音」,不一定再有卡顿。
05

已落地的修复

分「治本(底层不再无限阻塞)」与「兜底(队列不再瞬时塞满)」两类,二者配套:有了 L3 超时,播放线程最坏 ~1s 脱困;队列加深 + post 有界超时正好覆盖这个恢复窗口。

文件改动类型
vendor/T5/.../driver/tkl_audio.ctkl_ao_put_frame:无限 goto 重试 → 累计超时(1000ms)重试,超时丢帧返回 OPRT_TIMEOUT治本
.../audio_player/src/ai_player.h新增 PLAYER_QUEUE_DEPTH=8PLAYER_CMD_POST_TIMEOUT_MS=200兜底
.../audio_player/src/svc_ai_player.c队列深度 2 → 8;start/stop 的 post 超时 0 → 200ms;顺修 start 投递失败时 mm_strdup 的内存泄漏兜底
tkl_ao_put_frame() — 修复后(累计超时退出)
UINT_T waited_ms = 0;
do {
    ret = bk_voice_write_frame_data(g_voice_write_handle, ..., chunk_size);
    if (ret != 0) break;
    tkl_system_sleep(SPK_WRITE_RETRY_INTERVAL_MS);   // 20ms
    waited_ms += SPK_WRITE_RETRY_INTERVAL_MS;
} while (waited_ms < SPK_WRITE_TIMEOUT_MS);          // 1000ms

if (ret == 0) {
    os_printf("audio spk write timeout(%u ms), drop frame...");
    return OPRT_TIMEOUT;     // 丢帧, 让上层 player 线程得以 stop + 恢复
}
恢复路径 返回非 OK 后,__handle_player_streaming 把错误上抛,__ai_player_thread_cb 执行 __cmd_player_stop + __switch_player_mode,播放线程脱困并继续消费队列,不再 wedge。
部署提醒 vendor/T5/… 是 CDE/embcli 按 make.yaml 下载的依赖。重新跑 prepare.sh / embcli update 可能把 tkl_audio.c 覆盖回去。要长期生效需把改动反馈给 T5 adapter 上游,或在 prepare 后确认改动仍在。vendor/ 属外层仓库、audio_player 属 wukong app 仓库,提交时是两个 git 仓库。
06

调用方结构性问题

开发者用法(crash/1.c,hly_ai.c)的问题不在某一行,而在并发结构:那一排 debug_step_a/b/c/d/e 计数器正是在抓这个 hang。三个病灶都能对上 crash 现场。

病灶 1 — 持着 _ai_mutex 调阻塞 player API

hly_ai_wakeup() — 握锁期间阻塞
tal_mutex_lock(_ai_mutex);
...
wukong_audio_player_stop(AI_PLAYER_ALL);   // 阻塞调用, 还握着 _ai_mutex
wukong_audio_input_reset();
tuya_ai_agent_event(AI_EVENT_CHAT_BREAK, 0);
_hly_ai_set_stat(AI_STAT_LISTEN);
tal_mutex_unlock(_ai_mutex);

_ai_mutex 是所有热路径都要抢的锁——_hly_ai_mic_data(每 50ms 一次)、VAD、KWS、事件、状态机。stop 一卡,这把锁被一直握住,麦克风采集、VAD、KWS、状态机全部冻结

病灶 2 — 事件回调里直接调阻塞 player API(播放线程自重入)

_hly_ai_event() — 同步事件回调中
case WUKONG_AI_EVENT_PLAY_CTL_PAUSE:
    wukong_audio_player_stop(AI_PLAYER_BG);   // 回调链里直接调阻塞 stop
case WUKONG_AI_EVENT_PLAY_END:
    tal_mutex_lock(_ai_mutex);               // 又来抢 _ai_mutex → 锁反转

病灶 3 — 状态机被打散,还会静默丢状态

_hly_ai_set_stat() — 丢消息
STATIC VOID _hly_ai_set_stat(hly_ai_stat_e stat) {
    tal_queue_post(_hly_ai_stat_queue, &msg, 10);  // 返回值没查; 队列满就悄悄丢状态
}
07

更清晰的思路:单事件循环(actor)

核心原则与本仓库 UI 框架的「单向数据流」同源,也正是工单里说的「事件队列模式」:

原则 所有外部输入只「投递消息」,绝不在调用处直接碰 player;只有一个 worker 线程串行地改状态机 + 调 player。
KWS 唤醒
hly_ai_worker · 单线程
  • 读 / 改 _ai_stat(本线程独占,无需锁)
  • player_stop / play_data(此处阻塞安全:不在 player 线程,且有 svc/tkl 超时兜底)
  • publish(AI_STAT_EVENT) 给 UI
VAD 变化
AI 事件
shutup / 定时器
按键
↑ 只 post 一条 cmd 到 _ai_cmd_queue,立即返回 · 不加锁 · 不阻塞
病灶单循环后
1 · 持锁调阻塞_ai_mutex 直接删掉;状态只被 worker 读写,天然串行;阻塞调用不再连累 mic/vad/kws
2 · 回调内重入_hly_ai_eventpost 一条 cmd 就返回,绝不调 stop/play → 播放线程永不自重入
3 · 状态打散 + 丢消息状态转换全收口到 worker 的一个 switch,唯一入口出口;队列做深 + post 查返回值
worker 主体(示意)
STATIC VOID hly_ai_worker_cb(PVOID_T args) {
    hly_ai_msg_t m;
    while (1) {
        if (tal_queue_fetch(_ai_cmd_q, &m, SEM_WAIT_FOREVER) != OPRT_OK) continue;
        switch (m.cmd) {
        case CMD_WAKEUP:
            if (!ai_start || _ai_stat != AI_STAT_IDEL) break;
            wukong_audio_player_stop(AI_PLAYER_ALL);   // 只卡 worker 自己
            wukong_audio_input_reset();
            tuya_ai_agent_event(AI_EVENT_CHAT_BREAK, 0);
            __set_stat(AI_STAT_LISTEN);                // 直接改, 本线程独占
            wukong_audio_player_alert(AI_TOY_ALERT_TYPE_WAKEUP, FALSE);
            break;
        case CMD_PLAY_END: __set_stat(wake_up_flag ? AI_STAT_LISTEN : AI_STAT_IDEL); break;
        case CMD_TTS_PRE:  __set_stat(AI_STAT_SPEAK); break;
        ...
        }
    }
}

原来的回调全部退化成「投递 + 返回」,例如 _hly_ai_audio_kws_cbpost(CMD_WAKEUP),不再直接调 hly_ai_wakeup。这套结构配合 §05 的 svc/tkl 超时,形成完整闭环。

08

实锤 Bug 清单

无论是否重构,以下都建议直接修:

  1. _hly_ai_set_stat 没查 tal_queue_post 返回值 → 队列满时丢状态。
  2. hly_ai_start / hly_ai_stop / hly_ai_chat_over 声明 OPERATE_RET 却没有 return → 返回栈垃圾。
  3. _hly_ai_event 声明 STATIC VOIDreturn OPRT_xxx → 编译告警、语义错。
  4. hly_ai_play_alertuint8_t str[32]sprintf(&str, ...)&str 类型错,应传 str
  5. _hly_ai_mic_data 每 50ms 一条 TAL_PR_NOTICE → 日志刷屏,拖慢热路径。
  6. _hly_ai_audio_kws_cbINT_T idx = (INT_T)data → 指针强转 int,64 位告警;且直接同步调阻塞的 hly_ai_wakeup