1.1 什么是音画同步
音画同步旨在通过时钟参考的方式,将音频、视频、歌词等播放时间对应起来,确保画面和声音同步。音视频播放器开发中,音画同步是一项非常重要的工作,直接影响用户的视听体验。
但音画同步涉及多种方式,由于场景的需要,每种方式有所区别。目前而言,主流的音画同步一般都是以 Audio Master 方式为主,人体对声音的敏感度超过视觉,通常意义上轻微的视频丢帧人脑会依然认为画面流畅,但是对于音频而言,一些丢帧可能反映出明显的停顿或者杂音问题,这也主流播放器是以音频为主进行音画的主要原因之一。
当然,涉及到不同行业的应用,未必一定是固定的方式,有些领域甚至也不需要音画同步(比如有些游戏特效),这些都需要根据场景进行定制开发,一款的好的播放器往往都是长时间打磨出来的。
1.2 音画同步标准
国际电信联盟于 1998 年修订《ITU-R BT.1359-1》,针对电视广播的音画同步标准,该标准至今仍被使用,同时应用范围也扩展到互联网直播领域。
图:TU-R BT.1359-1 音频时延感知标准
用户能接受的偏差:
用户不能接受的偏差
1.3 音画同步的核心逻辑
主流音画同步以Audio Master 或者独立时钟的方式,音频保持匀速播放,通过音频播放的时间进度控制视频播放的方式。
假定视频同步送显阈值为 syncTime ,异常阈值为unexpectTime,syncTime 必须大于unexpectTime,视频解码帧时间为PTS。
二、常见的音同步方式
常见的同步方式
【1】获取音频的播放时间 ,然后将视频的播放位置Seek到音频的播放位置 ,然后再将音频 Seek 到视频的位置。
这种方式本质上画面和视频都会产生卡顿,之所以两次 Seek 的原因是视频的 GOP 不确定性以及关键帧的查找相对音频比较复杂,显然 Seek 视频反而可能达不到预期,需要再次 Seek 音频进行兜底处理。
优点:
缺点:
【2】获取音频或者视频的播放时间,让播放快的一方等待直到位置对齐
计算时间差值,快的一方进行等待(或 pause),时间差对齐之后 Resume
优点:
缺点:
【3】视频丢帧&视频等待对齐
这种方式一般是常见的主流播放器实现方式,以音频控制时间为准,目前主流的播放器如MediaPlayer、ExoPlayer、iJkPlayer都是这种实现,视频快则走方案【2】的方式,让视频等待,视频慢的时候则让视频丢帧达到同步目的。
优点:
缺点:
【4】变速同步
同样以音频时间播放为准,修改视频播放倍速,音频也不会受到任何影响,视频画面微动和较快的播放,对于一般用户而言可能认为这是正常的画面。
但是IOT领域尤其以Rockchip、AllWinner、海思为主的厂商修改底层MediaPlayer,这种方式目前存在一些杂牌机中非常多的问题,比如有的MediaPlayer调用Seek之后视频立马onCompletion,有的是onError之后调用了onCompletion,碎片化非常严重,甚至有的播放器状态码都是私有的。
优点:体验较好,视频快时视频减速,视频慢时视频加速
缺点:需要兼容各种播放器状态,控制逻辑相对复杂,倍速为0时MediaPlayer 会认为调用了pause,倍速大于0会被认为调用了resume。
这里简单说一下变速同步的具体实现
三、ExoPlayer 音画同步分析
3.1 为什么说 ExoPlayer 是以音频为准
ExoPlayer源码中其本身是有时钟的,主要有两个时钟,一个是MediaCodecAudioRenderer实现的时钟,另一个是StandaloneMediaClock。前者作为Audio Master方式为视频提供音频播放时间,后者使用自然时间作为兜底的时钟,为各种Render提供播放时间。
ExoPlayer 中,Audio Master实现中有两个核心类:com.google.android.exoplayer2.audio.AudioTrackPositionTracker和com.google.android.exoplayer2.audio.AudioTimestampPoller
使用这两个类好处是避免了 AudioTrack#getPlaybackHeadPosition 的两个问题,一个是只能增大,不能后退的问题 ,如向前Seek (seek backward),第二个原因是部分杂牌设备对 AudioTrack#getPlaybackHeadPosition 的适配存在前后抖动的问题,这对音画同步而言简直就是灾难性的,AudioTrackPositionTracker 对这种问题进行了容灾处理,使得播放保持“顺滑”。
我们这里主要分析Audio Master的实现,自然时钟StandaloneMediaClock除了播放位置计算外,音画同步流程和Audio Master方式基本一样的。在 ExoPlayer 中 com.google.android.exoplayer2.audio.BaseRenderer#getMediaClock 方法是空实现,但是在子类中视频依然返回 null,只有音频渲染器进行了实现
com.google.android.exoplayer2.audio.MediaCodecAudioRenderer#getMediaClock()。
@Override
@Nullable
public MediaClock getMediaClock() {
return this;
}
这也证明了存在音频 Renderer 时以音频为准,当然如果没有音频 Renderer时,ExoPlayer 中会使用自然时钟 StandaloneMediaClock。下面是 Render 时钟选择,不存在或者空时钟的Renderer 最终被排除掉,同时不允许存在多个时钟。
com.google.android.exoplayer2.DefaultMediaClock
public void onRendererEnabled(Renderer renderer) throws ExoPlaybackException {
@Nullable MediaClock rendererMediaClock = renderer.getMediaClock(); //只有音频的不为空
if (rendererMediaClock != null && rendererMediaClock != rendererClock) {
if (rendererClock != null) {
throw ExoPlaybackException.createForUnexpected(
new IllegalStateException("Multiple renderer media clocks enabled."));
}
this.rendererClock = rendererMediaClock;
this.rendererClockSource = renderer;
rendererClock.setPlaybackParameters(standaloneClock.getPlaybackParameters());
}
}
3.2 MediaClock 的作用
MediaClock 是ExoPlayer中播放进度重要组件,核心逻辑只有两个,一个是调节播放倍速,另一个是获取播放时间。
public interface MediaClock {
/**
* Returns the current media position in microseconds.
*/
long getPositionUs();
/**
* Attempts to set the playback parameters. The media clock may override the speed if changing the
* playback parameters is not supported.
*
* @param playbackParameters The playback parameters to attempt to set.
*/
void setPlaybackParameters(PlaybackParameters playbackParameters);
/** Returns the active playback parameters. */
PlaybackParameters getPlaybackParameters();
}
不幸的是,在ExoPlayer中,自定义的MediaClock基本上很难从外部传入,那么,如果想在外部传入自定义的MediaClock怎么实现呢 ?这个问题下文解答。
3.3 同步时间更新
首先来看同步方法的调用
private void updatePlaybackPositions() throws ExoPlaybackException {
MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod();
if (playingPeriodHolder == null) {
return;
}
// Update the playback position.
long discontinuityPositionUs =
playingPeriodHolder.prepared
? playingPeriodHolder.mediaPeriod.readDiscontinuity()
: C.TIME_UNSET;
if (discontinuityPositionUs != C.TIME_UNSET) {
resetRendererPosition(discontinuityPositionUs);
// A MediaPeriod may report a discontinuity at the current playback position to ensure the
// renderers are flushed. Only report the discontinuity externally if the position changed.
if (discontinuityPositionUs != playbackInfo.positionUs) {
playbackInfo =
handlePositionDiscontinuity(
playbackInfo.periodId,
discontinuityPositionUs,
playbackInfo.requestedContentPositionUs);
playbackInfoUpdate.setPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL);
}
} else {
rendererPositionUs =
mediaClock.syncAndGetPositionUs(
/* isReadingAhead= */ playingPeriodHolder != queue.getReadingPeriod());
long periodPositionUs = playingPeriodHolder.toPeriodTime(rendererPositionUs);
maybeTriggerPendingMessages(playbackInfo.positionUs, periodPositionUs);
playbackInfo.positionUs = periodPositionUs;
}
// Update the buffered position and total buffered duration.
MediaPeriodHolder loadingPeriod = queue.getLoadingPeriod();
playbackInfo.bufferedPositionUs = loadingPeriod.getBufferedPositionUs();
playbackInfo.totalBufferedDurationUs = getTotalBufferedDurationUs();
}
从代码中我们看到,利用MediaClock 的 syncAndGetPositionUs() 获取同步时间,这个方法实际上是 DefaultMediaClock 中的,属于 MediaClock 的子类。获取RendererClock或者StandoloneMediaClock播放时间点,注意这里并不是同步视频,仅仅是获取同步时间,而是与系统时间进行同步后获取音频位置。至于syncAndGetPositionUs 我们不需要关注,这个主要是矫正不连续的时间处理。
3.4 音频播放位置如何同步到视频 ?
这个我们可以看看 doSomeWork()方法的调用,该方法在 ExoPlayer 会定时调用,用来驱动播放状态、资源加载和音画同步,方法代码实现较多,这里简单截取一下关键代码。
private void doSomeWork() throws ExoPlaybackException, IOException {
updatePlaybackPositions(); //更新位置
//...................//
boolean renderersEnded = true;
boolean renderersReadyOrEnded = true;
for(Render render : enabledRenderers){ //便利所有当前正在使用的渲染器
// TODO: Each renderer should return the maximum delay before which it wishes to be called
// again. The minimum of these values should then be used as the delay before the next
// invocation of this method.
renderer.render(rendererPositionUs, rendererPositionElapsedRealtimeUs); //同步位置
renderersEnded = renderersEnded && renderer.isEnded(); //判断是否渲染结束
boolean rendererReadyOrEnded = renderer.isReady() || readerer.isEnded() || rendererWaitingForNextStream(rendered);
if(!rendererReadyOrEnded){ //如果不处于Ready状态或者结束状态,说明Renderer解码的数据可能存在问题
renderer.maybeThrowStreamError();
}
renderersReadyOrEnded = renderersReadyOrEnded && rendererReadyOrEnded;
}
}
代码中所有enabledRenderers 都会被同步,这里不仅仅是音频,ExoPlayer具备良好的可扩展性,可同时支持多个Renderer。这里我们仅仅关注视频Renderer的同步,毕竟视频控制相对复杂
3.4 视频如何同步
在 MediaCodecVideoRender 重,render () ->drainOutputBuffer -> processOutputBuffer 传递时间,最终在 processOuputBuffer 中处理。
protected boolean processOutputBuffer(
long positionUs,
long elapsedRealtimeUs,
@Nullable MediaCodec codec,
@Nullable ByteBuffer buffer,
int bufferIndex,
int bufferFlags,
int sampleCount,
long bufferPresentationTimeUs,
boolean isDecodeOnlyBuffer,
boolean isLastBuffer,
Format format)
throws ExoPlaybackException {
Assertions.checkNotNull(codec); // Can not render video without codec
if (initialPositionUs == C.TIME_UNSET) {
initialPositionUs = positionUs;
}
//获取数据流偏移时间
long outputStreamOffsetUs = getOutputStreamOffsetUs();
//计算出当前要送显的pts
long presentationTimeUs = bufferPresentationTimeUs - outputStreamOffsetUs;
if (isDecodeOnlyBuffer && !isLastBuffer) {
//如果仅仅参与解码,且不是最后提个buffer,所有数据均不送显示, 最终调用codec.releaseOutputBuffer(index, false) ,false 表示不送显
skipOutputBuffer(codec, bufferIndex, presentationTimeUs);
return true;
}
// 计算出与同步时钟的时间偏移
long earlyUs = bufferPresentationTimeUs - positionUs;
if (surface == dummySurface) {
// Skip frames in sync with playback, so we'll be at the right frame if the mode changes.
//如果是默认兜底的Surface,不进行同步,如果buffer晚于播放时间,直接丢弃SKIP,否则buffer依然保留,知道时间到达后再次skip
if (isBufferLate(earlyUs)) {
skipOutputBuffer(codec, bufferIndex, presentationTimeUs);
updateVideoFrameProcessingOffsetCounters(earlyUs);
return true;
}
return false;
}
long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000;
long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderTimeUs;
//判断是否处于播放状态
boolean isStarted = getState() == STATE_STARTED;
//判断是否该渲染首帧
boolean shouldRenderFirstFrame =
!renderedFirstFrameAfterEnable
? (isStarted || mayRenderFirstFrameAfterEnableIfNotStarted)
: !renderedFirstFrameAfterReset;
// Don't force output until we joined and the position reached the current stream.
//判断是否强制渲染,基本上如果是首帧,或者上一帧显示超过100ms且early小于30ms才会强制渲染,其他情况不需要强制渲染,具体看shouldForceRenderOutputBuffer() 源码
boolean forceRenderOutputBuffer =
joiningDeadlineMs == C.TIME_UNSET
&& positionUs >= outputStreamOffsetUs
&& (shouldRenderFirstFrame
|| (isStarted && shouldForceRenderOutputBuffer(earlyUs, elapsedSinceLastRenderUs)));
if (forceRenderOutputBuffer) {
long releaseTimeNs = System.nanoTime();
notifyFrameMetadataListener(presentationTimeUs, releaseTimeNs, format);
if (Util.SDK_INT >= 21) {
renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, releaseTimeNs);
} else {
renderOutputBuffer(codec, bufferIndex, presentationTimeUs);
}
updateVideoFrameProcessingOffsetCounters(earlyUs);
return true;
}
//如果视频还没正式播放,直接返回
if (!isStarted || positionUs == initialPositionUs) {
return false;
}
//下面是early大于 0的情况
// Fine-grained adjustment of earlyUs based on the elapsed time since the start of the current
// iteration of the rendering loop.
long elapsedSinceStartOfLoopUs = elapsedRealtimeNowUs - elapsedRealtimeUs;
earlyUs -= elapsedSinceStartOfLoopUs;
// Compute the buffer's desired release time in nanoseconds.
long systemTimeNs = System.nanoTime();
long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000);
// Apply a timestamp adjustment, if there is one.
long adjustedReleaseTimeNs = frameReleaseTimeHelper.adjustReleaseTime(
bufferPresentationTimeUs, unadjustedFrameReleaseTimeNs);
//调整送显时间
earlyUs = (adjustedReleaseTimeNs - systemTimeNs) / 1000;
//判断是否跳帧还是丢帧,跳帧和丢帧最大的区别是,后者会通知到播放器外部
boolean treatDroppedBuffersAsSkipped = joiningDeadlineMs != C.TIME_UNSET;
if (shouldDropBuffersToKeyframe(earlyUs, elapsedRealtimeUs, isLastBuffer)
&& maybeDropBuffersToKeyframe(
codec, bufferIndex, presentationTimeUs, positionUs, treatDroppedBuffersAsSkipped)) {
//是否丢帧到关键帧的位置,这里尤其需要注意,如果逻辑走到这里,解码器可能会重启,可能出现黑屏问题
return false;
} else if (shouldDropOutputBuffer(earlyUs, elapsedRealtimeUs, isLastBuffer)) {
if (treatDroppedBuffersAsSkipped) {
skipOutputBuffer(codec, bufferIndex, presentationTimeUs);
} else {
dropOutputBuffer(codec, bufferIndex, presentationTimeUs);
}
updateVideoFrameProcessingOffsetCounters(earlyUs);
return true;
}
if (Util.SDK_INT >= 21) {
// Let the underlying framework time the release.
// android 5.0+ 版本只要送显时间小于50ms则送显
if (earlyUs < 50000) {
notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format);
renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, adjustedReleaseTimeNs);
updateVideoFrameProcessingOffsetCounters(earlyUs);
return true;
}
} else {
// We need to time the release ourselves.
if (earlyUs < 30000) {
//android 4.4 之前 版本 小于30ms送显,但是如果时间大于11ms,则适当降低延迟
if (earlyUs > 11000) {
// We're a little too early to render the frame. Sleep until the frame can be rendered.
// Note: The 11ms threshold was chosen fairly arbitrarily.
try {
// Subtracting 10000 rather than 11000 ensures the sleep time will be at least 1ms.
//这里实际上exoplayer希望的是尽可能保证10ms的节奏,如果超过至少sleep 1ms
Thread.sleep((earlyUs - 10000) / 1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format);
renderOutputBuffer(codec, bufferIndex, presentationTimeUs);
updateVideoFrameProcessingOffsetCounters(earlyUs);
return true;
}
}
//返回false,一般来说主要还是送显时间未到,那么未送显的buffer仍然保留着
// We're either not playing, or it's not time to render the frame yet.
return false;
}
上面代码分析直接写到注释里了。基本逻辑是:通过一大段的执行方法得到校准后的时间 earlyUs,接下来要根据 earlyUs 来丢帧、跳帧或者说等一等音频解码。如果 earlyUs 时间差为正值,代表视频帧应该在当前系统时间之后被显示,换言之,代表视频帧来早了,反之,如果时间差为负值,代表视频帧应该在当前系统时间之前被显示,换言之,代表视频帧来晚了。如果超过一定的限值,即该视频帧来得太晚了,则将这一帧丢掉,不予显示。按照预设的门限值,视频帧比预定时间来的早了 30~50ms 以上,Android 5.0以上可以控制展示时间,超过则不予送显,等待下次定时同步;如果是Android 4.4之前则进入等待,且Android 4.4版本中ExoPlayer中内部逻辑显然期待以10ms的频率进行同步,否则直接送显。
通过本篇我们知道整个同步流程是定时触发的,以确保属于主动检测的方式进行同步。在有些业务中的音频输出和ExoPlayer是分开的,我们要考虑如何通过音频播放器去同步ExoPlayer中的视频渲染器,但有ExoPlayer具备高度的可扩展性,我们可以通过自定时钟的方式去同步ExoPlayer的视频播放,当然前提是熟悉ExoPlayer的音画同步的调用流程。
图:音画同步主要调用流程
ExoPlayer 具备很强的可扩展性,但是如果通过传参数,是很难将自定义的MediaClock传入进去的。
但是ExoPlayer的开发者也提供了另一种通道 ,那就是通过com.google.android.exoplayer2.DefaultRenderersFactory#createRenderers,我们可以继承DefaultRenderersFactory,复写createRenderers 相关实现,将我们自定义的MediaClock 传入相应的Renderer 中,前面说过,Renderer的基类com.google.android.exoplayer2.BaseRenderer#getMediaClock是支持自定义MediaClock的。
public class MusicMediaCodecVideoRenderer extends MediaCodecVideoRenderer {
private KtvMediaClock mediaClock;
public MusicMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector) {
super(context, mediaCodecSelector);
}
public MusicMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs) {
super(context, mediaCodecSelector, allowedJoiningTimeMs);
}
public MusicMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, @Nullable Handler eventHandler, @Nullable VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) {
super(context, mediaCodecSelector, allowedJoiningTimeMs, eventHandler, eventListener, maxDroppedFramesToNotify);
}
public MusicMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, boolean enableDecoderFallback, @Nullable Handler eventHandler, @Nullable VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) {
super(context, mediaCodecSelector, allowedJoiningTimeMs, enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify);
}
public void setMediaClock(KtvMediaClock mediaClock) {
this.mediaClock = mediaClock;
}
@Override
protected void onStarted() {
super.onStarted();
if(this.mediaClock != null) {
this.mediaClock.start();
}
}
@Override
protected void onStopped() {
super.onStopped();
if (this.mediaClock != null) {
this.mediaClock.stop();
}
}
@Override
protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException {
super.onPositionReset(positionUs, joining);
if (this.mediaClock != null) {
this.mediaClock.resetPosition(positionUs);
}
}
@Override
public MediaClock getMediaClock() {
return mediaClock;
}
}
我们知道AudioTrack本身无法支持Seek,另外由于系统版本原因,AudioTrack对进度的处理不是很完善,我们通常只能使用offset + getPlaybackHeadPosition 做Seek逻辑,然后通过偏移量计算出时间。
然而,在部分设备上通过AudioTack#getPlaybackHeadPosition计算时间存在很多问题,因为存在很多难点,主要是延迟的处理,有的设备上获取的PlaybackHeadPosition存在严重的抖动,因此,使用时钟防抖是非常必要的。
下面程序是一种模拟AudioTrack#write和AudioTrack#getPlaybackHeadPosition 运行的逻辑。
public class AudioClockOutputDevice extends AudioOutput{
private final AudioParams params;
private boolean isResumed = false;
int playState = 0;
int writeFrameSize = 0;
public AudioClockOutputDevice(AudioParams params) {
this.params = params;
playState = AudioPlayState.PLAYSTATE_NEW;
}
@Override
public void start() throws IOException {
isResumed = true;
playState = AudioPlayState.PLAYSTATE_PLAYING;
}
@Override
public void stop() throws IOException {
isResumed = false;
playState = AudioPlayState.PLAYSTATE_STOPPED;
}
@Override
public void resume() throws IOException {
isResumed = true;
playState = AudioPlayState.PLAYSTATE_PLAYING;
}
@Override
public void pause() throws IOException {
isResumed = false;
playState = AudioPlayState.PLAYSTATE_PAUSED;
}
@Override
public void flush() throws IOException {
}
@Override
public void release() throws IOException {
playState = AudioPlayState.PLAYSTATE_STOPPED;
}
@Override
public int write(AudioFrame audioFrame) throws IOException {
int toTimeMillis = AudioUtils.byteSizeToTimeMillis(audioFrame.size, (int) this.params.sampleRate, this.params.channelCount, this.params.bitDepth);
int totalWrittenFrameSize = writeFrameSize + audioFrame.size;
if (toTimeMillis > 0) {
long targetTimeMillis = SystemClock.uptimeMillis() + toTimeMillis;
int bytePerMilliseconds = audioFrame.size / toTimeMillis;
final long STEP = 10;
while (targetTimeMillis >= (SystemClock.uptimeMillis() + STEP)) {
try {
TimeUnit.MILLISECONDS.sleep(STEP);
} catch (Throwable e) {
e.printStackTrace();
}
int result = (int) (writeFrameSize + STEP * bytePerMilliseconds);
if(result > totalWrittenFrameSize){
result = totalWrittenFrameSize;
}
writeFrameSize = result;
}
}
writeFrameSize = totalWrittenFrameSize;
return audioFrame.size;
}
@Override
public void setVolume(float v) throws IOException {
}
@Override
public void setMicVolume(float v) throws IOException {
}
@Override
public int getPlaybackHeadPosition() throws IOException {
return AudioUtils.byteSizeToSamplePosition(writeFrameSize,this.params.channelCount,this.params.bitDepth);
}
@Override
public int getPlaybackBufferSize() throws IOException {
return 4096;
}
@Override
public int getAudioSessionId() throws IOException {
return 0;
}
@Override
public int getPlayState() throws IOException {
return playState;
}
}
这里只是简单的模拟,真正的计算逻辑相对而言更复杂一些,最终目的都是要保证1秒内消费掉 sampleRate *channelCount * bitDepth 的数据量。说到这里,那么如何解决AudioTrack 时间抖动的的缺陷呢 ?
一种可行的方法就是检测抖动,达到一定的阈值时不在调用getPlayHeadPosition方法,而是通过自定义的时钟去计算进度,只在pause、play、resume时调用,当然,还要在getPlayHeadPosition返回值大于0后放弃调用此方法,否则延迟(Latecy)可能导致新的不同步问题。