结论速览
这不是单点 bug,而是三层缺陷叠加。给 stop/start 加 2s 超时只解开了「调用线程」,没解开「播放线程」——超时把「永久卡死」转换成了「状态机错乱 + 静音」。真正能根除的是最底层 tkl_ao_put_frame 在 ring buffer 满时的无限重试。
tkl_ao_put_frame 的无限重试里 → 不再消费命令队列(深度仅 2)→ stop 投递失败(-26369)→ 状态停在 PLAYING → start 被拒(-2)→ 彻底无声。
现象与日志解码
工单演进:永久卡死 → 应用 2s/1s 超时 → 隔夜待机再唤醒「没声音」,唤醒处约 1 秒卡顿,之后不再卡顿。两条关键日志直接坐实了根因。
tuya_error_code.h:529
tal_queue_post 投递失败——播放命令队列(深度仅 2)已被塞满,说明播放线程不再消费。
tuya_ai_player_start()
start 检测到 player->state != AI_PLAYER_STOPPED 直接拒绝 → 播放器根本没启动 → 无声。
三层根因
从业务到底层,缺陷逐层叠加。前两层是之前讨论的「重入/自死锁」,而本次工单更新暴露的第三层(底层无限重试)才是「隔夜无声」的主因。
事件回调链里同步调 stop / play
同步事件 ty_publish_event → __player_event → 业务处理直接调阻塞的 wukong_audio_player_stop。
播放线程内重入忙等 + 队列深度仅 2
__cmd_player_stop → playlist_cb → tuya_ai_player_start 在播放线程自身忙等它自己处理消息 → 自死锁。
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
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; }
DRIVER_SPEAK_FIFO_FRAME_NUM(2) × 20ms = 40ms。正常播放时「满」最多等约 40ms 即被 DAC 排空;一旦待机唤醒后 DAC 实际未恢复消费,bk_voice_write_frame_data 持续返回 0,这段循环就永久卡住播放线程。这正是工单里反复提醒的「检查 consumer.write 底层,确保不会无限阻塞」。
无声的完整因果链
把三层串起来,隔夜待机 → 唤醒 → 无声,逐步如下:
| 步 | 发生了什么 | 结果 |
|---|---|---|
| 1 | 待机唤醒后音频 DAC 未真正恢复,bk_voice_write_frame_data 一直返回 0 | L3 卡点触发 |
| 2 | ai_player 线程永久卡在 tkl_ao_put_frame 重试循环,回不到主循环 | 不再消费消息队列 |
| 3 | 唤醒走 wukong_audio_play_data → tuya_ai_player_stop 投递 STOP,队列(深 2)已满,post(...,0) 失败 | ret = -26369 |
| 4 | STOP 没执行,player->state 停在非 STOPPED | 状态机错位 |
| 5 | 紧接着 tuya_ai_player_start 见 state != STOPPED 拒绝;feed 同理被拒 | ret = -2 → 无声 |
write,而 stop 在 tal_queue_post 阶段就直接失败返回了,根本走不到那个超时忙等。所以表现是「直接没声音」,不一定再有卡顿。
已落地的修复
分「治本(底层不再无限阻塞)」与「兜底(队列不再瞬时塞满)」两类,二者配套:有了 L3 超时,播放线程最坏 ~1s 脱困;队列加深 + post 有界超时正好覆盖这个恢复窗口。
| 文件 | 改动 | 类型 |
|---|---|---|
vendor/T5/.../driver/tkl_audio.c | tkl_ao_put_frame:无限 goto 重试 → 累计超时(1000ms)重试,超时丢帧返回 OPRT_TIMEOUT | 治本 |
.../audio_player/src/ai_player.h | 新增 PLAYER_QUEUE_DEPTH=8、PLAYER_CMD_POST_TIMEOUT_MS=200 | 兜底 |
.../audio_player/src/svc_ai_player.c | 队列深度 2 → 8;start/stop 的 post 超时 0 → 200ms;顺修 start 投递失败时 mm_strdup 的内存泄漏 | 兜底 |
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 + 恢复 }
__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 仓库。
调用方结构性问题
开发者用法(crash/1.c,hly_ai.c)的问题不在某一行,而在并发结构:那一排 debug_step_a/b/c/d/e 计数器正是在抓这个 hang。三个病灶都能对上 crash 现场。
病灶 1 — 持着 _ai_mutex 调阻塞 player API
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(播放线程自重入)
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 — 状态机被打散,还会静默丢状态
STATIC VOID _hly_ai_set_stat(hly_ai_stat_e stat) { tal_queue_post(_hly_ai_stat_queue, &msg, 10); // 返回值没查; 队列满就悄悄丢状态 }
更清晰的思路:单事件循环(actor)
核心原则与本仓库 UI 框架的「单向数据流」同源,也正是工单里说的「事件队列模式」:
- 读 / 改
_ai_stat(本线程独占,无需锁) - 调
player_stop / play_data(此处阻塞安全:不在 player 线程,且有 svc/tkl 超时兜底) publish(AI_STAT_EVENT)给 UI
| 病灶 | 单循环后 |
|---|---|
| 1 · 持锁调阻塞 | _ai_mutex 直接删掉;状态只被 worker 读写,天然串行;阻塞调用不再连累 mic/vad/kws |
| 2 · 回调内重入 | _hly_ai_event 只 post 一条 cmd 就返回,绝不调 stop/play → 播放线程永不自重入 |
| 3 · 状态打散 + 丢消息 | 状态转换全收口到 worker 的一个 switch,唯一入口出口;队列做深 + post 查返回值 |
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_cb 只 post(CMD_WAKEUP),不再直接调 hly_ai_wakeup。这套结构配合 §05 的 svc/tkl 超时,形成完整闭环。
实锤 Bug 清单
无论是否重构,以下都建议直接修:
_hly_ai_set_stat没查tal_queue_post返回值 → 队列满时丢状态。hly_ai_start / hly_ai_stop / hly_ai_chat_over声明OPERATE_RET却没有return→ 返回栈垃圾。_hly_ai_event声明STATIC VOID却return OPRT_xxx→ 编译告警、语义错。hly_ai_play_alert中uint8_t str[32]后sprintf(&str, ...)→&str类型错,应传str。_hly_ai_mic_data每 50ms 一条TAL_PR_NOTICE→ 日志刷屏,拖慢热路径。_hly_ai_audio_kws_cb中INT_T idx = (INT_T)data→ 指针强转 int,64 位告警;且直接同步调阻塞的hly_ai_wakeup。