前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Android ExoPlayer 音画同步代码分析

Android ExoPlayer 音画同步代码分析

作者头像
QQ音乐技术团队
发布2023-08-16 19:00:51
7740
发布2023-08-16 19:00:51
举报

一、音画同步

1.1 什么是音画同步

音画同步旨在通过时钟参考的方式,将音频、视频、歌词等播放时间对应起来,确保画面和声音同步。音视频播放器开发中,音画同步是一项非常重要的工作,直接影响用户的视听体验。

但音画同步涉及多种方式,由于场景的需要,每种方式有所区别。目前而言,主流的音画同步一般都是以 Audio Master 方式为主,人体对声音的敏感度超过视觉,通常意义上轻微的视频丢帧人脑会依然认为画面流畅,但是对于音频而言,一些丢帧可能反映出明显的停顿或者杂音问题,这也主流播放器是以音频为主进行音画的主要原因之一。

当然,涉及到不同行业的应用,未必一定是固定的方式,有些领域甚至也不需要音画同步(比如有些游戏特效),这些都需要根据场景进行定制开发,一款的好的播放器往往都是长时间打磨出来的。

1.2 音画同步标准

国际电信联盟于 1998 年修订《ITU-R BT.1359-1》,针对电视广播的音画同步标准,该标准至今仍被使用,同时应用范围也扩展到互联网直播领域。

图:TU-R BT.1359-1 音频时延感知标准

用户能接受的偏差:

  • 用户无法感知:-100ms ~ 25ms
  • 用户能识别:–125ms & 45ms
  • 用户接受的偏差最大范围:大于-185ms & 小于 90ms

用户不能接受的偏差

  • 用户不可接受:小于-185ms & 大于 90ms

1.3 音画同步的核心逻辑

主流音画同步以Audio Master 或者独立时钟的方式,音频保持匀速播放,通过音频播放的时间进度控制视频播放的方式。

假定视频同步送显阈值为 syncTime ,异常阈值为unexpectTime,syncTime 必须大于unexpectTime,视频解码帧时间为PTS。

  • -syncTime <= PTS<=syncTime 进行送显
  • PTS < syncTime 丢弃视频帧
  • PTS >= unexpectTime 丢弃适配帧
  • syncTime <= PTS <= unexpectTime 时适当sleep线程,通过这种方式确保适当的送显频率。

二、常见的音同步方式

常见的同步方式

【1】获取音频的播放时间 ,然后将视频的播放位置Seek到音频的播放位置 ,然后再将音频 Seek 到视频的位置。

这种方式本质上画面和视频都会产生卡顿,之所以两次 Seek 的原因是视频的 GOP 不确定性以及关键帧的查找相对音频比较复杂,显然 Seek 视频反而可能达不到预期,需要再次 Seek 音频进行兜底处理。

优点:

  • 实现简单,调用seek方法即可

缺点:

  • 体验很差,视频和音频每次都会有明显的卡顿,有的会有长时间的Buffering。

【2】获取音频或者视频的播放时间,让播放快的一方等待直到位置对齐

计算时间差值,快的一方进行等待(或 pause),时间差对齐之后 Resume

优点:

  • 难度一般,只有音频或视频一方需要卡顿一下

缺点:

  • 控制较复杂,需要合理的时间检测粒度去检测和目标位置 。
  • 音频或者视频一方可能存在明显卡顿或者Buffering,如果当前播放位置与目标位置相差很大 ,那么卡顿控制难度相对会提高很多。
  • 需要规避暂停、Buffering等操作。

【3】视频丢帧&视频等待对齐

这种方式一般是常见的主流播放器实现方式,以音频控制时间为准,目前主流的播放器如MediaPlayer、ExoPlayer、iJkPlayer都是这种实现,视频快则走方案【2】的方式,让视频等待,视频慢的时候则让视频丢帧达到同步目的。

优点:

  • 体验较好,音频不会受到任何影响。

缺点:

  • 解码和丢帧时间处理相对复杂
  • 如果视频远快于音频,则视频会出现一直暂停的现象
  • 如果视频远慢于音频可能出现比较明显的丢帧现象。

【4】变速同步

同样以音频时间播放为准,修改视频播放倍速,音频也不会受到任何影响,视频画面微动和较快的播放,对于一般用户而言可能认为这是正常的画面。

但是IOT领域尤其以Rockchip、AllWinner、海思为主的厂商修改底层MediaPlayer,这种方式目前存在一些杂牌机中非常多的问题,比如有的MediaPlayer调用Seek之后视频立马onCompletion,有的是onError之后调用了onCompletion,碎片化非常严重,甚至有的播放器状态码都是私有的。

优点:体验较好,视频快时视频减速,视频慢时视频加速

缺点:需要兼容各种播放器状态,控制逻辑相对复杂,倍速为0时MediaPlayer 会认为调用了pause,倍速大于0会被认为调用了resume。

这里简单说一下变速同步的具体实现

  • S *speed*1 - S*1 = gapOffset (矢量公式,S - 毫秒数,1 - 标准倍速就是1,gapOffset —时间差值,注意这里是毫秒单位)
  • 满足speed >= 0.25情况,尽可能让S <=5000,如果满足不了,S可以大于5000,但speed不能小于0.25 (注意 :speed不能为0,否则MediaPlayer会认为调用了pause)
  • 超过 S 时间之后,恢复原速度
  • 由于MediaPlayer 将速度设置可能作为 resume、pause处理,因此在调用resume和pause之前,恢复到原有的速度

三、ExoPlayer 音画同步分析

回到本文主题,我们来分析一下ExoPlayer的音画同步方式,以便利用这种机制实现一些场景下的多播放器同步。和主流播放器一样,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()。

代码语言:javascript
复制
@Override
@Nullable
public MediaClock getMediaClock() {
  return this;
}

这也证明了存在音频 Renderer 时以音频为准,当然如果没有音频 Renderer时,ExoPlayer 中会使用自然时钟 StandaloneMediaClock。下面是 Render 时钟选择,不存在或者空时钟的Renderer 最终被排除掉,同时不允许存在多个时钟。

com.google.android.exoplayer2.DefaultMediaClock

代码语言:javascript
复制
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中播放进度重要组件,核心逻辑只有两个,一个是调节播放倍速,另一个是获取播放时间。

代码语言:javascript
复制
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 同步时间更新

首先来看同步方法的调用

代码语言:javascript
复制
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 会定时调用,用来驱动播放状态、资源加载和音画同步,方法代码实现较多,这里简单截取一下关键代码。

代码语言:javascript
复制
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 中处理。

代码语言:javascript
复制
 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 具备很强的可扩展性,但是如果通过传参数,是很难将自定义的MediaClock传入进去的。

但是ExoPlayer的开发者也提供了另一种通道 ,那就是通过com.google.android.exoplayer2.DefaultRenderersFactory#createRenderers,我们可以继承DefaultRenderersFactory,复写createRenderers 相关实现,将我们自定义的MediaClock 传入相应的Renderer 中,前面说过,Renderer的基类com.google.android.exoplayer2.BaseRenderer#getMediaClock是支持自定义MediaClock的。

代码语言:javascript
复制
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的缺陷

我们知道AudioTrack本身无法支持Seek,另外由于系统版本原因,AudioTrack对进度的处理不是很完善,我们通常只能使用offset + getPlaybackHeadPosition 做Seek逻辑,然后通过偏移量计算出时间。

然而,在部分设备上通过AudioTack#getPlaybackHeadPosition计算时间存在很多问题,因为存在很多难点,主要是延迟的处理,有的设备上获取的PlaybackHeadPosition存在严重的抖动,因此,使用时钟防抖是非常必要的。

下面程序是一种模拟AudioTrack#write和AudioTrack#getPlaybackHeadPosition 运行的逻辑。

代码语言:javascript
复制
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)可能导致新的不同步问题。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-08-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯音乐技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、音画同步
  • 回到本文主题,我们来分析一下ExoPlayer的音画同步方式,以便利用这种机制实现一些场景下的多播放器同步。和主流播放器一样,ExoPlayer也是以音频为准的同步方式,本文将一步一步解释说明。
  • 四、ExoPlayer 音画同步流程总结
  • 五、如何在业务中使用自定义的MediaClock呢 ?
  • 六、附:关于AudioTrack的缺陷
相关产品与服务
云直播
云直播(Cloud Streaming Services,CSS)为您提供极速、稳定、专业的云端直播处理服务,根据业务的不同直播场景需求,云直播提供了标准直播、快直播、云导播台三种服务,分别针对大规模实时观看、超低延时直播、便捷云端导播的场景,配合腾讯云视立方·直播 SDK,为您提供一站式的音视频直播解决方案。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档