1118 lines
31 KiB
Markdown
1118 lines
31 KiB
Markdown
# 下行音频高频卡顿问题分析、修复实现与诊断说明
|
||
|
||
## 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 路径已经完成实现与构建验证,仍需等待一次更严重的真实下行断流完成现场验证。
|