Android 音视频同步 笔记

10 阅读7分钟

一、为什么需要音视频同步?

音频和视频是独立编码的流,它们各自包含时间戳信息。但在传输、解码、渲染过程中,由于各种原因(如网络抖动、解码延迟差异、渲染开销不同),两者很容易产生时间偏移。如果放任不管,就会出现声音超前于画面,或者画面滞后于声音的情况。因此,必须引入一套同步机制,强制音频和视频按照统一的时间轴播放。

二、音视频同步的基本原理

音视频同步的核心是时间戳。每一帧音频和视频数据都有一个PTS(Presentation Time Stamp,显示时间戳),表示该帧应该在何时被呈现。播放器需要一个参考时钟,并根据当前时钟值与当前帧PTS的差值,决定是立即渲染还是等待。

常见的参考时钟选择有三种策略:

  1. 视频同步到音频:以音频播放时间为参考时钟,视频尽力追赶或等待音频。这是最常用的策略,因为人对音频延迟更敏感,且音频播放通常更连续、平滑。
  2. 音频同步到视频:以视频渲染时间为参考,调整音频播放速度。实现复杂,且调整音频速度会影响听觉体验,很少采用。
  3. 同步到外部时钟:两者都根据一个统一的外部时钟(如系统时钟)调整。适合需要与外部设备(如字幕、灯光)同步的场景。

实际应用中,“视频同步到音频” 是主流方案。

三、时间戳详解

  • PTS(显示时间戳):帧应该被呈现给用户的时间。
  • DTS(解码时间戳):帧应该被解码器解码的时间。对于有B帧的视频,DTS和PTS可能不同。

时间戳的单位通常是微秒(µs)毫秒(ms),并且基于一个时间基(time base)。例如,FFmpeg中常用 AVRational 表示时间基,而Android的MediaExtractor返回的时间戳单位是微秒。

时间戳计算示例: 假设一个视频帧的PTS = 1000000 µs(即1秒),音频帧的PTS = 1005000 µs(即1.005秒)。如果当前音频播放时钟是1.002秒,说明音频慢了0.003秒,那么视频帧(1秒)应该被尽快显示(因为它已经过了0.002秒,不能等),而下一帧则需根据时钟延迟显示。

四、Android平台上的音视频同步实现

在Android中,我们通常使用 MediaCodec 进行解码,AudioTrack 播放音频,SurfaceViewTextureView 渲染视频。同步的关键在于控制视频帧的渲染时机

4.1 音频时钟的获取

音频播放进度是最可靠的参考时钟。我们可以通过 AudioTrack 获取当前已播放的音频时长。

// 初始化AudioTrack
AudioTrack audioTrack = new AudioTrack(...);

// 获取当前播放头位置(单位:帧)
int currentPlaybackHeadPosition = audioTrack.getPlaybackHeadPosition();
// 获取采样率
int sampleRate = audioTrack.getSampleRate();
// 获取已播放的字节数(如果使用getPlaybackHeadPosition不准确,也可以结合getTimestamp)
long playedFrames = currentPlaybackHeadPosition;
long playedMicroSeconds = playedFrames * 1000000L / sampleRate;

// 更精确的方式:使用AudioTrack.getTimestamp(API 19+)
AudioTimestamp audioTimestamp = new AudioTimestamp();
if (audioTrack.getTimestamp(audioTimestamp)) {
    // audioTimestamp.framePosition 是已播放的帧数
    // audioTimestamp.nanoTime 是对应的系统时钟(纳秒)
    // 可以计算出音频的播放进度(微秒)
    long audioPlayedUs = audioTimestamp.framePosition * 1000000L / sampleRate;
}

注意:getPlaybackHeadPosition 在某些设备上可能不准确或有延迟,建议使用 getTimestamp 配合系统时钟进行换算,得到更稳定的音频时钟。

4.2 视频帧的解码与渲染

MediaCodec 输出缓冲区拿到一帧数据后,我们得到它的 presentationTimeUs(PTS,单位微秒)。我们需要计算当前音频时钟与该PTS的差值:

diff = audioClockUs - videoPtsUs
  • 如果 diff > 0:表示视频帧已经过了 diff 微秒才准备好,应该立即渲染,否则会越来越滞后。
  • 如果 diff < 0:表示视频帧还未到显示时间,需要等待 -diff 微秒再渲染。

等待可以通过 Thread.sleep() 或更精确的 Object.wait(timeout) 实现,但在视频渲染线程中直接 sleep 会导致线程阻塞,影响后续帧的处理。更好的做法是记录帧的渲染时间,然后在渲染循环中根据当前时间决定是否渲染

示例伪代码(视频渲染线程)

// 渲染循环
while (isPlaying) {
    // 1. 从MediaCodec获取一帧(阻塞或非阻塞)
    int outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, timeoutUs);
    if (outputBufferIndex >= 0) {
        long videoPtsUs = bufferInfo.presentationTimeUs;
        
        // 2. 获取当前音频时钟
        long audioClockUs = getAudioClockUs();
        
        // 3. 计算延迟
        long delayUs = videoPtsUs - audioClockUs;
        
        if (delayUs > 0) {
            // 视频超前,需要等待
            // 注意:不要直接Thread.sleep,而是在循环中检查是否该渲染
            long waitUntilUs = System.nanoTime() / 1000 + delayUs;
            while (System.nanoTime() / 1000 < waitUntilUs && isPlaying) {
                // 短暂休眠,避免忙等
                Thread.sleep(1); 
            }
        }
        // 4. 渲染视频(释放缓冲区到Surface)
        mediaCodec.releaseOutputBuffer(outputBufferIndex, true);
        
        // 5. 记录已渲染的PTS,用于后续丢帧或重复帧判断(见下文)
        lastRenderedPtsUs = videoPtsUs;
    }
}

4.3 应对异常情况:丢帧与重复帧

  1. 视频严重滞后:如果 delayUs 是一个很大的负数(例如 < -30ms),说明视频帧已经过时很久,再渲染只会加剧不同步。此时应丢弃该帧(不渲染,直接释放缓冲区),并继续处理下一帧。
  2. 视频轻微超前:如果 delayUs 是一个较大的正数(例如 > 30ms),可以适当等待。
  3. 视频超前但等待会导致卡顿:如果等待时间过长,可能导致解码器缓冲区溢出或渲染线程饥饿。可以设定一个最大等待阈值(如 100ms),超过阈值则立即渲染,尽管有超前,但人眼可能不易察觉。
  4. 重复帧:当音频时钟慢于视频PTS,且差异不大时,我们可以通过重复渲染上一帧来填补空白,但这会导致画面轻微抖动,一般不常用。

4.4 音频时钟的校准

音频时钟并非完美。AudioTrack.getTimestamp 返回的 framePosition 可能不会每帧都更新,或者有延迟。我们可以结合系统时钟进行线性回归滤波,得到一个平滑的时钟曲线。

另外,如果音频是无声的,或者暂停了,那么音频时钟就会停滞,此时应切换到系统时钟作为参考。

五、进阶优化与注意事项

5.1 缓冲策略

  • 解码器输出队列:保持解码器输出一定数量的帧(例如5帧),可以平滑网络抖动和解码延迟。但队列过长会增加同步误差。
  • 渲染队列:在渲染线程前设置一个队列,让渲染线程以固定的节奏(如视频帧率)消费,通过比较PTS和时钟决定显示哪一帧。

5.2 同步粒度

  • 阈值设定:一般同步误差在 ±20ms 内人眼难以察觉,±40ms 可能被注意到,超过 ±100ms 就很明显。因此,可以设定一个容忍范围,误差小时不进行调整,避免频繁跳跃。
  • 音频重采样:如果必须将音频同步到视频,可以动态调整音频播放速率(使用 SoundPoolAudioTracksetPlaybackRate),但会改变音调,需要配合音调矫正算法(如 Sonic、SoundTouch),实现复杂。

5.3 硬件 vs 软件解码

  • 硬件解码MediaCodec 配合 Surface 渲染,渲染时机由底层控制,我们只能通过 releaseOutputBuffer(..., true) 通知 Surface 立即显示。但底层何时真正上屏(present)是不可控的,这可能导致同步误差。因此,有些播放器会采用软件绘制(将解码后的帧拷贝到 OpenGL 纹理),在 CPU 侧控制渲染时机,再通过 eglSwapBuffers 上屏。
  • 软件解码:如使用 FFmpeg 软解,解码后的帧在内存中,我们可以精确控制渲染时间,但 CPU 开销大。

5.4 直播场景的特殊性

直播流的音视频时间戳可能不是从 0 开始的连续值,且网络抖动大。通常做法是:

  • 使用缓存队列吸收抖动。
  • 在播放开始时,计算音频和视频第一帧的 PTS 差值,作为初始偏移量进行补偿。
  • 如果差异过大,可以跳跃追赶,即丢弃部分视频帧,快速追上音频。

六、总结

音视频同步是一个“测量-比较-调整”的闭环过程。在 Android 上,最佳实践是:

  1. AudioTrack 的播放时钟作为主参考时钟。
  2. 解码后的视频帧,通过比较 PTS 和音频时钟的差值,决定立即渲染或延迟渲染。
  3. 设置合理的容忍阈值,避免过度调整。
  4. 对于严重滞后的视频帧,果断丢弃。
  5. 注意硬件渲染的不确定性,必要时采用软件渲染以获得更精确的控制。