Files
lucky-cat/docs/knowledge vault/downstream-audio-stutter-fix-analysis.zh-CN.md

1118 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 下行音频高频卡顿问题分析、修复实现与诊断说明
## 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 路径已经完成实现与构建验证,仍需等待一次更严重的真实下行断流完成现场验证。