题记:播放器数据展示介绍了图像和声音是如何播放的,但是不同线程的分别处理,容易出现声音和画面对不上的情况,本章介绍如何把图像和声音关联起来,即音视频同步。
音视频同步
在线程设计中介绍了图像和声音是在不同的线程上处理,如果按照各自AVFrame的pts播放,会出现音视频不同步不同步的情况(早期还是经常出现的),此时就需要按照一定规则调整声音和图像的播放速度,更直观地说就调整两个循环的转速以达到唇音同步的目的。
同步原理
关于音视频同步,一般会有更多方式(不同参考给出的不同):
同步方式最大区别就是判断音视频帧是否需要调整的策略。大致步骤都是相同的,音频对不上调声音播放速度,视频对不上则停留或者丢弃帧。这里可以先来看下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中整体流程如图:
- 音频主导
- 主时钟为
audclk
- 线路走图中2、3,外部时钟不起作用
- 整体上流程就是音频正常播放,更新
audclk
,再根据audclk
和vidclk
的时间差调整视频播放速度。
- 主时钟为
- 视频主导
- 主时钟为
vidclk
- 线路走图中1、4,外部时钟不起作用
- 整体上流程就是视频按pts更新,更新
vidclk
,再根据vidclk
和audclk
的时间差调整音频播放速度。
- 主时钟为
- 外部时钟
- 主时钟为
extclk
- 线路走图中1、3
- 需要特别指出的是:最开始时钟都是
NAN
,audclk
和vidclk
在第一轮更新后才有有效值,更新后的时钟再去同步extclk
,这时的extclk
才有有效值。音视频的处理在又不同线程,所以先处理的第一帧(可能是视频也可能是音频,也可能音视频同时)按正常处理,先处理完的clk
同步为extclk
,随后的更新就是用矫正过的clk
微调extclk
。
- 主时钟为
由上分析,再清楚音视频如何调速及如何确定当前流播放时间,就基本可以了解音视频同步了。
音频调速
音频通过重采样swr_convert
实现,需要设置补偿函数swr_set_compensation
,这一段涉及的推算,这里会详述一下。
知识准备
- 音频帧播放时长说明
- 采样率: 表示1秒的采样次数
AVFrame
的sample_rate
或者AudioParams
的freq
- 每个采样的播放时长为
1/sample_rate
- 帧样本数: 表示1帧中包含的样本数
AVFrame
的nb_samples
- 帧的播放时长为
nb_samples/sample_rate
- 如
sample_rate
为44.1kHz,nb_samples
为1024,则播放时钟1024/44100 = 23.2ms
- 采样率: 表示1秒的采样次数
- 重采样
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
的设定
- 如果时差在10s(
- 阈值说明,平滑处理
audio_diff_threshold
是动态设置的,这里其实是每次SDL callback填充数据的播放长度,后面给出推算。- 使用的是
avg_diff
与阈值audio_diff_avg_coef
约等于0.79,为方便,下文用0.8audio_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 + diff
,diff
绝对值要小于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_index
和audio_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
pts
、pts_drift
、last_updated
需要同时设置speed
播放速度,用于倍速播放
- 时钟获取进度
get_clock
(time
代表当前时间)- 常速播放
pts
+ (time
-last_updated
)即pts_drift
+time
- 考虑播放
pts
+ (time
-last_updated
) *speed
即get_clock
里的表达 - 暂停时,时间流逝无效,即
pts
,改变暂停状态需要重新设置时钟
- 常速播放
音频时钟更新
音频时钟的更新,会在调用SDL的回调时,会相对复杂一些,先来看下音频播放机制:
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
- SDL播放器内部维护2个缓冲区,大小为设定的
- 音频时钟在
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);