Skip to content

第 8 章:音频与时钟——低延迟、连续性和漂移补偿

视频慢了可以跳到最新帧,音频慢了却不能随便丢一段,否则会产生明显爆音。scrcpy 的音频管线因此多出一个闭环调节器:观测缓冲量,再用极小的重采样速率变化追赶目标。

一、端到端音频链路

Android audio source → AudioRecord / playback capture → AudioRecordReader → AudioEncoder(MediaCodec) 或 AudioRawRecorder → Streamer(audio socket) → Demuxer → Decoder → AudioRegulator (ring buffer + resample compensation) → AudioPlayer (SDL callback)

设备端选择发生在 Server.scrcpy() 的 audio 分支。direct source 与 playback source 使用不同 capture,但向上都实现 AudioCapture

二、设备端两种采集模式

2.1 Playback capture

默认输出音频使用 Android playback capture,需要 Android 11+;可选 duplication 让设备本地和电脑同时播放。实现负责建立 AudioRecord、选择 48 kHz 双声道 PCM,并处理部分设备在启动/读取时返回的异常。

2.2 Direct capture

麦克风、voice call 等 source 走 direct capture。它更接近传统 AudioRecord 输入,不依赖 media projection 的 playback mix。

AudioSource 枚举决定某个 source 是否 direct,外层 Server 不需要知道具体 Android 构造细节。

三、为什么有 encoded 与 raw 两条分支

如果 codec 是 raw,AudioRawRecorder 直接把 PCM 块加时间戳后写入 Streamer;否则 AudioEncoder 使用 MediaCodec 输出 Opus/AAC/FLAC。

压缩节省 ADB 带宽,但增加编码/解码开销;raw 降低 codec 延迟,却以 48,000 × 2 声道 × 2 字节,约 1.536 Mbps 持续占用通道。

四、时间戳从哪里来

音频不能只按“读取到的顺序”播放,因为 AudioRecord 读取块的调度会抖动。设备端 reader 根据采样位置与单调时钟估计 PTS,编码器把 presentationTimeUs 传给 Streamer。客户端 FFmpeg frame 最终保留微秒时间基。

AudioEncoder 还处理初始 codec config、输入 EOS 和 broken pipe,并将“无法捕获音频”区分为可禁用与配置错误,见 AudioEncoder

五、为什么播放器前必须有 regulator

音频有两个独立时钟:

  • Android 采集设备每秒“产生”约 48,000 个样本;
  • 电脑声卡每秒“消费”约 48,000 个样本。

“约”字就是问题。哪怕只有 0.1% 频差,一分钟也会积累 60 ms:buffer 最终要么清空 underflow,要么越堆越大导致延迟。

audio_regulator 的目标不是绝对同步某个墙钟,而是把环形缓冲的平均样本数维持在用户指定目标附近。设计说明直接写在 audio_regulator.c 文件头

六、消费者:声卡拉取时发生什么

SDL 音频线程调用 sc_audio_regulator_pull(out, n)

  1. 播放尚未开始且 buffer 未达 target:整块输出静音,积累初始缓冲;
  2. 正常情况:从 ring buffer 读 n 个样本;
  3. 不足:剩余区域填静音,并累计 underflow 数;
  4. 标记已经开始播放。

实现见 sc_audio_regulator_pull()

为什么 underflow 后不丢掉后来迟到的真实样本?因为此时继续丢样会让空洞扩大,产生更明显断裂。scrcpy 先接受延迟变大,再让后续调速缓慢吸收。

七、生产者:解码帧推入时发生什么

sc_audio_regulator_push(frame) 先通过 libswresample 转成 SDL 需要的统一 sample format,再写 ring buffer:

  • 单帧异常大时裁到容量,防止内存破坏;
  • buffer 满时先加锁重试,再丢最老样本腾空间;
  • 播放未开始时限制最多只超出 target 10 ms,避免起步延迟过大;
  • 播放后允许 target 的 110% 再加 60 ms 余量;
  • PTS 跳变超过 100 ms 视为 discontinuity,重置补偿状态。

这些分支见 sc_audio_regulator_push()

八、闭环补偿算法

每累计约 1 秒输出,regulator 重新估计:

text
diff = target_buffering - average_buffering
  • buffer 太少:diff > 0,让 resampler 生成略多输出样本,相对放慢内容;
  • buffer 太多:diff < 0,生成略少输出样本,相对加快内容;
  • 小于阈值视为噪声,不调节;
  • 一旦启用补偿,用 1 ms 回差关闭;未启用时要超过 4 ms 才打开,避免频繁抖动;
  • 差值计划在 4 秒内吸收;
  • 速率变化硬限制在 2%。

最终调用 swr_set_compensation(diff, distance),见 audio_regulator.c 320–359 行

这相当于一个带死区、回差和限幅的低频反馈控制器。

九、平均值为什么还要加“瞬时修正”

自然 buffer 水位按块生产/消费,会高频锯齿,因此用 128 个样本窗口平滑。但以下动作是 regulator 自己造成的,不能等待平均慢慢反映:

  • resampler 本轮多/少生成的样本;
  • underflow 插入的静音;
  • buffer 满时丢掉的旧样本。

源码把这些作为 instant compensation 直接加到平均状态,再把真实 can_read 推入平滑器。否则控制器会在几秒内基于过时估计继续向错误方向补偿。

十、环形缓冲为什么故意很大

容量 = target buffering + 1 秒采样。注释明确说“大得有意”:生产者和消费者多数时候可并行访问而不锁,只有 buffer 满需要丢旧数据时才加 mutex,见 sc_audio_regulator_init()

物理容量大不等于运行延迟大。逻辑水位仍受 target + 阈值控制,额外容量用于减少争用和吸收极端调度抖动。

十一、音频失败为何常常不是致命错误

设备可能因 Android 版本、应用禁止 capture、source 不可用或编码器问题无法提供音频。服务端用 codec id 0 表示“主动禁用”,客户端默认继续视频;只有配置错误 code 1、连接错误或用户指定 --require-audio 才终止。

这体现能力降级原则:用户主要请求通常是镜像,音频是可选能力;但用户显式声明 required 后,策略立即变为 fail-fast。

十二、本章小结

音频管线的难点不是解码,而是两个硬件时钟永远不会完全一致。scrcpy 用目标缓冲、静音填充、平均估计、回差阈值和最多 2% 的重采样补偿,把连续性与低延迟维持在动态平衡。下一章反转数据方向,追踪控制消息如何从 SDL 输入跨过 socket,最终成为 Android InputEvent、剪贴板操作或系统服务调用。

基于 scrcpy 4.1 源码的独立学习笔记