TTS 播放卡顿与音量归一化

现象

实时 WebSocket TTS 音频播放:开始正常,几秒后出现明显卡顿和电流声。离线导出的 WAV 完全干净。

调查

1. 先排除服务端音频管线

Session 集成测试产生的 WAV 播放干净 → TTS → 重采样 → Opus → WebSocket 这条链本身没有质量问题,问题在发送节奏或客户端消费。

2. 逐个排除可调项

改动效果
opus-rs → opus crate(C binding)无效
输出采样率 16000 → 24000 Hz无效
sherpa-onnx::LinearResampler → rubato::FftFixedIn无效
Application::LowDelay → Application::Audio + FEC无效
Signal::Auto → Signal::Voice + Bandwidth::Superwideband无效
VBR → CBR(set_vbr(false))无效
旧 pacing:elapsed 相对时间 → 绝对目标时间无效

编码参数全部无效,说明失真不是编码引入的,而是播放端在等不到按时到达的数据。

3. 客户端代码

apps/server-ui/public/test/device/js/core/audio/ 下三个事实:

  • player.js 的 _opus_decode 是同步 WASM 调用,解码期间阻塞 JS 主线程;
  • player.js 每次从队列批量取包(内层循环最多 99 包);
  • stream-context.js 用 Web Audio API 链式调度:source.start(max(scheduledEndTime, currentTime)),每段 120 ms。

主线程一旦被长解码阻塞,scheduledEndTime 就会过期,下一段无法接上——听感即中断加噪声。

根因

TTS 对一个完整句子先做约 13 秒推理,产出的 PCM 一次性切成 228 帧(60 ms/帧)同时涌进发送端。旧 pacing 以 audio_start_time(TTS::Start 时刻)为基准算绝对目标:

target = audio_start_time + paced_index × 60ms

13 秒后所有帧到达时,target 全部落在过去,now > target 恒成立 → 永不 sleep → 228 帧在 10 ms 内爆发发送。客户端瞬间收到 200+ 包,一次性解码阻塞主线程超过 1 秒,Web Audio 调度断裂。

离线 WAV 不受影响,因为那里没有实时调度约束:全部帧解码后顺序拼接,解码阻塞不产生听觉后果。

修复

思路是把「绝对时间目标」换成「相对节拍」:首帧之后每帧从当前时刻起固定间隔,落后就跳过而不是追赶。这样发送节奏只与消费者的真实进度有关,与 TTS 何时开始推理无关。

当前实现位于 service/src/session/mod.rs 的输出循环(audio_pacer):首帧到达时惰性创建 interval_at(now + frame_duration, frame_duration),之后每帧 pacer.tick().await;非音频消息把 pacer 置空,下一段音频重新起拍。帧长取自 AudioSpec::frame_duration_ms,不写死 60 ms。

与最初补丁的两处差异(改这里时要留意):最初用 MissedTickBehavior::Skip 显式跳过积压的拍子,现在用默认策略;最初首帧立即发,现在首个 tick() 会先等一个帧长。在持续供帧的前提下两者等效,但突发场景下不再有跳过积压的保护。

效果:初始突发从 228 帧降到 1 帧量级,稳态一帧一包,客户端批解码从 200+ 帧降到 1 帧,主线程阻塞从 >1 s 降到 <1 ms。

现象二:音量前后不一致

中英混读时,后半段英文明显更响,听感割裂。

方案一:固定参数 DRC(已废弃)

传统 feed-forward 压缩器,peak 检测包络 + 可选 soft knee。参数由网格搜索 + EBU R128 客观指标选出:threshold −28 dB、ratio 6、knee 0、attack 5 ms、release 80 ms、makeup 8 dB。结果 LRA 从 9.58 降到 5.66 LU,但 Crest Factor 从 15.4 掉到 6.2 dB——音量齐了,动态没了,人耳感知为「闷」。

方案二:自适应 RMS 增益归一化

adaptive_normalize()(api/src/util/compressor.rs)分六步:

步骤做法
分帧 RMS200 ms 窗、10 ms 步长
目标响度取所有帧 RMS 的 p30
帧增益target_rms / frame_rms,限幅 ±12 dB
增益平滑attack 5 ms / release 300 ms,逐样点 IIR
全局响度补偿匹配原始 RMS 再 +3 dB
软限幅−0.5 dBFS 上限,防削波

指标对比:

指标原始Adaptive
LRA9.08 LU1.59 LU(↓82%)
LUFS−26.90−24.14(响约 3 dB)
Crest Factor15.3 dB14.7 dB(动态保住)

10 秒音频总开销约 10 ms(分帧 O(n)、排序 ~1000 帧、逐样点 IIR),相对 TTS 推理数秒可忽略。

现状

adaptive_normalize() 没有被管线调用(全仓只有定义处、没有调用方),pcm_compress() / grid_search_compressor() / evaluate_compressed() 也只剩 compressor.rs 内部自用。当时用来对比的 test_compare_raw_vs_processed、test_grid_search_compressor 已不存在,因此上面两组指标是当时的测量结果,用现在的代码复现不出来。要重新引入,得先补一条能跑的分析用例。

关键文件

文件作用
apps/server/service/src/session/mod.rsaudio_pacer:帧发送节拍(修复位置)
apps/server/api/src/component/tts/model/matcha/mod.rsMatchaTts 音频生成
apps/server/api/src/util/compressor.rsadaptive_normalize() / pcm_compress() / evaluate_compressed(),当前无调用方
apps/server-ui/public/test/device/js/core/audio/player.js客户端 Opus 解码与批量取帧
apps/server-ui/public/test/device/js/core/audio/stream-context.jsWeb Audio API 链式调度