前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >ExoPlayer 多路流切换

ExoPlayer 多路流切换

作者头像
QQ音乐技术团队
发布2023-09-19 16:09:18
7600
发布2023-09-19 16:09:18
举报

一、背景

国内互联网的发展的过程中,无论是3G、4G还是5G时代,甚至是在可见的未来nG时代,音视频领域一直自始至终参与其中,编解码标准也升级了一版又一版,和音视频的相关应用领域从传统的播放转为互动直播。从另一个方面,伴随中国的互联网发展的每一个过程,从高昂且卡慢流量资费到VIP、SVIP、SSVIP......,再到即将到来的人工智能和Web 3.0 ,必然也少不了音视频。接下来需要考虑你的钱包还能支撑多久,是不是已经准备好了?

音视频应用如腾讯视频、爱奇艺、B站、抖音、快手等大厂都支持码流切换,尤其是B站在码流切换和编解码器这方面玩的也是很溜,这类应用都可以很平滑的切换,当然各大厂的服务后台支持也很完善,HLS、DASH等自适应流支持的很完美。伴随着大环境的问题和市场需求,以及降本增效的影响,需要支持4K/1080P/720P/480P、音质切换、原伴唱切换的应用来说,如果受限于HLS和DASH支持不完善情况,这个难度相对来说还是比较高的。那有没有其他可行的方案呢 ?答案是肯定的,先来看看常见的切码流方案。

二、常见的切码流方案

DASH/HLS 切换:

这种切换相对来说是最友好的方式,可以在不中断播放的情况下,在下一个媒体片段处实现平滑切换,这种方式也是很多应用最常用的方案,无论是开发成本和用户体验也是最优的方案之一,同样对于前端开发人员来说相对友好,很多播放器都是默认支持DASH和HLS码流切换的。这种也是ExoPlayer支持本身支持的方式。

双播放器切换:

这种是一种相对来说比较原始的方案,正在播放的过程中,启动一个新的播放器播,并且将渲染画布alpha设置为透明,同时新的播放器Seek到比当前播放器播放位置更靠前的地方,直到播放位置大概相同时切换画布透明度,终止切一个播放器。相对来说,这种方案实现起来更加复杂,其次很多IOT设备对解码器数量有严格的限制,有的电视机上某种解码器只支持单个实例甚至更少的实例,多一个可能出现要么新的播放器播不起来,要么旧的黑屏或者Crash。不过作为一种原始的方案,并不意味它没有价值,后续的方案基本都是在这种原始的方案上进行了一系列创新。

双解码器切换:

上面说到,双播放器切换会受限于设备解码器数量限制,那是否可以在同一播放器中使用两种解码器?理论上说是可以的,但是却很少有人这样做,第一个原因是,如果要使用2种硬解码器,必然受到硬件制约,因为硬解码器在很多设备上作为DSP芯片的一部分,设备厂商不可能配置2个以上DSP芯片,特别对于IOT设备,尤其是TV,绝大部分成本在屏幕上,上个好点的CPU都很难;第二个原因如果使用软解码器+硬解码器,软解码器性能好的时候没有问题,但是性能差可能卡顿问题会相当多。那是不是没辙了呢?其实也不是,如果能保证不同封装和编码格式以及较低的清晰度的资源,使用不同的硬解码器,也能比较完美地实现,但是这个也会显著增大后台资源管理的难度。

重启播放器切换:

无论双播放器还是双解码器切换显然存在维护成本过高的问题,一种可行的方法,就是重启播放器,并Seek到当前播放点,这个过程相当于重播+Seek。好处是能避免很多问题,但问题也是显而易见的,第一就是就是需要在某些业务中,保留重启前的一些状态,在Seek完成之后再恢复回来。

重启解码器切换:

重启播放器既然可以,重启解码器也是可以的,当然首先要排除Android MediaPlayer这种播放器,不仅不支持码流切换,也不支持音频或者视频Track切换,仅支持字幕Track的切换,另外也不支持时钟同步。这种播放器只能使用重启播放器方式实现码流切换。ExoPlayer作为开源播放器,具备很好的可扩展性,既支持DASH/HLS切换,同时也支持解码器重启方式的切换。

三、ExoPlayer 如何实现多路流切换?

这里我们不说DASH、HLS部分,这部分其实有很多资料,ExoPlayer本身也是支持的。本篇主要分析一下另一种低成本的多路流切换方式——重启解码器实现多路流切换。

3.1 首先了解下多路流切换可以实现的功能。

  • 原伴唱切换
  • 音频品质切换
  • 视频清晰度切换
  • 其他渲染器资源切换

3.2 什么是多路流?

所谓多路流是指播放过程中,存在多个I/O相关的媒体资源。对于常见的Mp4而言,一般来说既包括音频轨道,又包括视频轨道,在解封装之后,一路进入音频渲染器中,一路进入视频渲染器中,属于典型的两路流。而ExoPlayer本质上是支持多路流的,可以同时支持多个Mp4、多个音频文件、多种语言版本的歌词。

3.3 MediaPlayer是否支持多路流

不支持,也没法切换

3.4 ExoPlayer如何将多路流输入到播放器中?

ExoPlayer 支持多种资源读取方式,以MediaSource 的子类开放给开发者使用,我们常用的有ProgressiveMediaSource、DashMediaSource、HlsMediaSource、ClippingMediaSoutce (片段流)、RtspMediaSource、MergingMediaSource等。其中,MergingMediaSource 可以实现多路流合并入到同一个MediaSource中。

代码语言:javascript
复制
 val mediaDataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(this)

        var array = ArrayList<MediaItem>()
        var mediaSources = ArrayList<MediaSource>()

        //加入480资源,包含音频和视频Track
        array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Video@MV@480/data"))
        //加入1080,包含音频和视频Track
        array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Video@MV@1080/data"))

        //再加入2组音频,可以实现音频切换效果,下面的ACC是高品质伴奏
        array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Audio@ACC@Q_1/data"))
        //加入原唱
        array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Audio@ORI@Q_1/data"))

        val mediaSourceFactory = DefaultMediaSourceFactory(mediaDataSourceFactory)

        array.forEach {
            mediaSources.add(mediaSourceFactory.createMediaSource(it))
        }

        var targetMergingMediaSource = MergingMediaSource(mediaSources[0],mediaSources[1],mediaSources[2])

3.5 ExoPlayer 如何实现多路流切换呢?

其实和很多博客中提到的原唱和伴唱切换一样,都是通过DefaultTrackSelector来实现,DefaultTrackSelector作为ExoPlayer Track流筛选的重要组件,可以通过我们设置的既定条件,实现码流切换,下面是一种切换分辨率的方式,我们通过视频尺寸切换视频Track。

代码语言:javascript
复制
public static void switchToOtherVideoTrack(ExoPlayer exoPlayer, @NotNull Tracks tracks, int width, int heigth) {
        if (tracks == null || exoPlayer == null) return;
        ImmutableList<Tracks.Group> groups = tracks.getGroups();
        for (Tracks.Group group :
                groups) {
            if (group == null) continue;
            if (group.getType() != C.TRACK_TYPE_VIDEO) {
                continue; //非视频的不切换
            }
            boolean selected = group.isSelected();
            if (selected) {
                continue; //当前播的不切换
            }
            for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
            //获取一种匹配的视频,理论上group.length一般是1
                Format trackFormat = group.getTrackFormat(trackIndex);
                if (trackFormat.width != width || trackFormat.height != heigth) {
                    continue;
                }

                TrackSelectionParameters trackSelectionParameters = exoPlayer.getTrackSelectionParameters();
                TrackSelectionParameters selectionParameters = trackSelectionParameters
                        .buildUpon()
                        .setOverrideForType(
                                new TrackSelectionOverride(
                                        group.getMediaTrackGroup(), ImmutableList.of(trackIndex) //设置目标媒体资源组和目标Track索引
                                )
                        )
                        .setTrackTypeDisabled(group.getType(), /* disabled= */ false) //保证改Track不被关闭
                        .build();

                exoPlayer.setTrackSelectionParameters(selectionParameters);
                Log.d("SelectTrackHelper", "--->group :" + group + ", selected=" + selected + ",group=" + group.getType() + "," + trackFormat);
            }
        }
    }

使用方式如下

代码语言:javascript
复制
SelectTrackHelper.switchToOtherVideoTrack(simpleExoPlayer,simpleExoPlayer.currentTracks,848,476)

3.6 切换过程

设置目标参数

  • ExoPlayer#setTrackSelectionParameters
  • DefaultTrackSelector#setParameters
  • DefaultTrackSelector#invalidate

通知播放器更新

  • ExoPlayerImplInternal#onTrackSelectionsInvalidated
  • ExoPlayerImplInternl#reselectTracksInternal

核心方法实现,具体逻辑会在下面代码中进行注释。

代码语言:javascript
复制
 private void reselectTracksInternal() throws ExoPlaybackException {
    float playbackSpeed = mediaClock.getPlaybackParameters().speed;
    // Reselect tracks on each period in turn, until the selection changes.
    MediaPeriodHolder periodHolder = queue.getPlayingPeriod();
    MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod();
    boolean selectionsChangedForReadPeriod = true;
    TrackSelectorResult newTrackSelectorResult;

    //查找匹配当前参数的periodHolder
    while (true) {
      if (periodHolder == null || !periodHolder.prepared) {
        // The reselection did not change any prepared periods.
        return;
      }
     //这里是重点,会调用到MappingTrackSelector#selectTracks方法,返回新的结果
      newTrackSelectorResult = periodHolder.selectTracks(playbackSpeed, playbackInfo.timeline);
      if (!newTrackSelectorResult.isEquivalent(periodHolder.getTrackSelectorResult())) {
        // Selected tracks have changed for this period.
       //判断新的结果和当前是不是一样,一样的话重新选择,不一样说明选择成功
        break;
      }
      if (periodHolder == readingPeriodHolder) {
        // The track reselection didn't affect any period that has been read.
        selectionsChangedForReadPeriod = false;
      }
      periodHolder = periodHolder.getNext();
    }

    //重建流数组,如果匹配的解码位置比较靠前的话
    if (selectionsChangedForReadPeriod) {
      // Update streams and rebuffer for the new selection, recreating all streams if reading ahead.
      MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod();
      boolean recreateStreams = queue.removeAfter(playingPeriodHolder);

      boolean[] streamResetFlags = new boolean[renderers.length];
      long periodPositionUs =
          playingPeriodHolder.applyTrackSelection(
              newTrackSelectorResult, playbackInfo.positionUs, recreateStreams, streamResetFlags);
      playbackInfo =
          handlePositionDiscontinuity(
              playbackInfo.periodId, periodPositionUs, playbackInfo.requestedContentPositionUs);
      if (playbackInfo.playbackState != Player.STATE_ENDED
          && periodPositionUs != playbackInfo.positionUs) {
        playbackInfoUpdate.setPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL);
        resetRendererPosition(periodPositionUs);
      }

     //按照Renders顺序,分别对比每个Renderer和每个SampleStream,判断当前正在使用的渲染器Track流是否匹配
     //注意:这里是循环,说明我们切换多路流时可以同时切换音频和视频等轨道

      boolean[] rendererWasEnabledFlags = new boolean[renderers.length];
      for (int i = 0; i < renderers.length; i++) {
        Renderer renderer = renderers[i];
        
        //获取第i轨道正在使用的渲染器,注意这里是可以渲染
        rendererWasEnabledFlags[i] = isRendererEnabled(renderer);  
        //获取第i轨道当前正在使用的SampleStream
        SampleStream sampleStream = playingPeriodHolder.sampleStreams[i];
         //当前渲染器正在使用才会被检测
        if (rendererWasEnabledFlags[i]) { 
          
          if (sampleStream != renderer.getStream()) {
            // We need to disable the renderer.
           //如果当前渲染器的码流和目标码流不匹配,则关闭当前渲染器

            disableRenderer(renderer); 
          } else if (streamResetFlags[i]) {
            // The renderer will continue to consume from its current stream, but needs to be reset.
            renderer.resetPosition(rendererPositionUs);
//如果码流匹配,统一同步播放位置
          }
        }
      }
//重新创建被关闭的渲染器
      enableRenderers(rendererWasEnabledFlags);
     
    } else {
//如果还没播放,则直接走启动逻辑
      // Release and re-prepare/buffer periods after the one whose selection changed.
      queue.removeAfter(periodHolder);
      if (periodHolder.prepared) {
        long loadingPeriodPositionUs =
            max(periodHolder.info.startPositionUs, periodHolder.toPeriodTime(rendererPositionUs));
        periodHolder.applyTrackSelection(newTrackSelectorResult, loadingPeriodPositionUs, false);
      }
    }
    handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ true);
    if (playbackInfo.playbackState != Player.STATE_ENDED) {
      //这里会通过开始时间,查询SeekPoint,设置采样队列时间界值
      maybeContinueLoading();
      updatePlaybackPositions();
      handler.sendEmptyMessage(MSG_DO_SOME_WORK);
    }
  }

3.7 效果评价

整个过程完全在ExoPlayer内部实现,正常网速切换速度也是很快的,当然相比HLS、DASH方式,整个切换过程有还是有明显的轻微的卡顿,不过对于人力本就不富裕的小团队来说,这个显然易见的方便。

四、对齐

4.1 对齐流程

本文所说的对齐和DASH、HLS有本质的区别,不存在切片,但是仍然要解决对齐问题,在ExoPlayer中对齐的过程中并没有直接去调用seek方法对齐,而是通过SeekPoint + 音画同步实现了对齐逻辑,具体对齐步骤如下:

  • 重置并统一所有渲染器的播放时间
  • 利用起播时解析的Track信息,重新注册新的解码器
  • 查找最接近且小于播放时间的SeekPoint ,这个播放点是一个GOP的开始位置,也是IDR帧的位置(IDR帧是I帧的一种);一般来说Mp4 文件头部有moov信息,从采样表(sample table)中可以查找出关键帧和关键帧所映射的文件位置信息,采样表会在起播阶段完成解析。
  • 查找出位置后从SeekPoint 位置处加载媒体资源。
代码语言:javascript
复制
loadable.setLoadPosition(
    checkNotNull(seekMap).getSeekPoints(pendingResetPositionUs).first.position,
    pendingResetPositionUs);
  • 设置所有采样队列的开始时间界值,解码出的inputBuffer如果pts小于这个时间的,一律加上BUFFER_FLAG_DECODE_ONLY标记,后续一旦带有这个标记的buffer被解码,如果使用的是SimpleDecoder解码,也会与之相对应的outputBuffer也加上这个标记,一律不予送显(渲染到Surface),直接跳帧处理。

下面代码表示重置所有采样队列的开始时间

代码语言:javascript
复制
 for (SampleQueue sampleQueue : sampleQueues) {
        sampleQueue.setStartTimeUs(pendingResetPositionUs);
    }

下面是对inputBuffer添加标记:

代码语言:javascript
复制
if (buffer.timeUs < startTimeUs) {
//这里对inputBuffer添加标记
  buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); 
}

下面是在VideoRenderer处理时,对带这个标记的InputBuffer解码后的outputBuffer一律跳帧处理。

代码语言:javascript
复制
if (isDecodeOnlyBuffer && !isLastBuffer) {
  skipOutputBuffer(codec, bufferIndex, presentationTimeUs);
  return true;
}
  • 渲染器从数据数据源不断读取、解码、直到outputBuffer时间大于等于统一的播放时间点。
代码语言:javascript
复制
   public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
   
      if (bypassEnabled) {
        TraceUtil.beginSection("bypassRender");
        while (bypassRender(positionUs, elapsedRealtimeUs)) {}
        TraceUtil.endSection();
      } else if (codec != null) {
        long renderStartTimeMs = SystemClock.elapsedRealtime();
        TraceUtil.beginSection("drainAndFeed");
        //消费InputBuffer数据
        while (drainOutputBuffer(positionUs, elapsedRealtimeUs)
            && shouldContinueRendering(renderStartTimeMs)) {}
            //读取InputBuffer数据
        while (feedInputBuffer() && shouldContinueRendering(renderStartTimeMs)) {}
        TraceUtil.endSection();
      } else {
        decoderCounters.skippedInputBufferCount += skipSource(positionUs);
        // We need to read any format changes despite not having a codec so that drmSession can be
        // updated, and so that we have the most recent format should the codec be initialized. We
        // may also reach the end of the stream. Note that readSource will not read a sample into a
        // flags-only buffer.
        readToFlagsOnlyBuffer(/* requireFormat= */ false);
      }
      decoderCounters.ensureUpdated();
   
  }

  • 进入音画同步阶段,因为切换过程中无论是独立MediaClock还是Audio Master MediaClock,本身播放进度在变化,因为这视频可能还需要跳过几帧,被切换的解码器才能正式渲染。
代码语言:javascript
复制
  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;
    }

至此,整个对齐流程分析完成。

4.2 对齐结果补充

4.2.1 音频和视频对齐共同点:

  • 音频和视频对齐时各自的渲染器都可能会有轻微的跳帧现象,当然这些调整和卡顿感也和IO速度、CPU负载网速也有一定的关系,磁盘、CPU运行效率越高,自然感知程度也会愈加自然减弱。

4.2.2 音频和视频对齐不同点:

  • 相对来说,音频对齐要简单的多,音频解码后的数据是有规律地线性排列,在保证播放时间的准确的基础上,保证声音通道数、位深排列顺序正常就行(比如对齐之后,不能将左声道变为右声道),不需要考虑参考帧的问题,总体而言几乎没有卡顿感,甚至也不需要跳帧。
  • 对齐过程中,ExoPlayer只要存在音频渲染器,那么音画同步的时间以音频为准。
  • 对齐过程中,如果缺少音频,那么音画同步以独立时钟为主。
  • 独立时钟相比音频时钟而言,由于线程的执行速度要慢且时间不可静止的问题,视频画面可能需要跳过很多帧,甚至会卡帧。
  • 对于视频渲染器,ExoPlayer为了避免黑屏,内部会强制渲染首帧和部分关键帧。

五、总结

ExoPlayer 具备完善的多路流切换,高可扩展性,可以实现MediaClock扩展、Renderer裁剪、多路流切换、自定义解封装器,也方便很多人学习音视频知识。最后,本篇知识点总结如下:

  • 利用MergingMediaSource可以实现多路流
  • 利用DefaultTrackSelector可实现码流、原伴唱、音频品质切换
  • 开发专业音视频应用,尽量不要使用MediaPlayer。
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-09-18 12:00,如有侵权请联系 cloudcommunity@tencent.com 删除

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
云直播
云直播(Cloud Streaming Services,CSS)为您提供极速、稳定、专业的云端直播处理服务,根据业务的不同直播场景需求,云直播提供了标准直播、快直播、云导播台三种服务,分别针对大规模实时观看、超低延时直播、便捷云端导播的场景,配合腾讯云视立方·直播 SDK,为您提供一站式的音视频直播解决方案。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档