目录
- 前言
- 一、视频时钟
- 1、时钟计算方法
- 2、同步视频时钟
- 3、同步到另一个时钟
- 二、音频时钟
- 1、时钟计算方法
- (1)、时间公式 公式一
- (2)、计算方法
- 2、同步音频时钟
- (1)、阻塞式
- (2)、回调式
- 3、同步到另一个时钟
- 三、外部时钟
- 1、绝对时钟
- 四、封装成通用模块
- 1、完整代码
- 五、使用示例
- 1、基本用法
- (1)、初始化
- (2)、设置同步类型
- (3)、视频同步
- (4)、音频同步
- (5)、获取播放时间
- (6)、暂停
- (7)、定位
- 总结
- 附录 1、获取系统时钟
- (1)、Windows
- (2)、C++11
前言
编写视频播放器时需要实现音视频的时钟同步,这个功能是不太容易实现的。虽然理论通常是知道的,但是不同过实际的调试很难写出可用的时钟同步功能。其实也是有可以参考的代码,ffplay中实现了3种同步,但实现逻辑较为复杂,比较难直接提取出来使用。笔者通过参考ffplay在自己的播放器中实现了时钟同步,参考。在实现过程中发现此功能可以做成一个通用的模块,在任何音视频播放的场景都可以使用。
一、视频时钟
1、时钟计算方法
视频的时钟基于视频帧的时间戳,由于视频是通过一定的帧率渲染的,采用直接读取当前时间戳的方式获取时钟会造成一定的误差,精度不足。我们要获取准确连续的时间,应该使用一个系统的时钟,视频更新时记录时钟的起点,用系统时钟偏移后作为视频时钟,这样才能得到足够精度的时钟。流程如下:
每次渲染视频帧时,更新视频时钟起点
视频时钟起点 = 系统时钟 − 视频时间戳 视频时钟起点=系统时钟-视频时间戳 视频时钟起点=系统时钟−视频时间戳
任意时刻获取视频时钟
视频时钟 = 系统时钟 − 视频时钟起点 视频时钟=系统时钟-视频时钟起点 视频时钟=系统时钟−视频时钟起点
代码示例如下:
定义相关变量
//视频时钟起点,单位秒
double videoStartTime=0;
更新视频时钟起点
//更新视频时钟(或者说矫正更准确)起点,pts为当前视频帧的时间戳,getCurrentTime为获取当前系统时钟的时间,单位秒
videoStartTime=getCurrentTime()-pts;
获取视频时钟
//获取视频时钟,单位秒
double videoTime=getCurrentTime()-videoStartTime;
2、同步视频时钟
有了上述时钟的计算方法,我们可以获得一个准确的视频时钟。为了确保视频能够在正确的时间渲染我们还需要进行视频渲染时的时钟同步。
同步流程如下所示,流程图中的“更新视频时钟起点”和“获取视频时钟”与一节的计算方法直接对应。
核心代码示例如下:
/// <summary>
/// 同步视频时钟
/// 视频帧显示前调用此方法,传入帧的pts和duration,根据此方法的返回值确定显示还是丢帧或者延时。
/// </summary>
/// <param name="pts">视频帧pts,单位为s</param>
/// <param name="duration">视频帧时长,单位为s。</param>
/// <returns>大于0则延时,值为延时时长,单位s。等于0显示。小于0丢帧</returns>
double synVideo(double pts, double duration)
{
if (videoStartTime== 0)
//初始化视频起点
{
videoStartTime= getCurrentTime() - pts;
}
//以下变量时间单位为s
//获取视频时钟
double currentTime = getCurrentTime() - videoStartTime;
//计算时间差,大于0则late,小于0则early。
double diff = currentTime - pts;
//时间早了延时
if (diff < -0.001)
{
if (diff < -0.1)
{
diff = -0.1;
}
return -diff;
}
//时间晚了丢帧,duration为一帧的持续时间,在一个duration内是正常时间,加一个duration作为阈值来判断丢帧。
if (diff > 2 * duration)
{
return -1;
}
//更新视频时钟起点
videoStartTime= getCurrentTime() - pts;
return 0;
}
3、同步到另一个时钟
我实现了视频时钟的同步,但有时还需要视频同步到其他时钟,比如同步到音频时钟或外部时钟。将视频时钟同步到另外一个时钟很简单,在计算出视频时钟偏差diff后再加上视频时钟与另外一个时钟的差值就可以了。
上一节的流程图基础上添加如下加粗的步骤
上一节的代码加入如下内容
//计算时间差,大于0则late,小于0则early。
double diff = currentTime - pts;
//同步到另一个时钟-start
double sDiff = 0;
//anotherStartTime为另一个时钟的起始时间
sDiff = videoStartTime - anotherStartTime;
diff += sDiff;
//同步到另一个时钟-end
//时间早了延时
if (diff < -0.001)
二、音频时钟
1、时钟计算方法
音频时钟的计算和视频时钟有点不一样,但结构上是差不多的,只是音频是通过通过数据长度来计算时间的。
(1)、时间公式 公式一
时长 = 音频数据长度( b y t e s ) / ( 声道数 ∗ 采样率 ∗ 位深度 / 8 ) 时长=音频数据长度(bytes)/(声道数*采样率*位深度/8) 时长=音频数据长度(bytes)/(声道数∗采样率∗位深度/8)
代码如下:
//声道数
int channels=2;
//采样率
int samplerate=48000;
//位深
int bitsPerSample=32;
//数据长度
int dataSize=8192;
//时长,单位秒
double duration=(double)dataSize/(channels*samplerate*bitsPerSample/8);
//duration 值为:0.0426666...
公式二
时长 = 采样数 / 采样率
时长=采样数/采样率 时长=采样数/采样率
代码如下:
//采样数
int samples=1024;
//采样率
int samplerate=48000;
//时长,单位秒
double duration=(double)samples/samplerate;
//duration值为0.021333333...
(2)、计算方法
计算音频时钟首先需要记录音频的时间戳,计算音频时间戳需要将每次播放的音频数据转换成时长累加起来如下:(其中n表示累计的播放次数)
音频时间戳 = ∑ i = 0 n 音频数据长度( b y t e s ) i / ( 声道数 ∗ 采样率 ∗ 位深度 / 8 ) 音频时间戳=\sum_{i=0}^n 音频数据长度(bytes)i/(声道数*采样率*位深度/8) 音频时间戳=i=0∑n音频数据长度(bytes)i/(声道数∗采样率∗位深度/8)
或者
音频时间戳 = ∑ i = 0 n 采样数 i / 采样率 音频时间戳=\sum_{i=0}^n 采样数i/采样率 音频时间戳=i=0∑n采样数i/采样率
有了音频时间戳就可可以计算出音频时钟的起点
音频时钟起点 = 系统时钟 − 音频时间戳 音频时钟起点=系统时钟-音频时间戳 音频时钟起点=系统时钟−音频时间戳
通过音频时钟起点就可以计算音频时钟了
音频时钟 = 系统时钟 − 音频时钟起点 音频时钟=系统时钟-音频时钟起点 音频时钟=系统时钟−音频时钟起点
代码示例:
定义相关变量
//音频时钟起点,单位秒
double audioStartTime=0;
//音频时间戳,单位秒
double currentPts=0;
更新音频时钟(通过采样数)
//计算时间戳,samples为当前播放的采样数
currentPts += (double)samples / samplerate;
//计算音频起始时间
audioStartTime= getCurrentTime() - currentPts;
更新音频时钟(通过数据长度)
currentPts += (double)bytesSize / (channels *samplerate *bitsPerSample/8);
//计算音频起始时间
audioStartTime= getCurrentTime() - currentPts;
获取音频时钟
//获取音频时钟,单位秒
double audioTime= getCurrentTime() - audioStartTime;
2、同步音频时钟
有了时间戳的计算方法,接下来就需要确定同步的时机,以确保计算的时钟是准确的。通常按照音频设备播放音频的耗时去更新音频时钟就是准确的。
我们根据上述计算过方法封装一个更新音频时钟的方法:
/// <summary>
/// 更新音频时钟
/// </summary>
/// <param name="samples">采样数</param>
/// <param name="samplerate">采样率</param>
/// <returns>应该播放的采样数</returns>
int synAudio(int samples, int samplerate) {
currentPts += (double)samples / samplerate;
//getCurrentTime为获取当前系统时钟的时间
audioStartTime= getCurrentTime() - currentPts;
return samples;
}
/// <summary>
/// 更新音频时钟,通过数据长度
/// </summary>
/// <param name="bytesSize">数据长度</param>
/// <param name="samplerate">采样率</param>
/// <param name="channels">声道数</param>
/// <param name="bitsPerSample">位深</param>
/// <returns>应该播放的采样数</returns>
int synAudioByBytesSize(size_t bytesSize, int samplerate, int channels, int bitsPerSample) {
return synAudio((double)bytesSize / (channels * bitsPerSample/8), samplerate) * (bitsPerSample/8)* channels;
}
(1)、阻塞式
播放音频的时候可以使用阻塞的方式,输入音频数据等待设备播放完成再返回,这个等待时间可以真实的反映音频设备播放音频数据的耗时,我们播放完成后更新音频时钟即可。
(2)、回调式
回调式的写入音频数据,比如sdl就有这种方式。我们在回调中更新音频时钟。
3、同步到另一个时钟
音频通常情况是不需要同步到另一个时钟的,因为音频的播放本身就不需要时钟校准。但是还是有一些场景需要音频同步到另一个时钟比如多轨道播放。
关键流程如下所示:
计算应写入数据长度的代码示例:(其中samples为音频设备需要写入的采样数)
//获取音频时钟当前时间
double audioTime = getCurrentTime() - audioStartTime;
double diff = 0;
//计算差值,getMasterTime获取另一个时钟的当前时间
diff = getMasterTime(syn) - audioTime;
int oldSamples = samples;
if (fabs(diff) > 0.01)
//超过偏差阈值,加上差值对应的采样数
{
samples += diff * samplerate;
}
if (samples < 0)
//最小以不播放等待
{
samples = 0;
}
if (samples > oldSamples * 2)
//最大以2倍速追赶
{
samples = oldSamples * 2;
}
//输出samples为写入数据长度,后面音频时钟计算也以此samples为准。
三、外部时钟
播放视频的时候还可以参照一个外部的时钟,视频和音频都向外部时钟去同步。
1、绝对时钟
播放视频或者音频的时候,偶尔因为外部原因,比如系统卡顿、网络变慢、磁盘读写变慢会导致播放时间延迟了。如果一个1分钟的视频,播放过程中卡顿了几秒,那最终会在1分钟零几秒后才能播完视频。如果我们一定要在1分钟将视频播放完成,那就可以使用绝对时钟作为外部时钟。
本文采用的就是这种时钟,大致实现步骤如下:
视频开始播放-->设置绝对时钟起点-->视频同步到绝对时钟-->音频同步到绝对时钟
四、封装成通用模块
1、完整代码
/************************************************************************
* @Project: Synchronize
* @Decription: 视频时钟同步模块
* 这是一个通用的视频时钟同步模块,支持3种同步方式:同步到视频时钟、同步到音频时钟、以及同步到外部时钟(绝对时钟)。
* 使用方法也比较简单,初始化之后在视频显示和音频播放处调用相应方法即可。
* 非线程安全:内部实现未使用线程安全机制。对于单纯的同步一般不用线程安全机制。当需要定位时可能需要一定的互斥操作。
* 没有特殊依赖(目前版本依赖了ffmpeg的获取系统时钟功能,如果换成c++则一行代码可以实现,否则需要宏区分实现各个平台的系统时钟获取)
* @Verision: v1.0
* @Author: Xin Nie
* @Create: 2022/9/03 14:55:00
* @LastUpdate: 2022/9/22 16:36:00
************************************************************************
* Copyright @ 2022. All rights reserved.
************************************************************************/
/// <summary>
/// 时钟对象
/// </summary>
typedef struct {
//起始时间
double startTime;
//当前pts
double currentPts;
}Clock;
/// <summary>
/// 时钟同步类型
/// </summary>
typedef enum {
//同步到音频
SYNCHRONIZETYPE_AUDIO,
//同步到视频
SYNCHRONIZETYPE_VIDEO,
//同步到绝对时钟
SYNCHRONIZETYPE_ABSOLUTE
}SynchronizeType;
/// <summary>
/// 时钟同步对象
/// </summary>
typedef struct {
/// <summary>
/// 音频时钟
/// </summary>
Clock audio;
/// <summary>
/// 视频时钟
/// </summary>
Clock video;
/// <summary>
/// 绝对时钟
/// </summary>
Clock absolute;
/// <summary>
/// 时钟同步类型
/// </summary>
SynchronizeType type;
/// <summary>
/// 估算的视频帧时长
/// </summary>
double estimateVideoDuration;
/// <summary>
/// 估算视频帧数
/// </summary>
double n;
}Synchronize;
/// <summary>
/// 返回当前时间
/// </summary>
/// <returns>当前时间,单位秒,精度微秒</returns>
static double getCurrentTime()
{
//此处用的是ffmpeg的av_gettime_relative。如果没有ffmpeg环境,则可替换成平台获取时钟的方法:单位为秒,精度需要微妙,相对绝对时钟都可以。
return av_gettime_relative() / 1000000.0;
}
/// <summary>
/// 重置时钟同步
/// 通常用于暂停、定位
/// </summary>
/// <param name="syn">时钟同步对象</param>
void synchronize_reset(Synchronize* syn) {
SynchronizeType type = syn->type;
memset(syn, 0, sizeof(Synchronize));
syn->type = type;
}
/// <summary>
/// 获取主时钟
/// </summary>
/// <param name="syn">时钟同步对象</param>
/// <returns>主时钟对象</returns>
Clock* synchronize_getMasterClock(Synchronize* syn) {
switch (syn->type)
{
case SYNCHRONIZETYPE_AUDIO:
return &syn->audio;
case SYNCHRONIZETYPE_VIDEO:
return &syn->video;
case SYNCHRONIZETYPE_ABSOLUTE:
return &syn->absolute;
default:
break;
}
return 0;
}
/// <summary>
/// 获取主时钟的时间
/// </summary>
/// <param name="syn">时钟同步对象</param>
/// <returns>时间,单位s</returns>
double synchronize_getMasterTime(Synchronize* syn) {
return getCurrentTime() - synchronize_getMasterClock(syn)->startTime;
}
/// <summary>
/// 设置时钟的时间
/// </summary>
/// <param name="syn">时钟同步对象</param>
/// <param name="pts">当前时间,单位s</param>
void synchronize_setClockTime(Synchronize* syn, Clock* clock, double pts)
{
clock->currentPts = pts;
clock->startTime = getCurrentTime() - pts;
}
/// <summary>
/// 获取时钟的时间
/// </summary>
/// <param name="syn">时钟同步对象</param>
/// <param name="clock">时钟对象</param>
/// <returns>时间,单位s</returns>
double synchronize_getClockTime(Synchronize* syn, Clock* clock)
{
return getCurrentTime() - clock->startTime;
}
/// <summary>
/// 更新视频时钟
/// </summary>
/// <param name="syn">时钟同步对象</param>
/// <param name="pts">视频帧pts,单位为s</param>
/// <param name="duration">视频帧时长,单位为s。缺省值为0,内部自动估算duration</param>
/// <returns>大于0则延时值为延时时长,等于0显示,小于0丢帧</returns>
double synchronize_updateVideo(Synchronize* syn, double pts, double duration)
{
if (duration == 0)
//估算duration
{
if (pts != syn->video.currentPts)
syn->estimateVideoDuration = (syn->estimateVideoDuration * syn->n + pts - syn->video.currentPts) / (double)(syn->n + 1);
duration = syn->estimateVideoDuration;
//只估算最新3帧
if (syn->n++ > 3)
syn->estimateVideoDuration = syn->n = 0;
if (duration == 0)
duration = 0.1;
}
if (syn->video.startTime == 0)
{
syn->video.startTime = getCurrentTime() - pts;
}
//以下变量时间单位为s
//当前时间
double currentTime = getCurrentTime() - syn->video.startTime;
//计算时间差,大于0则late,小于0则early。
double diff = currentTime - pts;
double sDiff = 0;
if (syn->type != SYNCHRONIZETYPE_VIDEO && synchronize_getMasterClock(syn)->startTime != 0)
//同步到主时钟
{
sDiff = syn->video.startTime - synchronize_getMasterClock(syn)->startTime;
diff += sDiff;
}
//时间早了延时
if (diff < -0.001)
{
if (diff < -0.1)
{
diff = -0.1;
}
return -diff;
}
syn->video.currentPts = pts;
//时间晚了丢帧,duration为一帧的持续时间,在一个duration内是正常时间,加一个duration作为阈值来判断丢帧。
if (diff > 2 * duration)
{
return -1;
}
//更新视频时钟
printf("video-time:%.3lfs audio-time:%.3lfs absolute-time:%.3lfs synDiff:%.4lfms diff:%.4lfms \r", pts, getCurrentTime() - syn->audio.startTime, getCurrentTime() - syn->absolute.startTime, sDiff * 1000, diff * 1000);
syn->video.startTime = getCurrentTime() - pts;
if (syn->absolute.startTime == 0)
//初始化绝对时钟
{
syn->absolute.startTime = syn->video.startTime;
}
return 0;
}
/// <summary>
/// 更新音频时钟
/// </summary>
/// <param name="syn">时钟同步对象</param>
/// <param name="samples">采样数</param>
/// <param name="samplerate">采样率</param>
/// <returns>应该播放的采样数</returns>
int synchronize_updateAudio(Synchronize* syn, int samples, int samplerate) {
if (syn->type != SYNCHRONIZETYPE_AUDIO && synchronize_getMasterClock(syn)->startTime != 0)
{
//同步到主时钟
double audioTime = getCurrentTime() - syn->audio.startTime;
double diff = 0;
diff = synchronize_getMasterTime(syn) - audioTime;
int oldSamples = samples;
if (fabs(diff) > 0.01) {
samples += diff * samplerate;
}
if (samples < 0)
{
samples = 0;
}
if (samples > oldSamples * 2)
{
samples = oldSamples * 2;
}
}
syn->audio.currentPts += (double)samples / samplerate;
syn->audio.startTime = getCurrentTime() - syn->audio.currentPts;
if (syn->absolute.startTime == 0)
//初始化绝对时钟
{
syn->absolute.startTime = syn->audio.startTime;
}
return samples;
}
/// <summary>
/// 更新音频时钟,通过数据长度
/// </summary>
/// <param name="syn">时钟同步对象</param>
/// <param name="bytesSize">数据长度</param>
/// <param name="samplerate">采样率</param>
/// <param name="channels">声道数</param>
/// <param name="bitsPerSample">位深</param>
/// <returns>应该播放的数据长度</returns>
int synchronize_updateAudioByBytesSize(Synchronize* syn, size_t bytesSize, int samplerate, int channels, int bitsPerSample) {
return synchronize_updateAudio(syn, bytesSize / (channels * bitsPerSample/8), samplerate) * (bitsPerSample /8)* channels;
}
五、使用示例
1、基本用法
(1)、初始化
Synchronize syn;
memset(&syn,0,sizeof(Synchronize));
(2)、设置同步类型
设置同步类型,默认不设置则为同步到音频
//同步到音频
syn->type=SYNCHRONIZETYPE_AUDIO;
//同步到视频
syn->type=SYNCHRONIZETYPE_VIDEO;
//同步到绝对时钟
syn->type=SYNCHRONIZETYPE_ABSOLUTE;
(3)、视频同步
在视频渲染处调用,如果只有视频没有音频,需要注意将同步类型设置为SYNCHRONIZETYPE_VIDEO、或SYNCHRONIZETYPE_ABSOLUTE。
//当前帧的pts,单位s
double pts;
//当前帧的duration,单位s
double duration;
//视频同步
double delay =synchronize_updateVideo(&syn,pts,duration);
if (delay > 0)
//延时
{
//延时delay时长,单位s
}
else if (delay < 0)
//丢帧
{
}
else
//播放
{
}
(4)、音频同步
在音频播放处调用
//将要写入的采样数
int samples;
//音频的采样率
int samplerate;
//时钟同步,返回的samples为实际写入的采样数,将要写入的采样数不能变,实际采样数需要压缩或拓展到将要写入的采样数。
samples = synchronize_updateAudio(&syn, samples, samplerate);
(5)、获取播放时间
获取当前播放时间
//返回当前时钟,单位s。
double cursorTime=synchronize_getMasterTime(&syn);
(6)、暂停
暂停后直接将时钟重置即可。但在重新开始播放之前将无法获取正确的播放时间。
void pause(){
//暂停逻辑
...
//重置时钟
synchronize_reset(&syn);
}
(7)、定位
定位后直接将时钟重置即可,需要注意多线程情况下避免重置时钟后又被更新。
void seek(double pts){
//定位逻辑
...
//重置时钟
synchronize_reset(&syn);
}
在音频解码或即将播放处,校正音频定位后的时间戳。
//音频定位后第一帧的时间戳。
double pts;
synchronize_setClockTime(&syn, &syn.audio, pts);