Android FFmpeg系列07--音画同步

2022-11-19 09:56:11 浏览数 (1)

引言

在前面的Demo中,我们已经分别在独立的线程中实现了对视频的解码渲染和音频的解码播放功能

Android FFmpeg系列03--视频解码与渲染

Android FFmpeg系列05--音频解码与播放

(oceans.mp4)

不过随着播放的持续进行,可以发现播放的画面和声音会逐渐的对不上,存在严重的音画不同步问题,而精确的音频和视频同步,是媒体播放的关键性能衡量指标之一,所以这篇文章我们就来简单的聊聊音画同步的那些事

Demo中一直使用的oceans.mp4可能不是很容易区分音画不同步问题,除非是真的特别严重的时候,在网上找了一个可以用来测试音画是否同步的视频,也上传到工程中的assets目录中了,感兴趣的小伙伴可以自己在MainActivity中改下播放的file

(av_sync_test.mp4)

音画同步定义

音画同步是指播放器正在渲染的每一帧画面和正在播放的每一段声音都能严格对应起来,不存在视觉和听觉可以分辨出来的差异

视觉和听觉可以分辨的差异标准可以参考ITU-R BT.1359标准

从上图可以看到,我们并不是真的需要音频、视频帧的时间严格匹配,只需要在合理的区间内相互追赶就行,所以说音视频的同步是动态的、是暂时的,不同步则是常态

  • 无感知区间:音频帧和视频帧显示的时间戳差值在-100ms~ 25ms之间
  • 能感知区间:音频滞后在-100ms以上或者超前了25ms
  • 无法接受的区间:音频滞后在-185ms以上或者超前了90ms

为什么要做音画同步

音视频文件在解复用阶段后,音频/视频独立解码、独立播放,理论上来说按照视频的帧率、音频采样率进行播放的话音画是同步的

这里以Demo工程中的av_sync_test.mp4为例

一个视频帧的播放时长为1000ms / 25 = 40ms,一个AAC音频帧的播放时长为1024 / 44100 * 1000ms ≈ 23.22ms,理想情况下音视频完全同步,播放过程如下:

不过实际上受限于各种原因,音画总是不同步的,可能的原因如下:

  • 一帧的播放时间难以精确控制;比如视频帧受限于解码性能、渲染性能等导致一帧耗时大于1 / fps
  • 异常、误差会随时间逐渐积累;比如一帧音频帧播放耗时约等于23.22ms,当累积播放几万帧的时候误差就达到秒级别了

音画同步的三种策略

音视频编码的时候引入了显示时间戳pts的概念:

  • 选择参考时钟(要求时钟是线性递增);
  • 编码时依据参考时钟给每个音频、视频数据帧打上显示时间戳pts;
  • 解码播放时,根据音频、视频时间戳及参考时钟来调整播放(如果数据帧的pts大于当前参考时钟上的时间,则sleep直到参考时钟到达数据帧的时间;如果数据帧的pts小于当前参考时钟上的时间,则尽快消费数据或者直接丢弃数据,以使播放进度追上参考时钟);

参考时钟的选择一般来说有三种:

视频同步到音频:以音频的播放速度为基准来同步视频

  • 优点:音频播放连续;
  • 缺点:视频画面会出现丢帧、跳帧

音频同步到视频:以视频的播放速度为基准来同步音频

  • 优点:视频播放流畅;
  • 缺点:音频根据对齐策略可能会出现静音、卡顿、加速播放等情况

音视频同步到外部时钟:以外部时钟为基准,视频和音频的播放速度都以该时钟为标准

  • 优点:最大限度的保证音视频都不发生跳帧行为;
  • 缺点:如果控制不好外部时钟,极有可能引发音频和视频都跳帧的情况

这三种是最基本的同步策略,考虑到人对声音的敏感度要强于画面,频繁调节音频会带来较差的感官体验,另一方面是音频数据在确定采样率、采样位数、声道数等参数时播放时间就很容易计算且能准确计算,而视频数据不行,所以一般播放器都会默认以音频时钟为参考时钟,视频同步到音频上。ffplay,exoplayer都是如此

音画同步的关键在于计算视频和音频时间的diff和计算最终的delay,在ffplay.c源码中通过如下函数计算

代码语言:javascript复制
static double compute_target_delay(double delay, VideoState *is)
{
    double sync_threshold, diff = 0;

    /* update delay to follow master synchronisation source */
    if (get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER) {
        /* if video is slave, we try to correct big delays by
           duplicating or deleting a frame */
        diff = get_clock(&is->vidclk) - get_master_clock(is);

        /* skip or repeat frame. We take into account the
           delay to compute the threshold. I still don't know
           if it is the best guess */
        sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
        if (!isnan(diff) && fabs(diff) < is->max_frame_duration) {
            if (diff <= -sync_threshold)
                delay = FFMAX(0, delay   diff);
            else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
                delay = delay   diff;
            else if (diff >= sync_threshold)
                delay = 2 * delay;
        }
    }

    av_log(NULL, AV_LOG_TRACE, "video: delay=%0.3f A-V=%fn",
            delay, -diff);

    return delay;
}

这里我们先不深入计算细节,只需要把握主体思路即可

diff的计算参考网上总结的一张图:

回到Android端,要实现音画同步一个可参考源码的例子是exoplayer

这里说说AudioTrack来播放音频pcm数据,要计算audio playback position主要有的两种api:

AudioTrack#getTimestamp() (api level 19 )

返回的AudioTimestamp实例中将填入一个以帧为单位,以及呈现该帧的估计时间

该接口的注意事项:

  • 该接口不一定都支持,不支持的时候会返回0;
  • 在音频管道初始预热阶段,可能无法连续更新时间戳;
  • 该接口不应该太频繁调用,频繁调用会导致CPU负担,电量损耗过大;exoplayer中是每500ms查询一次

AudioTrack#getPlaybackHeadPosition() (api level 3 )

返回当前播放的头位置(以帧为单位)

计算最新的音频时间戳

代码语言:javascript复制
/** The number of microseconds in one second. */
public static final long MICROS_PER_SECOND = 1000000L;

private long framesToDurationUs(long frameCount) {
    return (frameCount * C.MICROS_PER_SECOND) / sampleRate;
}

long timestamp = framesToDurationUs(audioTrack.getPlaybackHeadPosition());

考虑底层的音频延迟(包括混音器的延迟、音频硬件驱动程序的延迟等)和AudioTrack缓冲区引入的延迟

代码语言:javascript复制
Method getLatencyMethod;
if (Util.SDK_INT >= 18) {
  try {
    getLatencyMethod =
     android.media.AudioTrack.class.getMethod("getLatency", (Class < ? > []) null);
   } catch (NoSuchMethodException e) {
      //不能保证此方法存在。不进行任何操作。
   }
}

long bufferSizeUs = isOutputPcm ? framesToDurationUs(bufferSize / outputPcmFrameSize) : C.TIME_UNSET;
int audioLatencyUs = (Integer) getLatencyMethod.invoke(audioTrack, (Object[]) null) * 1000L - bufferSizeUs;

结合上述两个部分,计算音频管道渲染的上一时间戳的最终值为:

代码语言:javascript复制
int latestAudioFrameTimestamp = framesToDurationUs(audioTrack.getPlaybackHeadPosition() - audioLatencyUs;

exoplayer中对拿到的playbackHeadPostion还做了平滑处理,实现细节可以查看:

AudioTrackPostionTracker#getCurrentPostionUs

~~END~~

0 人点赞