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

31 KiB
Raw Permalink Blame History

下行音频高频卡顿问题分析、修复实现与诊断说明

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 后:

  1. codec 输出音量设置为 0;
  2. 不再收到一包就立即播放;
  3. 播放状态切换为等待缓存;
  4. 达到恢复目标后再统一恢复。

关键实现:

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

因此可以确认:

  1. 高频卡顿没有再次复现;
  2. 上游较大的供包抖动实际出现过;
  3. 这次 560 ms 抖动被队列库存完全吸收;
  4. 初始预缓冲和更充裕的队列余量已经产生实际效果;
  5. 尚未现场触发真正耗空后的 360~660 ms 自适应恢复路径;
  6. 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 路径已经完成实现与构建验证,仍需等待一次更严重的真实下行断流完成现场验证。