# 下行音频高频卡顿问题分析、修复实现与诊断说明 ## 1. 文档范围 本文说明下行 TTS 音频播放过程中出现短暂停顿、高频卡顿以及类似死机噪声的问题,包括: - 问题产生原因和现场日志证据; - 基于上次提交之后的具体修改; - 修改后的实际效果; - 预缓冲、自适应重缓冲、静音保护、超时保护和 generation 隔离的实现方法; - 新增诊断日志、保护证明日志及其解读方法; - 现场验证结果、资源影响和当前仍需验证的边界。 本次修改以当前分支上次提交为基线: ```text b60a037 fix(control_plane): serialize wake prompt after barge-in ``` 相对该提交,当前工作区修改如下: ```text 5 files changed, 692 insertions(+), 10 deletions(-) ``` 涉及文件: ```text components/platform/src/multimodal/audio_pipeline.c components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c main/Kconfig.projbuild sdkconfig.defaults ``` ## 2. 问题现象 现场出现过两类相关现象。 ### 2.1 播放中途短暂停顿 典型表现是 TTS 已经开始,例如播放到“哟”之后停住一小会儿,然后继续播放。 对应日志: ```text W multimodal_audio: Downstream diagnostics active_wait: response_age_ms=740 first_packet=1 last_packet_age_ms=520 first_playback=1 last_playback_age_ms=250 queued=7 played=7 pending_estimate=0 enqueue_failures=0 playback_failures=0 W multimodal_audio: Downstream diagnostics: queue recovered after 430 ms empty ``` 这里可以直接看出: - 已经收到首包并开始播放; - 总共入队 7 包,也已经播放 7 包; - `pending_estimate=0`,说明播放队列已经完全耗空; - 520 ms 没有收到新包; - 队列实际保持空状态约 430 ms; - 没有入队失败; - 没有 Opus 解码或 codec 写入失败。 结合两条日志的时间差,连续音频包之间存在约 720 ms 的到包空洞。已有库存吸收其中一部分,最终仍产生约 430 ms 的可听暂停。 ### 2.2 长时间高频卡顿或类似死机噪声 另一类现象是响应已经开始,但首包或后续音频长时间不再到达。设备其他任务和 WebSocket 心跳仍然正常,但扬声器输出可能持续异常。 这说明问题不是整机死机,而是: ```text 下行响应仍处于 active ↓ 没有新的音频包或播放进展 ↓ 播放队列耗空 ↓ 输出路径没有及时进入安全静音 ↓ I2S/DMA 可能保持残留输出 ↓ 出现持续高频声或类似死机的卡顿感 ``` ## 3. 问题产生原因 问题不是单一故障,而是“上游供包空洞”和“设备端缺少抗抖动及失效保护”共同形成的。 ### 3.1 直接触发原因:下行音频供包不连续 现场已经明确观察到: ```text last_packet_age_ms=520 queued=7 played=7 pending_estimate=0 queue recovered after 430 ms empty ``` 因此直接触发原因是下行音频包没有按正常节奏持续进入播放器。 空洞可能来自: - 服务端 TTS 流式生成节奏不连续; - 服务端或代理层批量发送; - Wi-Fi 或公网传输抖动; - WebSocket 接收任务调度延迟。 现有 WebSocket 采样日志显示,在被采样的时刻,event、worker 和会话回调通常只相差 0~10 ms,队列深度通常为 0~1,暂时没有发现本地 WebSocket worker 长时间积压。因此目前更倾向于服务端供包节奏或网络传输抖动。 但现有 WebSocket 日志是定期采样,不是逐异常包链路跟踪,所以还不能仅凭它严格区分服务端和网络。 ### 3.2 原有播放器收到首包就开始播放 更新前的播放逻辑基本等价于: ```c if (queue != NULL) { received = xQueueReceive(queue, &packet, pdMS_TO_TICKS(20)); } if (received == pdTRUE) { handle_downstream_opus_sync(packet.data, packet.len); } ``` 首包到达后立即开始播放,没有固定的初始 jitter buffer。 这种策略首字延迟低,但只要后续包出现较大间隔,队列就很容易耗空。 ### 3.3 原有逻辑没有耗空后的重缓冲状态 更新前队列耗空后,一旦下一包到达就立即恢复播放。 如果网络持续不稳定,播放会变成: ```text 收到 1 包 → 播放 1 包 → 再次耗空 → 等待 → 收到 1 包 → 再播放 ``` 这会造成反复启停。听感上可能不只是一次干净的停顿,而是连续的高频卡顿或抖动。 ### 3.4 active 响应会抑制原有 idle mute 原有定时器在响应 active 时主要进行诊断并返回: ```c if (snapshot_downstream_response_active()) { log_downstream_active_diagnostics(); arm_downstream_active_diagnostics(ctx); return; } ``` 如果响应状态一直没有结束,但音频已经停止,输出路径可能长期没有进入正常的 idle mute。 ### 3.5 I2S 原来没有显式启用 DMA 自动清零 原 I2S 初始化只设置 DMA descriptor 和 frame 数量: ```c i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG( (i2s_port_t)CONFIG_MULTIMODAL_AUDIO_I2S_PORT, I2S_ROLE_MASTER); chan_cfg.dma_desc_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_DESC_NUM; chan_cfg.dma_frame_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_FRAME_NUM; ``` 在上层断流或写入停止时,缺少显式的 DMA 自动清零保护,会增加旧采样残留或重复输出的风险。 ### 3.6 旧响应包缺少 generation 标识 原来的下行包只包含长度和数据指针: ```c typedef struct { size_t len; uint8_t *data; } downstream_packet_t; ``` 当响应被打断、超时或切换时,队列中迟到的旧包缺少明确的响应归属信息,存在被下一次播放路径消费的风险。 ## 4. 本次更新后的具体效果 ### 4.1 正常播放开始前保留至少 180 ms 音频库存 配置值为 160 ms: ```text CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS=160 ``` 当前 Opus 帧长为 60 ms,代码向上取整: ```c static UBaseType_t get_downstream_prebuffer_packet_count( const multimodal_audio_ctx_t *ctx) { uint32_t frame_ms = ctx != NULL && ctx->opus_frame_duration_ms != 0U ? ctx->opus_frame_duration_ms : 20U; uint32_t packet_count = ((uint32_t)CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS + frame_ms - 1U) / frame_ms; if (packet_count == 0U) { packet_count = 1U; } UBaseType_t queue_len = get_downstream_playback_queue_len(); return packet_count > (uint32_t)queue_len ? queue_len : (UBaseType_t)packet_count; } ``` 计算结果: ```text ceil(160 / 60) = 3 包 3 × 60 ms = 180 ms 音频库存 ``` 这不是固定增加 180 ms 的墙钟延迟。服务端可能批量到包,现场等待到第 3 包通常只需要 10~40 ms。 ### 4.2 较大到包抖动可以由库存直接吸收 更新后的现场日志出现过: ```text Downstream diagnostics playback_complete: response_age_ms=8560 queued=142 played=142 depth_max=30 arrival_gap_ms(min/avg/max)=0/47/560 empty_events=0 enqueue_failures=0 playback_failures=0 ``` 说明: - 音频包之间出现过最大 560 ms 间隔; - 队列最高累计 30 包,即约 1.8 秒音频; - 队列没有耗空; - 142 个包全部正常播放; - 用户没有听到高频卡顿。 这是已经由现场日志验证的改善。 ### 4.3 队列真正耗空后转为安全静音和重缓冲 空队列检测阈值: ```text CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_REBUFFER_GAP_MS=120 ``` 队列连续为空 120 ms 后: 1. codec 输出音量设置为 0; 2. 不再收到一包就立即播放; 3. 播放状态切换为等待缓存; 4. 达到恢复目标后再统一恢复。 关键实现: ```c if (received != pdTRUE || packet.data == NULL || packet.len == 0U) { if (snapshot_downstream_response_end_received()) { maybe_notify_downstream_playback_completed_after_drain("queue_empty"); } else if (playback_buffer_ready && generation_started && snapshot_downstream_response_active()) { TickType_t now = xTaskGetTickCount(); if (queue_empty_since == 0U) { queue_empty_since = now; note_downstream_queue_empty(); } uint32_t empty_ms = (uint32_t)pdTICKS_TO_MS(now - queue_empty_since); if (empty_ms >= get_downstream_rebuffer_gap_ms(ctx)) { if (recovery_underrun_count < UINT32_MAX) { recovery_underrun_count++; } UBaseType_t recovery_target = get_downstream_recovery_packet_count( ctx, recovery_underrun_count); (void)set_downstream_output_mute_if_needed( ctx, true, "Playback underrun"); playback_buffer_ready = false; underrun_since = queue_empty_since; buffer_wait_since = now; first_recovery_packet_tick = 0U; queue_empty_since = 0U; ESP_LOGW(TAG, "Downstream protection engaged: " "empty_ms=%u underrun=%u " "recovery_target=%u packets " "target_ms=%u output_muted=1", (unsigned)empty_ms, (unsigned)recovery_underrun_count, (unsigned)recovery_target, (unsigned)((uint32_t)recovery_target * ctx->opus_frame_duration_ms)); } } vTaskDelay(pdMS_TO_TICKS(10)); continue; } ``` ### 4.4 重复断流时自适应提高恢复库存 最大恢复缓存配置: ```text CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS=640 ``` 实现: ```c static UBaseType_t get_downstream_recovery_packet_count( const multimodal_audio_ctx_t *ctx, uint32_t underrun_count) { uint32_t frame_ms = ctx != NULL && ctx->opus_frame_duration_ms != 0U ? ctx->opus_frame_duration_ms : 20U; uint32_t base_ms = (uint32_t)CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS; if (base_ms < frame_ms) { base_ms = frame_ms; } uint32_t max_ms = (uint32_t)CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS; if (max_ms < base_ms) { max_ms = base_ms; } uint64_t scaled_ms = (uint64_t)base_ms * ((uint64_t)underrun_count + 1ULL); uint32_t recovery_ms = scaled_ms > max_ms ? max_ms : (uint32_t)scaled_ms; uint32_t packet_count = (recovery_ms + frame_ms - 1U) / frame_ms; UBaseType_t queue_len = get_downstream_playback_queue_len(); return packet_count > (uint32_t)queue_len ? queue_len : (UBaseType_t)packet_count; } ``` 当前 60 ms 帧对应: | 本次响应内耗空次数 | 理论目标 | 实际包数 | 实际库存 | | --- | ---: | ---: | ---: | | 第 1 次 | 320 ms | 6 包 | 360 ms | | 第 2 次 | 480 ms | 8 包 | 480 ms | | 第 3 次及以后 | 640 ms | 11 包 | 660 ms | 网络越不稳定,同一次响应后续恢复时越保守,避免反复出现“恢复一下又耗空”。 ### 4.5 I2S DMA 无新数据时自动清零 更新后的 I2S 初始化: ```c static esp_err_t init_i2s_bus(multimodal_audio_ctx_t *ctx) { if (!ctx) { return ESP_ERR_INVALID_ARG; } i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG( (i2s_port_t)CONFIG_MULTIMODAL_AUDIO_I2S_PORT, I2S_ROLE_MASTER); chan_cfg.dma_desc_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_DESC_NUM; chan_cfg.dma_frame_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_FRAME_NUM; chan_cfg.auto_clear_after_cb = true; esp_err_t err = i2s_new_channel( &chan_cfg, &ctx->tx_handle, &ctx->rx_handle); if (err != ESP_OK) { ESP_LOGE(TAG, "Create I2S channels failed: %s", esp_err_to_name(err)); return err; } /* 后续标准 I2S 配置保持不变。 */ return ESP_OK; } ``` 作用: - 上层停止写入时清理 DMA 输出; - 降低重复旧采样或残留波形的风险; - 配合 codec 音量门控,将异常断流表现控制为安静暂停。 本次没有新增直接切换 PA GPIO 的逻辑。PA 原有控制方式保持不变,断流保护使用 codec 输出音量归零和 I2S DMA 自动清零。 ### 4.6 3 秒无音频且无新包时终止异常响应 配置: ```text CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_ACTIVE_STALL_MUTE_MS=3000 ``` 上下文增加: ```c typedef struct { /* 其他字段省略。 */ bool downstream_response_active; bool downstream_response_end_received; TickType_t downstream_last_audio_tick; TickType_t downstream_last_response_activity_tick; esp_timer_handle_t downstream_idle_mute_timer; /* 其他字段省略。 */ } multimodal_audio_ctx_t; ``` 定时器回调: ```c static void downstream_idle_mute_timer_cb(void *arg) { multimodal_audio_ctx_t *ctx = (multimodal_audio_ctx_t *)arg; if (ctx == NULL) { return; } const bool response_active = snapshot_downstream_response_active(); if (response_active) { log_downstream_active_diagnostics(); TickType_t now = xTaskGetTickCount(); TickType_t last_audio_tick = snapshot_downstream_last_audio_tick(ctx); uint32_t idle_ms = last_audio_tick != 0U ? (uint32_t)pdTICKS_TO_MS(now - last_audio_tick) : 0U; uint32_t idle_timeout_ms = get_downstream_idle_mute_timeout_ms(); uint32_t stall_timeout_ms = get_downstream_active_stall_mute_timeout_ms(); if (idle_ms < idle_timeout_ms) { restart_downstream_idle_mute_timer( ctx, idle_timeout_ms - idle_ms); return; } if (ctx->initialized && ctx->codec_dev != NULL) { (void)set_downstream_output_mute_if_needed( ctx, true, "Active response gap"); } if (idle_ms < stall_timeout_ms) { uint32_t remaining_ms = stall_timeout_ms - idle_ms; #if CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE if (remaining_ms > MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ACTIVE_INTERVAL_MS) { remaining_ms = MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ACTIVE_INTERVAL_MS; } #endif restart_downstream_idle_mute_timer(ctx, remaining_ms); return; } TickType_t last_activity_tick = snapshot_downstream_last_response_activity_tick(ctx); uint32_t response_idle_ms = last_activity_tick != 0U ? (uint32_t)pdTICKS_TO_MS(now - last_activity_tick) : idle_ms; if (response_idle_ms < stall_timeout_ms) { restart_downstream_idle_mute_timer( ctx, stall_timeout_ms - response_idle_ms); return; } abort_downstream_response_after_active_stall(ctx, idle_ms); return; } if (ctx->initialized && ctx->codec_dev != NULL) { (void)set_downstream_output_mute_if_needed( ctx, true, "Idle mute"); } maybe_notify_downstream_playback_completed_after_drain("idle"); } ``` 终止异常响应: ```c static void abort_downstream_response_after_active_stall( multimodal_audio_ctx_t *ctx, uint32_t idle_ms) { ESP_LOGW(TAG, "Downstream protection watchdog: " "abort stalled response idle_ms=%u", (unsigned)idle_ms); (void)advance_downstream_playback_generation(); notify_downstream_playback_stopped(); update_downstream_response_state(false, false); if (audio_get_downstream_playback_task(ctx) != NULL || audio_get_downstream_playback_starting(ctx)) { audio_set_downstream_playback_stopping(ctx, true); audio_set_downstream_playback_keep_stopping_on_exit(ctx, false); audio_set_downstream_playback_stop_requested(ctx, true); } drain_downstream_playback_queue_now(ctx); } ``` 判断逻辑不是单纯“3 秒没有播放就终止”,而是同时参考: - 最后一次成功音频输出时间; - 最后一次下行音频包活动时间。 如果包仍在到达,只是暂时没有输出,会保持静音并继续等待;只有两者都停滞达到阈值才终止。 ### 4.7 使用 generation 隔离新旧响应 下行包增加 generation: ```c typedef struct { size_t len; uint32_t playback_generation; uint8_t *data; } downstream_packet_t; ``` 入队时标记: ```c downstream_packet_t packet = { .len = len, .playback_generation = snapshot_downstream_playback_generation(), .data = (uint8_t *)audio_psram_first_malloc(len), }; ``` 播放前验证: ```c if (packet.playback_generation != snapshot_downstream_playback_generation()) { free_downstream_packet(&packet); continue; } ``` 响应被打断、超时或切换后,旧 generation 的迟到包会被释放,不会继续送入新的解码播放流程。 ## 5. 播放状态机具体实现 播放任务新增以下状态: ```c downstream_packet_t packet = {0}; uint32_t buffered_generation = UINT32_MAX; TickType_t buffer_wait_since = 0U; TickType_t queue_empty_since = 0U; TickType_t underrun_since = 0U; TickType_t first_recovery_packet_tick = 0U; bool playback_buffer_ready = false; bool generation_started = false; uint32_t recovery_underrun_count = 0U; ``` 含义: | 字段 | 含义 | | --- | --- | | `buffered_generation` | 当前状态机服务的响应 generation | | `buffer_wait_since` | 本次初始或恢复缓存开始时间 | | `queue_empty_since` | 连续空队列开始时间 | | `underrun_since` | 本次真正 underrun 起始时间 | | `first_recovery_packet_tick` | 耗空后第一包重新到达时间 | | `playback_buffer_ready` | 是否已经达到允许播放的库存 | | `generation_started` | 当前响应是否已经成功开始播放 | | `recovery_underrun_count` | 当前响应内累计耗空次数 | generation 变化时全部重置: ```c uint32_t current_generation = snapshot_downstream_playback_generation(); if (current_generation != buffered_generation) { buffered_generation = current_generation; buffer_wait_since = xTaskGetTickCount(); queue_empty_since = 0U; underrun_since = 0U; first_recovery_packet_tick = 0U; playback_buffer_ready = false; generation_started = false; recovery_underrun_count = 0U; } ``` 未达到缓存目标时不出队播放: ```c if (queue != NULL && !playback_buffer_ready) { UBaseType_t queued_packets = uxQueueMessagesWaiting(queue); UBaseType_t target_packets = generation_started ? get_downstream_recovery_packet_count( ctx, recovery_underrun_count) : get_downstream_prebuffer_packet_count(ctx); TickType_t now = xTaskGetTickCount(); if (generation_started && queued_packets > 0U && first_recovery_packet_tick == 0U) { first_recovery_packet_tick = now; } bool response_ended = snapshot_downstream_response_end_received(); if (queued_packets >= target_packets || (response_ended && queued_packets > 0U)) { playback_buffer_ready = true; } } ``` 只有 ready 后才消费队列: ```c if (queue != NULL && playback_buffer_ready) { received = xQueueReceive( queue, &packet, pdMS_TO_TICKS(20)); } ``` 这就是本次从“数据驱动立即播放”改为“带初始缓冲和恢复缓冲的状态机播放”的核心。 ## 6. 日志更新 ### 6.1 可配置诊断开关 新增 Kconfig: ```kconfig config MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE bool "Enable downstream audio diagnostics" default n help Collect and report low-rate downstream audio timing, queue depth, queue starvation, enqueue failure, and playback failure statistics. Active responses also report a once-per-second wait snapshot when no audio advances. Disabled by default to avoid diagnostic logging and per-packet accounting in production builds. ``` 默认配置: ```text # CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE is not set ``` 当前现场测试使用的本地 `sdkconfig` 已开启该配置,因此能看到完整诊断日志。产品默认仍为关闭。 ### 6.2 诊断统计结构 ```c #if CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE typedef struct { TickType_t response_begin_tick; TickType_t arrival_last_tick; TickType_t first_arrival_tick; TickType_t first_playback_tick; TickType_t last_playback_tick; TickType_t queue_empty_since_tick; uint32_t arrival_gap_min_ms; uint32_t arrival_gap_max_ms; uint64_t arrival_gap_total_ms; uint32_t arrival_gap_samples; UBaseType_t queue_depth_min; UBaseType_t queue_depth_max; uint32_t queue_empty_events; uint32_t queue_empty_max_ms; uint32_t enqueue_failures; uint32_t playback_failures; bool summary_logged; } downstream_diagnostics_t; static downstream_diagnostics_t s_downstream_diagnostics; #endif ``` 关闭诊断后,对应函数使用空内联实现: ```c static inline void reset_downstream_diagnostics(void) {} static inline void note_downstream_arrival(UBaseType_t queue_depth) { (void)queue_depth; } static inline void note_downstream_playback(void) {} static inline void note_downstream_queue_empty(void) {} static inline void note_downstream_queue_recovered(void) {} static inline void note_downstream_enqueue_failure(void) {} static inline void note_downstream_playback_failure(void) {} ``` 因此关闭时不会保留逐包诊断统计开销。 ### 6.3 首包日志 ```text Downstream diagnostics first packet: wait_ms=20 queue_depth=1 ``` 含义: - `wait_ms`:响应开始到首包入队的时间; - `queue_depth`:首包入队后的队列深度。 ### 6.4 首次播放日志 ```text Downstream diagnostics first playback: wait_ms=80 ``` 表示响应开始到第一次成功写入 codec 的时间。 ### 6.5 播放完成汇总日志 ```text Downstream diagnostics playback_complete: response_age_ms=8560 first_packet=1 first_packet_ms=20 last_packet_age_ms=1820 first_playback=1 first_playback_ms=80 last_playback_age_ms=20 queued=142 played=142 current_depth=0 depth_min=1 depth_max=30 arrival_gap_ms(min/avg/max)=0/47/560 empty_events=0 empty_max_ms=0 enqueue_failures=0 playback_failures=0 ``` 关键判断: | 字段 | 判断方法 | | --- | --- | | `first_packet=0` | 响应开始后首包始终没有到 | | `first_packet=1 first_playback=0` | 包已到,但本地解码/播放没有推进 | | `queued == played` | 所有成功入队的包都已消费 | | `current_depth` | 汇总时队列剩余包数 | | `depth_max` | 本次响应形成过的最大音频库存 | | `arrival_gap_ms(.../max)` | 最大连续到包间隔 | | `empty_events` | 播放期间队列实际耗空并恢复次数 | | `enqueue_failures` | 包分配或入队失败次数 | | `playback_failures` | 解码或 codec 写入失败次数 | ### 6.6 active_wait 日志 响应仍然 active、但音频没有继续推进时输出: ```text Downstream diagnostics active_wait: response_age_ms=... first_packet=... last_packet_age_ms=... first_playback=... last_playback_age_ms=... queued=... played=... pending_estimate=... enqueue_failures=... playback_failures=... ``` 判断方式: - `first_packet=0`:零首包停滞; - `first_packet=1 first_playback=0`:包已到但播放未启动; - `pending_estimate=0` 且 `last_packet_age_ms` 持续增长:下行供包停止; - `pending_estimate>0` 且 `last_playback_age_ms` 持续增长:本地播放器或 I2S 路径阻塞。 ### 6.7 初始缓存证明日志 诊断开启时输出: ```text Downstream protection evidence: initial_wait_ms=30 buffered_ms=180 first_audio_jitter_budget_ms=180 ``` 证明播放不是从首包立即开始,而是在开始前形成了指定音频库存。 现场还出现过: ```text Downstream protection evidence: initial_wait_ms=20 buffered_ms=1200 first_audio_jitter_budget_ms=1200 ``` 说明服务端当时批量到包,在播放任务开始消费前已经形成 20 包、约 1.2 秒库存。 ### 6.8 耗空保护日志 队列连续空达到阈值时,无论诊断开关是否开启,都会保留关键运行告警: ```text Downstream protection engaged: empty_ms=120 underrun=1 recovery_target=6 packets target_ms=360 output_muted=1 ``` 这条日志说明: - 队列确实连续空了 120 ms; - 输出已经静音; - 播放器已经进入重缓冲; - 第一次恢复需要 6 包,即约 360 ms 音频。 ### 6.9 恢复证明日志 达到恢复目标时,普通运行日志输出: ```text Downstream recovery buffer ready: depth=6 target=6 buffered_ms=360 waited_ms=... outage_ms=... ``` 诊断开启时额外输出: ```text Downstream protection evidence: underrun=1 outage_ms=... first_packet_wait_ms=... held_after_first_packet_ms=... buffered_ms=360 legacy_would_resume_at_first_packet=1 ``` 字段含义: - `first_packet_wait_ms`:耗空后重新等到第一包的时间; - `held_after_first_packet_ms`:第一包到达后,新逻辑继续等待库存的时间; - `buffered_ms`:真正恢复时的库存; - `legacy_would_resume_at_first_packet=1`:旧逻辑此时会直接恢复。 如果: ```text held_after_first_packet_ms > 0 buffered_ms >= target_ms ``` 就能从日志证明新逻辑没有“来一包播一包”,而是等待形成连续播放能力后再恢复。 ### 6.10 watchdog 日志 音频输出和下行包活动都停滞达到 3 秒时: ```text Downstream protection watchdog: abort stalled response idle_ms=3000 ``` 表示异常 generation 已终止、旧队列已清理,输出保持静音。 ### 6.11 WebSocket 日志说明 以下日志不是本次新增,原来已经存在: ```text WS binary event queued WS binary worker frame Downstream binary frame ``` 它们是定期采样日志,可观察: - WebSocket 原始 event 是否进入队列; - worker 是否积压; - 会话回调是否被调用。 现有现场日志中,这三层通常相差 0~10 ms,`pending` 通常为 0,说明采样时本地 WebSocket 路径没有明显拥塞。 因为它们不是逐异常包记录,所以目前不能用它们严格定位 560 ms 或 720 ms 空洞发生在服务端还是公网传输阶段。 ## 7. 配置更新 Kconfig 新增: ```kconfig config MULTIMODAL_AUDIO_DOWNSTREAM_ACTIVE_STALL_MUTE_MS int "Downstream active response stall timeout (ms)" default 3000 range 500 10000 config MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS int "Downstream playback prebuffer (ms)" default 160 range 0 1000 config MULTIMODAL_AUDIO_DOWNSTREAM_REBUFFER_GAP_MS int "Downstream underrun detection delay (ms)" default 120 range 20 500 config MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS int "Maximum adaptive downstream recovery buffer (ms)" default 640 range 160 2000 config MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE bool "Enable downstream audio diagnostics" default n ``` 默认配置: ```text # CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE is not set CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_ACTIVE_STALL_MUTE_MS=3000 CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS=160 CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_REBUFFER_GAP_MS=120 CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS=640 ``` 保护功能和诊断开关相互独立: - 关闭诊断不会关闭预缓冲、重缓冲、I2S 清零和 watchdog; - 关闭诊断只移除逐包统计和详细 evidence 日志; - 关键运行告警仍会保留。 ## 8. 更新前后行为对比 | 场景 | 更新前 | 更新后 | | --- | --- | --- | | 开始播放 | 首包到达即播放 | 至少形成 3 包、180 ms 库存 | | 普通小抖动 | 容易消耗掉全部余量 | 优先由初始库存吸收 | | 较大到包间隔 | 可能出现短暂停顿 | 实际库存足够时完全无感 | | 队列耗空 | 下一包到达即恢复 | 空 120 ms 后静音并进入重缓冲 | | 连续耗空 | 反复来一包播一包 | 恢复目标逐步增加到约 660 ms | | 长时间无包 | active 状态可能长期保持 | 音频和包都停滞 3 秒后终止 | | I2S 无新数据 | 存在残留输出风险 | DMA callback 后自动清零 | | 响应切换 | 旧包归属不明确 | generation 不匹配直接丢弃 | | 问题定位 | 缺少完整队列时序 | 可区分供包、入队、播放和耗空 | ## 9. 当前现场验证结论 最新日志中: ```text Downstream response begin 21 次 Downstream diagnostics playback_complete 16 次 Downstream protection engaged 0 次 Downstream recovery buffer ready 0 次 Downstream protection watchdog 0 次 active_wait 0 次 codec write failed 0 次 opus decode failed 0 次 ``` 16 次完整播放汇总全部满足: ```text empty_events=0 enqueue_failures=0 playback_failures=0 ``` 最长的一次到包间隔: ```text arrival_gap_max=560 ms depth_max=30 queued=142 played=142 empty_events=0 ``` 因此可以确认: 1. 高频卡顿没有再次复现; 2. 上游较大的供包抖动实际出现过; 3. 这次 560 ms 抖动被队列库存完全吸收; 4. 初始预缓冲和更充裕的队列余量已经产生实际效果; 5. 尚未现场触发真正耗空后的 360~660 ms 自适应恢复路径; 6. watchdog 保护已实现并通过构建,但尚未由真实 3 秒停滞触发验证。 ## 10. 资源影响 ### 10.1 Flash 构建结果: ```text 诊断开启:0x1e1af0 诊断关闭:0x1e1080 ``` 大致影响: - 保护逻辑约增加 2 KB Flash; - 开启诊断相对关闭再增加约 2.7 KB Flash。 ### 10.2 RAM 和 PSRAM - `downstream_packet_t` 增加一个 `uint32_t playback_generation`; - 当前队列长度为 1024,队列存储优先分配在 PSRAM; - generation 字段约增加 4 KB PSRAM; - 恢复缓存通常持有 6~11 个 Opus 包,约占 1~3 KB PSRAM; - 诊断结构体只在诊断开启时存在。 ### 10.3 CPU 和任务 - 没有新增任务; - 复用现有下行播放任务; - 复用现有 idle mute 定时器; - 增加少量队列深度检查和时间戳计算; - I2S 自动清零由驱动完成,不新增软件任务。 ## 11. 构建验证 已经完成: ```text 诊断开启配置构建通过 诊断关闭配置构建通过 架构边界检查通过 git diff --check 通过 ``` 两种配置均无新增编译告警。 ## 12. 最终结论 本次问题的直接触发点是下行 TTS 音频供包出现数百毫秒空洞;设备端原有首包立即播放、缺少耗空重缓冲、active 响应抑制 idle mute、I2S 未启用自动清零等因素,使普通网络抖动可能进一步表现为明显停顿、反复启停或高频残留声。 本次更新将播放器改为带初始预缓冲和自适应恢复缓冲的状态机,并增加 codec 静音、I2S DMA 自动清零、3 秒双进度 watchdog、generation 隔离和可关闭诊断。 当前现场已经证明 560 ms 到包抖动被库存完整吸收,未出现队列耗空或高频卡顿。真正队列耗空后的自适应恢复和 watchdog 路径已经完成实现与构建验证,仍需等待一次更严重的真实下行断流完成现场验证。