Android ExoPlayer 音画同步代码分析

2023-08-16 19:00:51 浏览数 (3)

一、音画同步

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)可能导致新的不同步问题。

0 人点赞