31 KiB
下行音频高频卡顿问题分析、修复实现与诊断说明
1. 文档范围
本文说明下行 TTS 音频播放过程中出现短暂停顿、高频卡顿以及类似死机噪声的问题,包括:
- 问题产生原因和现场日志证据;
- 基于上次提交之后的具体修改;
- 修改后的实际效果;
- 预缓冲、自适应重缓冲、静音保护、超时保护和 generation 隔离的实现方法;
- 新增诊断日志、保护证明日志及其解读方法;
- 现场验证结果、资源影响和当前仍需验证的边界。
本次修改以当前分支上次提交为基线:
b60a037 fix(control_plane): serialize wake prompt after barge-in
相对该提交,当前工作区修改如下:
5 files changed, 692 insertions(+), 10 deletions(-)
涉及文件:
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 已经开始,例如播放到“哟”之后停住一小会儿,然后继续播放。
对应日志:
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 心跳仍然正常,但扬声器输出可能持续异常。
这说明问题不是整机死机,而是:
下行响应仍处于 active
↓
没有新的音频包或播放进展
↓
播放队列耗空
↓
输出路径没有及时进入安全静音
↓
I2S/DMA 可能保持残留输出
↓
出现持续高频声或类似死机的卡顿感
3. 问题产生原因
问题不是单一故障,而是“上游供包空洞”和“设备端缺少抗抖动及失效保护”共同形成的。
3.1 直接触发原因:下行音频供包不连续
现场已经明确观察到:
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 原有播放器收到首包就开始播放
更新前的播放逻辑基本等价于:
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 原有逻辑没有耗空后的重缓冲状态
更新前队列耗空后,一旦下一包到达就立即恢复播放。
如果网络持续不稳定,播放会变成:
收到 1 包 → 播放 1 包 → 再次耗空 → 等待 → 收到 1 包 → 再播放
这会造成反复启停。听感上可能不只是一次干净的停顿,而是连续的高频卡顿或抖动。
3.4 active 响应会抑制原有 idle mute
原有定时器在响应 active 时主要进行诊断并返回:
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 数量:
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 标识
原来的下行包只包含长度和数据指针:
typedef struct {
size_t len;
uint8_t *data;
} downstream_packet_t;
当响应被打断、超时或切换时,队列中迟到的旧包缺少明确的响应归属信息,存在被下一次播放路径消费的风险。
4. 本次更新后的具体效果
4.1 正常播放开始前保留至少 180 ms 音频库存
配置值为 160 ms:
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS=160
当前 Opus 帧长为 60 ms,代码向上取整:
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;
}
计算结果:
ceil(160 / 60) = 3 包
3 × 60 ms = 180 ms 音频库存
这不是固定增加 180 ms 的墙钟延迟。服务端可能批量到包,现场等待到第 3 包通常只需要 10~40 ms。
4.2 较大到包抖动可以由库存直接吸收
更新后的现场日志出现过:
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 队列真正耗空后转为安全静音和重缓冲
空队列检测阈值:
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_REBUFFER_GAP_MS=120
队列连续为空 120 ms 后:
- codec 输出音量设置为 0;
- 不再收到一包就立即播放;
- 播放状态切换为等待缓存;
- 达到恢复目标后再统一恢复。
关键实现:
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 重复断流时自适应提高恢复库存
最大恢复缓存配置:
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS=640
实现:
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 初始化:
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 秒无音频且无新包时终止异常响应
配置:
CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_ACTIVE_STALL_MUTE_MS=3000
上下文增加:
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;
定时器回调:
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");
}
终止异常响应:
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:
typedef struct {
size_t len;
uint32_t playback_generation;
uint8_t *data;
} downstream_packet_t;
入队时标记:
downstream_packet_t packet = {
.len = len,
.playback_generation =
snapshot_downstream_playback_generation(),
.data = (uint8_t *)audio_psram_first_malloc(len),
};
播放前验证:
if (packet.playback_generation !=
snapshot_downstream_playback_generation()) {
free_downstream_packet(&packet);
continue;
}
响应被打断、超时或切换后,旧 generation 的迟到包会被释放,不会继续送入新的解码播放流程。
5. 播放状态机具体实现
播放任务新增以下状态:
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 变化时全部重置:
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;
}
未达到缓存目标时不出队播放:
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 后才消费队列:
if (queue != NULL && playback_buffer_ready) {
received = xQueueReceive(
queue,
&packet,
pdMS_TO_TICKS(20));
}
这就是本次从“数据驱动立即播放”改为“带初始缓冲和恢复缓冲的状态机播放”的核心。
6. 日志更新
6.1 可配置诊断开关
新增 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.
默认配置:
# CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE is not set
当前现场测试使用的本地 sdkconfig 已开启该配置,因此能看到完整诊断日志。产品默认仍为关闭。
6.2 诊断统计结构
#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
关闭诊断后,对应函数使用空内联实现:
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 首包日志
Downstream diagnostics first packet:
wait_ms=20 queue_depth=1
含义:
wait_ms:响应开始到首包入队的时间;queue_depth:首包入队后的队列深度。
6.4 首次播放日志
Downstream diagnostics first playback:
wait_ms=80
表示响应开始到第一次成功写入 codec 的时间。
6.5 播放完成汇总日志
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、但音频没有继续推进时输出:
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 初始缓存证明日志
诊断开启时输出:
Downstream protection evidence:
initial_wait_ms=30
buffered_ms=180
first_audio_jitter_budget_ms=180
证明播放不是从首包立即开始,而是在开始前形成了指定音频库存。
现场还出现过:
Downstream protection evidence:
initial_wait_ms=20
buffered_ms=1200
first_audio_jitter_budget_ms=1200
说明服务端当时批量到包,在播放任务开始消费前已经形成 20 包、约 1.2 秒库存。
6.8 耗空保护日志
队列连续空达到阈值时,无论诊断开关是否开启,都会保留关键运行告警:
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 恢复证明日志
达到恢复目标时,普通运行日志输出:
Downstream recovery buffer ready:
depth=6
target=6
buffered_ms=360
waited_ms=...
outage_ms=...
诊断开启时额外输出:
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:旧逻辑此时会直接恢复。
如果:
held_after_first_packet_ms > 0
buffered_ms >= target_ms
就能从日志证明新逻辑没有“来一包播一包”,而是等待形成连续播放能力后再恢复。
6.10 watchdog 日志
音频输出和下行包活动都停滞达到 3 秒时:
Downstream protection watchdog:
abort stalled response idle_ms=3000
表示异常 generation 已终止、旧队列已清理,输出保持静音。
6.11 WebSocket 日志说明
以下日志不是本次新增,原来已经存在:
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 新增:
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
默认配置:
# 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. 当前现场验证结论
最新日志中:
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 次完整播放汇总全部满足:
empty_events=0
enqueue_failures=0
playback_failures=0
最长的一次到包间隔:
arrival_gap_max=560 ms
depth_max=30
queued=142
played=142
empty_events=0
因此可以确认:
- 高频卡顿没有再次复现;
- 上游较大的供包抖动实际出现过;
- 这次 560 ms 抖动被队列库存完全吸收;
- 初始预缓冲和更充裕的队列余量已经产生实际效果;
- 尚未现场触发真正耗空后的 360~660 ms 自适应恢复路径;
- watchdog 保护已实现并通过构建,但尚未由真实 3 秒停滞触发验证。
10. 资源影响
10.1 Flash
构建结果:
诊断开启: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. 构建验证
已经完成:
诊断开启配置构建通过
诊断关闭配置构建通过
架构边界检查通过
git diff --check 通过
两种配置均无新增编译告警。
12. 最终结论
本次问题的直接触发点是下行 TTS 音频供包出现数百毫秒空洞;设备端原有首包立即播放、缺少耗空重缓冲、active 响应抑制 idle mute、I2S 未启用自动清零等因素,使普通网络抖动可能进一步表现为明显停顿、反复启停或高频残留声。
本次更新将播放器改为带初始预缓冲和自适应恢复缓冲的状态机,并增加 codec 静音、I2S DMA 自动清零、3 秒双进度 watchdog、generation 隔离和可关闭诊断。
当前现场已经证明 560 ms 到包抖动被库存完整吸收,未出现队列耗空或高频卡顿。真正队列耗空后的自适应恢复和 watchdog 路径已经完成实现与构建验证,仍需等待一次更严重的真实下行断流完成现场验证。