音视频学习笔记八——从0开始的播放器之音视频同步

319 阅读11分钟

题记:播放器数据展示介绍了图像和声音是如何播放的,但是不同线程的分别处理,容易出现声音和画面对不上的情况,本章介绍如何把图像和声音关联起来,即音视频同步。

音视频同步

线程设计中介绍了图像和声音是在不同的线程上处理,如果按照各自AVFrame的pts播放,会出现音视频不同步不同步的情况(早期还是经常出现的),此时就需要按照一定规则调整声音和图像的播放速度,更直观地说就调整两个循环的转速以达到唇音同步的目的。 音视频同步.jpg

同步原理

关于音视频同步,一般会有更多方式(不同参考给出的不同): 视频同步全图.jpg

同步方式最大区别就是判断音视频帧是否需要调整的策略。大致步骤都是相同的,音频对不上调声音播放速度,视频对不上则停留或者丢弃帧。这里可以先来看下ffplay的中同步实现

ffplay示例

ffplay中定义的三种基本模式,基于时间戳PTS,又是主时钟控制:

enum {
    AV_SYNC_AUDIO_MASTER, // 默认,以音频为基准
    AV_SYNC_VIDEO_MASTER, // 以视频为基准
    AV_SYNC_EXTERNAL_CLOCK, // 以外部时钟为基准
};

对应地,定义了三个Clock(无论哪种同步这三个时钟都同时存在),先不用管Clock是什么,只需要知道它来标识时间就可以。

Clock audclk; 音频的时钟
Clock vidclk; 视频的时钟
Clock extclk; 系统时钟

来看下这三个时钟是怎么用的,ffplay中整体流程如图: ffplay实现.jpg

  • 音频主导
    • 主时钟为audclk
    • 线路走图中2、3,外部时钟不起作用
    • 整体上流程就是音频正常播放,更新audclk,再根据audclkvidclk的时间差调整视频播放速度。
  • 视频主导
    • 主时钟为vidclk
    • 线路走图中1、4,外部时钟不起作用
    • 整体上流程就是视频按pts更新,更新vidclk,再根据vidclkaudclk的时间差调整音频播放速度。
  • 外部时钟
    • 主时钟为extclk
    • 线路走图中1、3
    • 需要特别指出的是:最开始时钟都是NANaudclkvidclk在第一轮更新后才有有效值,更新后的时钟再去同步extclk,这时的extclk才有有效值。音视频的处理在又不同线程,所以先处理的第一帧(可能是视频也可能是音频,也可能音视频同时)按正常处理,先处理完的clk同步为extclk,随后的更新就是用矫正过的clk微调extclk

由上分析,再清楚音视频如何调速及如何确定当前流播放时间,就基本可以了解音视频同步了。

音频调速

音频通过重采样swr_convert实现,需要设置补偿函数swr_set_compensation,这一段涉及的推算,这里会详述一下。

知识准备

  • 音频帧播放时长说明
    • 采样率: 表示1秒的采样次数
      • AVFramesample_rate或者AudioParamsfreq
      • 每个采样的播放时长为 1/sample_rate
    • 帧样本数: 表示1帧中包含的样本数
      • AVFramenb_samples
      • 帧的播放时长为 nb_samples/sample_rate
    • sample_rate为44.1kHz,nb_samples为1024,则播放时钟 1024/44100 = 23.2ms
  • 重采样swr_alloc_set_opts参数设置说明
    • audio_tgt目标参数,设备支持的音频播放参数

      • swr_alloc_set_opts 参数设置,AVFrame重采样为audio_tgt的规范
    • audio_src 上一次设置的源参数

      • 此处是为了避免重复设置swr_ctx
      • 如果和上次参数不同,则释放先前的,重新设置,并把当前AVFrame参数同步到audio_src
      • 需要注意的是wanted_nb_samples的设置不在这里,但需要swr_ctx,所以有(wanted_nb_samples != af->frame->nb_samples && !is->swr_ctx)的写法
      if (af->frame->format != is->audio_src.fmt ||
          dec_channel_layout != is->audio_src.channel_layout ||
          af->frame->sample_rate != is->audio_src.freq ||
          (wanted_nb_samples != af->frame->nb_samples && !is->swr_ctx)) {
              swr_free(&is->swr_ctx);
              is->swr_ctx = swr_alloc_set_opts(NULL,
                                               is->audio_tgt.channel_layout, is->audio_tgt.fmt, is->audio_tgt.freq, dec_channel_layout, af->frame->format, af->frame->sample_rate, 0, NULL);                                         
      
  • synchronize_audio解释及推算
    • 音频时钟和主时钟时差
      • audclk 代表音频的播放进度
      • get_master_clock 代表主时钟的播放进度
      • diff 差值代表需要调整的时差,则样本数差为(freq为1秒的样本数)diff * freq
        • diff为正代表音频播快了,当前帧需要增加样本,播慢一点
        • diff为负代表音频播慢了,当前帧需要减少样本,播快一点
        • 则当前帧的目标样本数为 nb_samples + (diff * freq)
    • 边界控制
      • 如果时差在10s(AV_NOSYNC_THRESHOLD)以上,可能pts有问题,直接放弃了
      • 调整的上下限不超过10%,参看代码里av_clip的设定
    • 阈值说明,平滑处理
      • audio_diff_threshold是动态设置的,这里其实是每次SDL callback填充数据的播放长度,后面给出推算。
      • 使用的是avg_diff与阈值
        • audio_diff_avg_coef约等于0.79,为方便,下文用0.8
        • audio_diff_cum 加权累计误差,当前帧的权重大 diff(n)+0.8* diff(n-1) + 0.8* 0.8* diff(n-2) + ...
        • avg_diff = is->audio_diff_cum * (1.0 - 0.8)
      • 如果没有足够的数量计算(少于20次)avg_diff,略过
      • avg_diff超过阈值才去处理。

代码详解

来看一下主要的函数

  • 设置补偿函数
    • swr_set_compensation 动态调整音频的采样率转换过程中的延迟或偏差
      • struct SwrContext *s 采样器的上下文
      • sample_delta 需要补偿的样本数
      • compensation_distance 表示在多长的音频帧范围内完成补偿
  • 音频重采样
    • swr_convert 用于音频重采样
      • s: SwrContext 指针,音频重采样器的上下文。
      • out: 输出缓冲区的指针数组。每个元素指向一个声道的输出数据。
      • out_count: 输出缓冲区中每个声道的样本数。
      • in: 输入缓冲区的指针数组。每个元素指向一个声道的输入数据。
      • in_count: 输入缓冲区中每个声道的样本数。

需要特别注意的是,重采样后的样本数可能发生改变:帧的时长计算为nb_samples/sample_rate,重采样应该保持时长不变,则帧的样本数:

目标样本数 = 原样本数 * 目标采样率 / 原采样率

而样本数的变动及channels的变动,会使存储空间大小发生改变,来看一下这部分的代码,摘出关键部分如下。

// 输入输出
const uint8_t **in = (const uint8_t **)af->frame->extended_data;
uint8_t **out = &is->audio_buf1;
// 估算帧样本数、+256 保证足够大
int out_count = (int64_t)wanted_nb_samples * is->audio_tgt.freq / af->frame->sample_rate + 256;

// 设置补偿
if (wanted_nb_samples != af->frame->nb_samples) {
    if (swr_set_compensation(...) {
        return -1;
    }
}

// 转换,len2是实际样本数
len2 = swr_convert(is->swr_ctx, out, out_count, in, af->frame->nb_samples);
// len2 == out_count说明out_count可能不够大

整段代码就是把AVFrame的数据进行重采样,保存到audio_buf里,准备喂给SDL进行播放。

视频调速

逻辑梳理

视频调速相对加容易理解一些,刷新线程内部控制,每一帧是一张图片,调整展示时间即可。

double remaining_time = 0.0;
while (!is->abort_request) {
    // 休眠,保持视频帧
    if (remaining_time > 0.0) av_usleep((int)(int64_t)(remaining_time * 1000000.0));
    remaining_time = REFRESH_RATE;
    if (...) {
        // 刷新页面,更新remaining_time的值,就是该帧的展示时间
        video_refresh(is, &remaining_time);
    }
}

线程维护loop循环,video_refresh作用就是更新图像,并计算出该帧的展示时间remaining_time(进入休眠)。操作上和音频类似,有如下关键点:

  • vp_duration 计算当前帧持续时间
    • 优先:next->pts - cur->pts
    • 备选:cur->duration
  • compute_target_delay 调整当前帧的持续时间,适配主时间轴
    • 计算视频时钟和主时钟时差diff
    • 时间补偿delay + diffdiff绝对值要小于delay
  • 显示帧逻辑
    • frame_timer 保存帧展示时间
    • delay 为帧的修正展示时间
    • frame_timer+delay 即该帧的预期结束时间
    • time < is->frame_timer + delay 则当前帧表示还没有播完,更新remaining_time
    • 播完情况,更新frame_timer,更新页面
      • 允许掉帧 + 非视频主导:选择合适帧
      • 否则:播放下一帧
// 计算帧的展示时间
last_duration = vp_duration(is, lastvp, vp);
delay = compute_target_delay(last_duration, is);

// 未播完则更新剩余时间,跳转display,如果已经显示了就不做什么
time = av_gettime_relative()/1000000.0;
if (time < is->frame_timer + delay) {
    *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
    goto display;
}
// 播完,更新展示时间
is->frame_timer += delay;
if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
    is->frame_timer = time;
    
// 视频clock更新,同步外部时钟
xxx
// 可以掉帧的话,进入循环,选择合适的帧
if (xxx) {
    frame_queue_next(&is->pictq);
    goto retry;
}
// 更新到下一帧,标记要刷新页面
frame_queue_next(&is->pictq);
is->force_refresh = 1;

视频音频差异

在音频和视频的处理上,音频交给SDL播放,视频是内部处理的runloop,在处理上有差异

  • FrameQueue 环状存储,参考上一篇介绍过
    • rindex_shown 代表rindex已经展示过。
      • 音频上不使用,音频帧处理完就被copy出来,当前Frame就被丢弃了
      • 视频上使用,rindex+rindex_shown代表当前帧,rindex代表正在上一帧,就是展示过的帧。
  • Buffer处理
    • 音频 处理后的数据保存在audio_buf,并保存audio_buf_indexaudio_buf_size
    • 视频 上一个展示过的帧还在FrameQueue中,帧开始时间为frame_timer

时钟更新解析

最后来看一下,时钟设计和音视频更新时钟机制。

时钟解析

Clock结构体如下:

typedef struct Clock {
    double pts;           // 设置时的进度
    double pts_drift;     // pts - last_updated 设置时的时间差,有点冗余
    double last_updated;  // 设置时钟的时间
    double speed;         // 播放速度
    int serial; int paused; int *queue_serial;
} Clock;
  • 设置时钟set_clock_at
    • ptspts_driftlast_updated需要同时设置
    • speed 播放速度,用于倍速播放
  • 时钟获取进度get_clock(time代表当前时间)
    • 常速播放 pts + (time - last_updated)即 pts_drift + time
    • 考虑播放 pts + (time - last_updated) * speedget_clock里的表达
    • 暂停时,时间流逝无效,即pts,改变暂停状态需要重新设置时钟

音频时钟更新

音频时钟的更新,会在调用SDL的回调时,会相对复杂一些,先来看下音频播放机制:

sdl播放.jpg

  • callback回调
    • SDL播放器内部维护2个缓冲区,大小为设定的SDL_AudioSpec.size
      • SDL_AudioSpec.size 保存为audio_hw_buf_size
      • 也是callback的参数中的len
      • 前文中的audio_diff_threshold,也是由audio_hw_buf_size/bytes_per_sec设置的,即缓冲区的播放时间
    • 当缓冲区播完调用callback补充数据
      • 开始播放时,会调用2次callback,补充2个缓冲区
      • 后续播放完一个缓冲区数据,调用一次callback
  • 音频时钟在callback更新
    • 重采样后的数据长度不确定(可能动态调整),使用最后处理帧结束时间估算当前进度。
      • audio_clock = pts + 帧时长
    • 此时没有播放的数据有3部分:
      • 未使用的缓冲区(第一次没有这个缓冲区),长度为audio_hw_buf_size
      • 正在补充的缓冲区,长度也是audio_hw_buf_size
      • 还有没用完的数据audio_buf, 长度为 audio_buf_size - audio_buf_index
    • 则此时音频的进度为:audio_clock - 未播放数据长度/每秒播放长度
      • 每秒播放长度 audio_tgt.bytes_per_sec
      • 未播放数据长度 2 * audio_hw_buf_size - (audio_buf_size - audio_buf_index)

由上推算,即可知道当前音频的播放位置,用这个值更新audclk,再同步到外部时钟extclk,即完成音频时钟更新。

视频时钟更新

视频时钟更新比较简单,在更新图像时,调用update_video_pts(is, vp->pts, vp->pos, vp->serial)。使用当前帧的pts更新视频时钟,再同步到外部时钟。

需要额外说明一下的是,在video_refresh,还有一段代码。 如果是外部时钟状态,并且是实时直播时,会根据缓冲区状态,调整整体播放速度,具体

  • 如果音视频packet存量有一个少于3,说明马上不够播了,适当减速
  • 如果音视频packet存量都是10以上,适当增加播放速度。直播不可能超过当前进度,packet存量较多说明本地延时较大。
  • 在3和10之间,逐渐恢复到正常播放速度。
if (!is->paused && get_master_sync_type(is) == AV_SYNC_EXTERNAL_CLOCK && is->realtime) 
    check_external_clock_speed(is);