OpenMAX编程-时钟与同步

阅读原文

导读:
音视频的同步问题一直是音视频播放过程中很重要的一部分,OpenMax的spec文档里面给出了一种推荐的音视频同步的做法,而且很多地方也正是采用的这种推荐的音视频同步方法。本文就对spec里面介绍的音视频同步方法进行拆解分析,探究其编码实现过程。

往期文章索引:
04 - OpenMAX编程-音视频等组件介绍
03 - OpenMAX编程-实现一个组件
02 - OpenMAX编程-数据结构
01 - OpenMAX编程-组件
00 - OpenMAX编程初识

时钟组件

时钟组件是一个比较特殊的组件,特殊在其没有属于自己的具体的数据流,它被用来对音视频进行平滑、同步控制(控制音视频的Render速率)。时钟组件的输入是一路音频参考时钟加上一路视频参考时钟,最终会由两者派生出一个media时钟。时钟组件与客户端(Client)通过时钟port连接来共享时间,时钟组件内部提供一个media时钟的控制机制,而客户端(Client)也可以通过端口来进行速率控制。

时间戳(Timestamps)

所有的时间戳数据在时钟组件中都用下面一个结构体进行描述:

typedef struct OMX_TICKS
{
    OMX_U32 nLowPart;
    OMX_U32 nHighPart;
} OMX_TICKS;

可以看到该结构体表示了一个64位的有符号类型的变量,它表示的单位是us,它可以表示如下的内容:

  • 正的和负的时间。负的时间会出现在pre-roll这种情况下,此时会有负的时间戳与增量。pre-roll可以理解为缓冲,比如视频暂停时为了下次能够尽快地恢复播放状态,只有在缓存了足够数量的流数据之后才能够进入暂停状态,这个过程就可以称之为pre-roll。
  • 高分辨率时间戳(这个高分辨率值的是时间戳的采样点-密集采样称之为高分辨率),比如基于90kHZ的MPEG2时间戳。高分辨率的时间戳也能够保证更高精度以及更加同步的数据传输控制(比如有的音频采样就是按照192kHZ来的)。
  • 更大的动态时间范围,64位大概可以表示正负大约2600万天,而32位的分辨率则仅可以表示正负35分钟(想想平时普通的电影大概就是2个小时,这种情况下就只能用更加麻烦的手段去实现2个小时的时间戳统计)。

可以选择把64bit转换为32bit来实现有限精度的时间戳表示,但是,显然可知,该情况下会有精度损失的风险。

media时钟

clock组件内部会维持一个media时钟,它对media数据流进行同步跟踪,使用时间戳来表示即时的media时钟时间节点,这个时间戳是基于起始时间推算出来的(一般情况下起始时间为0,后续不断累加)。media时间可以进行增加-increase(对应与正常播放或者快进播放)、减少-decrease(对应于倒播)、保持为固定值-hold(对应暂停播放),这些都是通过对media时钟进行速率控制实现的。

  • media时钟缩放
    时钟组件内部会维护一个缩放因子,直接用于速率控制,该缩放因子是一个播放速率的倍数值,比如为1就表示正常播放,为0就表示暂停播放,负值就是倒播。
    media scale图示

    media scale图示

可以使用下面的结构体来进行缩放系数的设置与获取:

typedef struct OMX_TIME_CONFIG_SCALETYPE {
    OMX_U32 nSize;
    OMX_VERSIONTYPE nVersion;
    OMX_U32 xScale;
} OMX_TIME_CONFIG_SCALETYPE;
  • 客户端起始时间
    客户端通过OMX_SetConfig回调的OMX_IndexConfigTimeClientStartTime索引进行起始时间的设置(起始时间包含在有着OMX_BUFFERFLAG_STARTTIME标记的buffer中),该设置包含了两个信息:

    1. 数据流已经准备完毕。
    2. 数据流开始的时间戳(startup或者seek结束时的时间戳)。
      时钟组件会为每个与之绑定的客户端组件维护一个起始时间(注意只有一个起始时间,由与之绑定的所有组件共同决定,通常是发送起始信号信息的组件传递的时间戳数值中最早的那个),该起始时间通过OMX_TIME_CONFIG_TIMESTAMPTYPE结构体进行传递。当时钟组件被初始化之后进入OMX_TIME_WaitingOnStartTime状态时,会在该状态下等待所有的组件发送完毕起始时钟信号,然后才进如Running状态,这个动作保证了播放的同步性。
  • media时钟的状态
    时钟的状态使用下面的结构体进行描述:

typedef struct OMX_TIME_CONFIG_CLOCKSTATETYPE {
    OMX_U32 nSize;
    OMX_VERSIONTYPE nVersion;
    OMX_TIME_CLOCKSTATE eState;
    OMX_TICKS nStartTime;
    OMX_TICKS nOffset;
    OMX_U32 nWaitMask;
} OMX_TIME_CONFIG_CLOCKSTATETYPE;
  1. nStartTime:指明media时间,此时时钟组件已经开始运行或者即将开始运行。即将开始运行就是已经接收到了某个绑定组件发来的起始信号信息,但是还没有全部接收,所以此时间可能会随着后续组件起始信号的发送而被更新,此为即将开始运行。
  2. nWaitMask:位掩码,每个与之绑定的组件都占有一个位,当所有的位都被清除掉的时候,表明所有的组件都发送过起始信号信息了,此时就可以从OMX_TIME_ClockStateWaitingForStartTime状态转入OMX_TIME_ClockStateRunning状态了。
  3. nOffset:时间偏移值,针对media time,比如值为-x就表明pre-roll过程中的前向时间间隔x。
  4. eState:状态枚举类型,状态取值如下所示
状态枚举成员 状态说明
OMX_TIME_ClockStateRunning media时钟开始运行
OMX_TIME_ClockStateWaitingForStartTime 等待所有的组件发送完毕起始信号
OMX_TIME_ClockStateStopped 停止运行,比如暂停播放

状态转换通过IL Client来完成(Client发送相关的命令进行控制),需要注意的是,从OMX_TIME_ClockStateWaitingForStartTimeOMX_TIME_ClockStateRunning状态的转换是组件内部自发完成的,在时钟组件的nWaitMask位被全部清除的时候就会自动转入运行状态,无需Client显式的去调用相关的命令去完成。状态转换过程如下所示(虚线即是表明无需Client主动参与):

时钟组件的状态转换

时钟组件的状态转换

  • 状态转换关系
    1. OMX_TIME_ClockStateStopped:立即停止media时钟,清除所有正在挂起的media时间请求,清除所有的起始时间,,然后转换到该状态,该状态下可以任意地与其它两个状态进行互转。
    2. OMX_TIME_ClockRunning:立即启动media时钟(需要把起始时间与offset计算在内)。
    3. OMX_TIME_WaitingOnStartTime:立即进入等待状态,等待所有nWaitMask指定的组件上报完毕它们各自的起始时间之后,选择这个起始时间当中最小的一个来作为真正的起始时间,并自动转入OMX_TIME_ClockRunning状态。只能从Stop状态转为该状态。

时钟时间线

Wall Clock

该时间可以简单理解为在clock组件转入OMX_TIME_ClockRunning状态时候的参考时间坐标系,在这个参考坐标系上面派生出media time,包括video的参考时间值等(后面会有图片说明),clock组件根据周期性更新的参考始终与该值推算出media的时间。Client可以通过OMX_IndexConfigTimeCurrentWallTime索引来获取clock组件的Wall time,该时间不可由IL Client主动进行设置(如果主动设置的话就无法保证media时间的正确性了)。

参考时钟

clock组件可以接收video与audio的参考时钟,它们可以分别由video组件与audio组件提供,不过需要说明的一点是目前一般情况下的同步方案是视频跟随音频,也就是把音频的时间作为参考时钟,由音频时间为准去调节视频的时间,并且根据实际的体验测试,音频与视频的时间差在50ms以内的时候人的主观感觉是察觉不到这个差距的。参考时钟所在的组件(此处默认参考时钟提供者是音频组件)会按照一定的频率(可以是5S左右,这个时间需要根据实际的使用体验进行调整,如果达不到预期的同步效果就减少这个值,如果能够轻松达到预期效果,就可以适当放大以减少一部分系统负担)从clock组件获取到media time,然后把这个时间跟自己的参考时钟对比一下,再把调整之后的media time返回给clock组件,此处的回调方法为:OMX_SetConfigOMX_IndexConfigTimeCurrentAudioReferenceindex项,如果是视频作为参考时钟的话就是OMX_IndexConfigTimeCurrentVideoReference索引。该索引会传递以下结构体类型:

typedef struct OMX_TIME_CONFIG_TIMESTAMPTYPE {
    OMX_U32 nSize;
    OMX_VERSIONTYPE nVersion;
    OMX_TICKS nReferenceTimestamp;
} OMX_TIME_CONFIG_TIMESTAMPTYPE;

clock组件在收到这个时间之后会更新自己的media time,注意这里的更新不是直接把这个时间设置为media time,而是根据该参考时间与当前实际的media time做一个对比,然后通过一定的计算得出一个新的缩放因子,把缩放因子设置到media time的计算当中,这个计算可以是pid控制,也可以是简单的比例控制、抑或有限制的分段比例控制(此处以实际效果体验为准进行选择)。这样就不会打断视频的连续性,可以达到如果音频超前于视频,那么视频就快放,如果音频滞后于视频,那么视频就慢放等待音频。OMX_IndexConfigTimeActiveRefClock索引会设置clock组件的参考时钟(此处指的是音频、视频组件内部反馈的时钟),结构体类型如下:

typedef struct OMX_TIME_CONFIG_ACTIVEREFCLOCKTYPE {
    OMX_U32 nSize;
    OMX_VERSIONTYPE nVersion;
    OMX_TIME_REFCLOCKTYPE eClock;
} OMX_TIME_CONFIG_ACTIVEREFCLOCKTYPE;

eClock枚举成员的取值范围如下所示:

枚举 意义
OMX_TIME_RefClockNone 不使用参考时钟
OMX_TIME_RefClockAudio 使用audio组件时钟作为参考时钟
OMX_TIME_RefClockVideo 使用video组件作为参考时钟

下面一张图演示了各个时间之间的关系:

各时间变量之间的关系

各时间变量之间的关系

start_time:clock组件转换为Executing状态时的时间,通过(clock_gettime获得)。
wall_time:clock组件在收到音视频开启Render命令之后的实时clock时间。
clock_time:clock运行的实时时间,也就是(clock_gettime得到的系统时间,monotonic模式)。
pause_time:视频暂停时的clock时间。
pause_duration:暂停的时间段累加。
sample_time:调整ratio时的的clock_time-pause_duration值(没有进行缩放的)。
clock_system_time:clock组件计算出来的视频已经显示的时间长度(已经进行缩放的)。
media_time_base:视频帧开始显示时的时间戳(由视频帧本身提供)。
media_display_time:视频帧应该显示到哪个时间戳的位置,也就是播放器时间轴的时间值。
audio_time:音频的时间戳,时间轴会根据音频的时间来进行滚动。

理想情况:audio_time == media_display_time。此为终极目标
media_display_time = media_time_base + clock_system_time;
clock_system_time = clock_time - pause_duration - wall_time; //没有任何缩放作用
clock_system_time = ((clock_time - pause_duration) - sample_time) * ratio + clock_system_time_old;
// clock_system_time_old:上一次的clock_system_time,两者都是经过缩放的。

media time的控制

media time更新

clock组件可以通过下面的结构体来进行时间的更新(回返给client,这里的client指的是client组件,比如与时钟组件相绑定的音频、视频组件),包括响应client组件的media time请求(获取当前的media time)或者缩放因子改变。

typedef struct OMX_TIME_MEDIATIMETYPE {
    OMX_U32 nSize;
    OMX_VERSIONTYPE nVersion;
    OMX_U32 nClientPrivate;
    OMX_TIME_UPDATETYPE eUpdateType;
    OMX_TICKS nMediaTimestamp;
    OMX_TICKS nOffset;
    OMX_TICKS nWallTimeAtMediaTime;
    OMX_S32 xScale;
    OMX_TIME_CLOCKSTATE eState;
} OMX_TIME_MEDIATIMETYPE;

其中eUpdateType有如下的枚举成员值:

枚举 意义
OMX_TIME_UpdateRequestFulfillment media time请求
OMX_TIME_UpdateScaleChanged 缩放系数被改变
OMX_TIME_UpdateClockStateChanged clock组件状态发生改变

nClientPrivate:如果请求是Fulfillment类型,该指针指向client特定的私有数据结构,否则就为NULL。
xScale:缩放系数改变(OMX_TIME_UpdateScaleChanged枚举)时使用
eState:状态改变(OMX_TIME_UpdateClockStateChanged枚举)时使用

Media Time Request(主视角是client组件)

client有时候会需要请求传输(client传给clock的)特定的时间戳,比如比如需要在特定的时间戳处传送视频帧,此时可以通过OMX_IndexConfigTimeMediaTimeRequest索引来完成media time的请求,其传递的结构体如下:

typedef struct OMX_TIME_CONFIG_MEDIATIMEREQUESTTYPE {
    OMX_U32 nSize;
    OMX_VERSIONTYPE nVersion;
    OMX_U32 nPortIndex;
    OMX_PTR pClientPrivate;
    OMX_TICKS nMediaTimestamp;
    OMX_TICKS nOffset;
} OMX_TIME_CONFIG_MEDIATIMEREQUESTTYPE;

当client组件传送完这个时间之后会原地等待,直到clock组件的time line达到这个请求的时间戳处,clock组件就会回返一个OMX_TIME_MEDIATIMETYPE类型的事件,这时client组件接收到回复之后就进行相关的操作。也就是说media time request是在client组件需要在特定时间执行特定的操作时使用-一个操作名为O,在未来的某个时间T需要被执行,并且需要用到一些私有的数据(比如一个视频帧的地址放在pClientPrivate里面),clock组件接收之后在T时间产生一个OMX_TIME_UpdateRequestFulfillment类型的事件,之后client接收到该时间,立马执行操作O。

在实际操作当中,client组件可能需要在稍微早于nMediaTimestamp时间点的时候接收到clock组件的响应,这时就需要设置nOffset,它是一个偏差值,比如你设置T为指定的时间,但是你需要在T1时间就接收到来自clock组件的响应,那么nOffset就需要被设置为T-T1,可能是正的,也可能是负的,不过一般都是正直(也就是稍微提前)。这个nOffset不宜过大,要求是在ms级别。另一个需要注意的是,由于缩放系数可能会实时发生变化,所以client组件不能简单的拿目标时间减去这个偏移量得到偏移时间,需要拿这个offset乘以缩放系数之后再去做加减操作。

Media Time Request Fulfillment(主视角是clock组件)

响应请求的时候,OMX_TIME_MEDIATIMETYPE结构体,内部包含了请求的media time以及对应请求发生时的wall time(也就是请求发生的时刻time line上面的时间戳),还有wall time line上面请求的media time与请求真正地被响应时候的offset,下面一副说明各个时间点的关系:

media time请求响应

media time请求响应

可通过上图看到,有些是因为时钟组件的实现,导致不能够非常精确地在锁请求的media time处产生响应,它实际上会比目标的offset更大,图中的Client’s actual offset就是说实际上的响应发生时的offset,它比client预定的offset大了一点点(这里有个要求,就是只能大,不能小,并且要尽量接近)。

实际上,其实当client收到回应之后,可以选择自行等待一个时间段(长度为Client’s actual offset),相较于通过clock组件来完成来说,自行等待会更加准确点,client组件可以通过OMX_IndexConfigTimeCurrentWallTime来获取当前的wall time,然后与nWallTimeAtMediaTime作差,得出offset,在client组件内部施加一个延时。另者,则个offset要尽量的小。

Scale Change Notifications

时钟组件可能会通知client组件缩放系数发生了变化,比如在快进播放的时候,视频组件可能会跳过一些视频帧,音频组件可能会加速采样等等(这取决于具体的内部实现)。

提供参考时钟的组件需要关注缩放系数的变化并做出相应的调整,通常情况下,调整包含以下方面:
1. 当缩放系数为0的时候,应停止所有的数据传输与参考时钟的运行(暂停)。
2. 恢复数据传输并恢复参考时钟的运行(从暂停恢复)。

nMediaTimestamp:当前时钟组件的media time
nWallTimeAtMediaTime:当前时钟组件的wall time
xScale:新的缩放系数
nOffset:缩放系数发生改变的那刻与该响应发送的时刻的偏差值

Clock State Change Notifications

必要时,时钟组件也需要向client组件发送状态改变的消息,client组件要做出相应的动作以适应clock组件的状态转换:
1. 当时钟组件转入stop状态时,所有的render相关的组件都要停止数据传输。
2. 提供参考时钟的组件需要使用media time request来获取时钟组件恢复运行的时间点,以及获取恢复运行时它自身的参考时钟时间值。

nMediaTimestamp:当前时钟组件的media time
nWallTimeAtMediaTime:当前时钟组件的wall time
nOffset:状态发生改变的那刻与该响应发送的时刻的偏差值

时钟组件的实现

时钟组件应该提供以下几种功能:

  • 查询自身的wall以及media
  • 查询、改变media时钟的状态与缩放系数
  • 查询、改变其参考时钟
  • 通知client组件缩放系数发生了改变
  • 回应、履行media time请求
  • 根据参考时钟更新自身的时钟

派生 Media Time

clock组件根据参考时钟与wall时钟派生出media时间。当参考时钟发送参考时间,记为Rnow,clock组件查询自己的wall time,记为Wnow,如果IL client设置了offset(在pre-roll功能时用到),Wnow需要加上offset作为真正的Wbase:

Rbase = Rnow
Wbase = Wnow + Offset
Mnow = Rbase + Scale * (Wnow – Wbase)

Mnow是当前的media time(派生出来的media time),其实这个时间理解起来非常生硬,建议直接转到前面看时钟时间线一节以及各时间变量之间的关系一图,会理解的比较清楚。

  • 一个音视频同步的例子
    同步的例子

    同步的例子

在此图中,假定音频与视频都是有自己的时间戳的,并且是准确对应的(如果片源的时间戳就不准确,那就不要指望播放时候能准确了)。此图中audio decoder与所有的render与clock组件做了连接,实际使用当中也可以用video decoder做连接。在开始播放的瞬间,audio render与video render向时钟组件发送一次自己的参考时钟(也就是第一帧数据的时间戳),该例子使用audio作为参考时钟组件,此时时钟组件不必去改变audio render的呈现时间,audio render值需要拿到数据就送去硬件播放,缩放由audio decoder来完成。而对与video render来说,它就需要在恰当的时间去输送一帧数据到硬件(这个是video render主动完成的),过程如下:
1. video render提交一个media time的请求,需要呈现的视频帧数据放到private指针指向的地址,目标时间戳就是该帧视频的时间戳,设置指定的响应时间稍微早于目标时间戳值。
2. clock在目标时间戳将要到达的时候回返一个响应给video render,包括原始时间戳与private指针(用于render视频帧数据)。
3. video render收到响应之后,在内部等待一个指定的offset之后,立马把视频帧数据传送出去。

IL client控制时钟组件的开启与停止,必要时候修改缩放系数,当修改系数发生时,clock设置新的缩放系数,然后发送给所有的client组件。当缩放系数变为0时,clock组件停止运行,client组件也停止运行,此时,audio decoder可能会在非1的缩放系数情况下忽略输入的音频压缩码流。最后,IL lient也可能会获取clock组件的media time,用以刷新进度条之类的。

该篇文章是openMAX编程系列最后一篇了(至少到此为止已经是比较完整的一个系列了),后续可能会选择更新一些落下的细枝末节,但是时间就不定了,有疑问可以加微信或者留言等等。接下来可能会对linux内核的V4L2大框架做一个详细的专题介绍,敬请期待。


一只奋斗的汪

微信公众号

猜你喜欢

转载自blog.csdn.net/u013904227/article/details/80040307