环境
背景:在系统性学习FFmpeg时,发现官方推荐教程还是15年的,不少接口已经弃用,大版本也升了一级,所以在这里记录下FFmpeg4.0 SDL2.0的学习过程。
win10,VS2019,FFmpeg4.3.2,SDL2.0.14
原文地址:http://dranger.com/ffmpeg/tutorial06.html
同步音频
上一章我们通过记录音频时间线的方法来同步视频,这次我们要采用相反的方法,即记录视频时间线来同步音频。后面还有音视频都向系统时间同步。
实现视频时钟
与音频时钟类似,这次我们来实现视频时钟,它记录当前视频的播放进度。
初步来看,视频时钟就是最近一帧视频的PTS,每渲染一帧视频就更新一次。但问题在于,从毫秒级别来看,两帧视频间隔是比较长的(比如40ms),而两帧音频间隔就比较短了(比如10ms),这就导致每次播音频时计算出的音视频时差可能是这样的:比视频快0ms,比视频快10ms,比视频快20ms,比视频快30ms,比视频快0ms,比视频快10ms...这肯定是不行的,我们需要的是一个稳定的值。
因此在计算音视频时差时必须要拿到视频时钟的动态值。动态值计算方法是:上一帧的PTS (当前系统时间-上一帧播放时的系统时间),与计算音频时钟动态值的方法类似。
有了思路,我们可以写代码了。在VideoState里加上上一帧PTS:videoCurrentPts,上一帧播放时的系统时间:videoCurrentPtsTime,初始化并在渲染视频时更新它们。
代码语言:javascript复制typedef struct VideoState {
...
double videoCurrentPts;
uint64_t videoCurrentPtsTime;
}VideoState;
int openStreamComponent(VideoState* pVideoState, int streamIndex) {
...
//初始化
pVideoState->videoCurrentPtsTime = av_gettime();
}
void videoRefreshTimer(void* userdata) {
...
//更新
pVideoState->videoCurrentPts = pVideoPicture->pts;
pVideoState->videoCurrentPtsTime = av_gettime();
}
然后是获取视频时钟动态值的方法
代码语言:javascript复制double getVideoClock(VideoState* pVideoState) {
double delta;
delta = (av_gettime() - pVideoState->videoCurrentPtsTime) / 1000000.0;
return pVideoState->videoCurrentPts delta;
}
区分时钟
有了视频时钟后,我们就可以同步音频了,但在这之前,有一个小问题,之前实现的视频同步代码怎么办?总不能让音视频互相同步吧,或者把之前的代码都注释掉?因此我们定义了一个方法getMasterClock,它会根据当前同步类型来区分是调用getVideoClock还是getAudioClock,亦或其他时钟。
代码语言:javascript复制enum {
kAVSyncAudioMaster,
kAVSyncVideoMaster,
kAVSyncExternalMaster,
};
const int kAvSyncDefaultMaster = kAVSyncVideoMaster;
double getMasterClock(VideoState* pVideoState) {
if (pVideoState->avSyncType==kAVSyncAudioMaster) {
return getAudioClock(pVideoState);
}
else if (pVideoState->avSyncType == kAVSyncVideoMaster) {
return getVideoClock(pVideoState);
}
else {
return getExternalClock(pVideoState);
}
}
同步音频
终于到同步音频的部分了。同步方法是根据音视频时钟的差值,计算出需要调整多少音频采样:如果音频比视频慢就丢掉部分采样来加速,如果快则增加一些采样来减速。
因此我们定义一个synchronizeAudio方法来专门处理音频同步。在增/减音频样本前,还要注意两点:
由于音频频率比视频高很多,我们不想每次都处理音频数据。因此synchronizeAudio会先统计缺失同步的次数,只有在连续20次都缺失同步后,这个方法才真正开始工作。是否缺失同步则通过音视频时差是否大于同步阈值来判断。
在计算音视频时差时,还需要做一点微小的调整。是这样的,虽然之前实现了视频时钟的动态值计算,音视频时差不会朝一个方向递增了,但还是会上下波动。可能第一次计算音视频之间差40ms,第二次差50ms,第三次又差35ms了,没有一次能完全准确的代表时差。如果取多个时差的平均值呢?也不行,我们期望的是最近一次时差的权重最大,然后依次递减,计算公式是: 新总时差 = 新时差 系数*旧总时差。公式里的系数能很好的帮我们降低前面时差的权重。
翻译成代码就是这样:
代码语言:javascript复制int synchronizeAudio(VideoState* pVideoState, int16_t* samples, int samplesSize, double pts)
{
int bytesPerSamples;
double refClock;
bytesPerSamples = sizeof(int16_t) * pVideoState->pAudioCodecCtx->channels;
if (pVideoState->avSyncType != kAVSyncAudioMaster) {
double diff, avgDiff;
int wantedSize, minSize, maxSize;
refClock = getMasterClock(pVideoState);
diff = getAudioClock(pVideoState) - refClock;
if (fabs(diff) < kAVNoSyncThreshold) {
//先积累差值
pVideoState->audioDiffCum = diff pVideoState->audioDiffCum * pVideoState->audioDiffAvgCoef;
if (pVideoState->audioDiffAvgCount < kAudioDiffAvgNb) {
pVideoState->audioDiffAvgCount;
}
else {
//当连续累计到20次时才会尝试去同步
avgDiff = pVideoState->audioDiffCum * (1.0 - pVideoState->audioDiffAvgCoef);
//缩减或增加采样的代码
...
}
}
else {
//音视频时间差值过大
pVideoState->audioDiffCum=0;
pVideoState->audioDiffAvgCount=0;
}
}
return samplesSize;
}
然后再来计算该增/减多少采样
代码语言:javascript复制 if (fabs(avgDiff) >= 0.04) {
//确保sync后的sample在一定范围内
wantedSize = samplesSize ((int)(diff * pVideoState->pAudioCodecCtx->sample_rate) * bytesPerSamples);
minSize = samplesSize * (1 - kSampleCorrectionPercentMax);
maxSize = samplesSize * (1 kSampleCorrectionPercentMax);
if (wantedSize > maxSize){
wantedSize = maxSize;
}
else if (wantedSize < minSize) {
wantedSize = minSize;
}
计算某时差内的音频数据大小公式: 时差*采样率*频道数*单个样本字节数=该时差对应音频数据的字节数。在算出wanted_size后,还要将其调整到一个合理的范围,不然一次调整太多,会有大量噪音或跳跃过大。
调整音频数据
synchronizeAudio会返回一个samplesSize,代表应该将多少字节送入SDL的buffer播放,我们需要在最后修改它。
当wantedSize<samplesSize时,直接截断即可。但当wantedSize>samplesSize时,我们不能单单修改samplesSize,因为后面的buffer是空的,我们得填点什么进去。教程里推荐的做法是全部填入最后一个音频样本。
代码语言:javascript复制 if (wantedSize < samplesSize) {
samplesSize = wantedSize;
}
if (wantedSize > samplesSize) {
//用最后一个sample把所有多出来的空间填满
uint8_t* lastSample, * ptr;
int fillSize;
fillSize = wantedSize - samplesSize;
lastSample = (uint8_t*)samples samplesSize - bytesPerSamples;
ptr = lastSample bytesPerSamples;
while (fillSize >0) {
memcpy(ptr, lastSample, bytesPerSamples);
ptr = bytesPerSamples;
fillSize -= bytesPerSamples;
}
samplesSize = wantedSize;
}
}
拿到调整后的音频数据,送入SDL的buffer即可。
代码语言:javascript复制void audioCallback(void* userdata, uint8_t* stream, int len) {
...
while (len > 0) {
if (pVideoState->audioBufIndex >= pVideoState->audioBufSize) {
//所有音频数据已发出,从队列里再拿一份
decodedAudioSize = audioDecodeFrame(pVideoState, pVideoState->audioBuf, sizeof(pVideoState->audioBuf), &pts);
if (decodedAudioSize < 0) {
//拿不到数据,静音播放
pVideoState->audioBufSize = kSDLAudioSize;
memset(pVideoState->audioBuf, 0, pVideoState->audioBufSize);
}
else {
//同步音频数据
decodedAudioSize = synchronizeAudio(pVideoState, (short*)pVideoState->audioBuf, decodedAudioSize, pts);
pVideoState->audioBufSize = decodedAudioSize;
}
pVideoState->audioBufIndex = 0;
}
}
...
}
还有一件事别忘了,在之前视频同步的地方加上if判断,保证运行时只存在一种同步方式。
代码语言:javascript复制 if (pVideoState->avSyncType != kAVSyncVideoMaster) {
refClock = getAudioClock(pVideoState);
diff = pVideoPicture->pts - refClock;
//cout << "diff " << diff << endl;
//如果比音频慢的时间超过阈值,就立刻播放下一帧,相反情况则延迟两倍时间
syncThreshold = delay > kAVSyncThreshold ? delay : kAVSyncThreshold;
if (fabs(diff) < kAVNoSyncThreshold) {
if (diff <= -syncThreshold) {
delay = 0;
}
else if (diff >= syncThreshold) {
delay *= 2;
}
}
}
以上就是音频同步的全部内容了。从最后的效果来看,不太推荐音频同步,因为不管缩减还是增加采样都会打断声音的连续性,一定会被用户察觉,而视频同步只是缩短/增加两帧播放间隔,用户基本察觉不到。
代码:https://github.com/onlyandonly/ffmpeg_sdl_player