【开发者的减法日常】使用声网 [跨频道媒体流转发] 实现 [语音房 - 单人 - 跨房PK] 功能 1

我正在参加「声网 RTE 开发者社区」征文活动 https://www.shengwang.cn/cn/community/discussion/43/25573


开篇说明

本篇帖子只是精炼讲解了, 使用 声网 [跨频道媒体流转发] 实现 [语音房 - 单人 - 跨房PK] 功能,
没有完整讲解实现语音房各种功能的详细设计, 语音房完整解决方案, 我稍后会单开篇幅进行讲解.
另外, 本篇帖子, 只是 “抛砖引玉”, 我会给出相关声网文档的地址.
注意 : 本篇文章中提到的 “主播” “嘉宾” “观众” , 不是声网SDK中定义的 [主播 / 听众], 是我们自己APP的业务名词.注意 : 如果要使用[频道转发功能], 需要找声网开通权限 (流媒体转发权限).


相关文档链接

1] 官网文档,api时序图

https://docs.agora.io/cn/live-streaming-premium-legacy/media_relay_android?platform=Android

2] 3.x SDK跨频道连麦场景说明(场景优化方案)

https://docs.qq.com/doc/DVmZyUFJDR1ZoZ0dx

3] 错误码处理、兜底方案

https://docs.qq.com/doc/DWXh2em9PeFpZUW1r

4] 3.x对应demo(legacy分支)

Android:https://github.com/AgoraIO/API-Examples/blob/legacy/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/HostAcrossChannel.java

iOS:https://github.com/AgoraIO/API-Examples/blob/legacy/iOS/APIExample/Examples/Advanced/MediaChannelRelay/MediaChannelRelay.swift


我开发中, 使用的SDK版本

声网 : api 'io.agora.rtc:voice-sdk:3.7.2.1’
云信 : api 'com.netease.nimlib:basesdk:9.6.3’ / api 'com.netease.nimlib:chatroom:9.6.3


业务场景说明

A / B 两个 "个播语音房", 就是只有 1 位主播的语音房, 一般配有8个嘉宾位.
1] A / B 房的主播, 可以互相邀请对方, 进行跨房PK;
2] PK建立之后, 会同时显示 A/B房的主播头像, 以及PK相关业务信息;
3] A / B房的主播, 可以 [禁麦 / 开麦] 对方主播的麦克, 一旦关闭后, 本房间所有人, 将听不见对方主播的声音.
4] 特殊场景 : A房主播被B房主播 “禁麦”了, 此时A房主播杀死客户端, 然后重新启动, 快速回到自己的房间时, “禁麦” 状态依然保持, 不能出现 “漏音” .
5] 特殊场景 : 要有异常情况的规避处理策略.


使用到的,声网核心API

1] startChannelMediaRelay : 该方法可用于实现跨频道媒体流转发。
2] stopChannelMediaRelay : 停止跨频道媒体流转发。
3] updateChannelMediaRelay : 更新媒体流转发的频道。
4] resumeAllChannelMediaRelay : 恢复向所有目标频道转发媒体流。
5] pauseAllChannelMediaRelay : 暂停向所有目标频道转发媒体流。
6] onChannelMediaRelayStateChanged : 跨频道媒体流转发状态发生改变回调。
7] onChannelMediaRelayEvent : 跨频道媒体流转发事件回调。


实现方案

我们APP是使用 [声网] 和 [云信IM] 结合来实现的 “语音房” 功能;进房流程串行如下 (如果有一个环节出现失败, 就认为是 进房失败了) :
1] 请求目标房间 [房间信息] (这是我们自己实现的接口);
2] 请求当前登录用户的 [声网相关信息] (这是我们自己实现的接口);
3] 请求加入声网频道(这里调用 声网SDK方法 joinChannel )
4] 请求加入云信聊天室(这里调用 云信SDK方法 enterChatRoom).
串行4步都成功之后, 才算是进房成功, 用户才可以进行游戏了.


使用声网 [跨频道媒体流转发] 实现[语音房 - 单人 - 跨房PK] 功能的核心逻辑 :

注意 : 下面代码, 都是在登录用户是 [主播] 时, 所进行的处理.

1] 进房流程 : 在进房成功之后调用如下代码


//TODO :对[单人-跨房PK]的处理
DebugLog.e(TAG_SINGLE_DIFFERENT_ROOM_PK,"进房流程-> onEnterRoomSuccess -->进房成功.");
if(isSingleDifferentRoomPKStarting()) {
   //当前房间已经开启了[跨房PK]
   
    //TODO :处理声网频道转发
   if(isAnchor()) {
       //当前登录用户是主播
       if(getSingleDifferentRoomPKInfo().getOtherControlMeMicStatusEnum() == VoiceChatRoomSeatMicStatus.ON) {
           //对方主播,没有对我方主播禁麦



           //TODO :开始执行[声网-频道转发]流程
           startChannelMediaRelayForSDRPK("进房成功");
       }
    }
}


设计细节说明 :

在进房流程中, 如果对方主播已经对我方主播 “禁麦” 了, 此时不能采用如下如下方案来实现 “禁麦” :
先调用声网频道转发API startChannelMediaRelay, 在转发成功之后立刻调用声网 API pauseAllChannelMediaRelay,
如果这样实现的话, 会出现 “漏音”. 就是说, B房间会听见一点A房间主播的声音.
也不能串行调用 startChannelMediaRelay 和 pauseAllChannelMediaRelay, 因为在频道转发成功之前, 调用 pauseAllChannelMediaRelay会直接报错.


正确的解决方案是(放漏音) :

进房成功之后, 如果对方主播已经对我方主播 “禁麦” 了, 那么我方主播就不进行 [频道转发] 请求,

等到收到对方主播, 对我方主播 “开麦” 的 信令消息时, 再进行[频道转发] 请求.


2] 收到对方主播对我方主播 [禁麦 / 开麦] 信令消息时的处理逻辑

/**
*标识 本次 声网频道转发 是否成功,如果停止转发之后,要把这个标记位复位
*/
volatile booleanisChannelMediaRelaySuccess=false;

/**
*TODO :声网给出的,复位isNeedDeepEvadeStrategy的时机
*<p>
*(1)主播调用joinChannel()后,重置isNeedDeepEvadeStrategy为false;
*(2)遇到1、3、4、5、6、7、9,判断标志位(isNeedDeepEvadeStrategy)状态,
*     如果标志位状态是false,则执行startChannelMediaRelay(),并把标志位置为true;
*     如果标志位状态已经是true,则执行leaveChannel()动作,则进入到leaveChannel()的深度退避策略;
*(3)监听回调onChannelMediaRelayStateChanged,如果收到state为RELAY_STATE_RUNNING(2),则把状态置为false.
*/
// [深度规避策略]标记位
volatile booleanisNeedDeepEvadeStrategy=false;

//是否正在进行[声网-频道转发]请求中
volatile booleanisChannelMediaRelaying=false;

//是否暂停了[声网-频道转发]
volatile booleanisPauseAllChannelMediaRelay=false;


设计细节说明 :

我这里设计了一些 “标记位” 变量, 用于记录客户端的一些状态.
因为声网的 IRtcEngineEventHandler 回调, 都是在 “子线程” 中,
我上面的那些变量, 会同时在 主线程 和 子线程 使用, 所以增加了 volatile 修饰.


//对方主播 对 我方主播 麦位禁麦状态
if(event.getData().getOtherControlMeMicStatusCode() != getSingleDifferentRoomPKInfo().getOtherControlMeMicStatusCode()) {
   //TODO : [对方主播 对 我方主播 麦位禁麦状态]发生变化了...
   getSingleDifferentRoomPKInfo().setOtherControlMeMicStatusCode(event.getData().getOtherControlMeMicStatusCode());
   
    if(isAnchor()) {
       //TODO :下面是主播需要做的处理
       if(getSingleDifferentRoomPKInfo().getOtherControlMeMicStatusEnum() == VoiceChatRoomSeatMicStatus.ON) {
           //TODO :对方 对 我方 解除禁麦---------------------------------------------------
           if(!isChannelMediaRelaySuccess) {
               //我方还未建立频道转发成功
               if(!isNeedDeepEvadeStrategy&& !isChannelMediaRelaying) {
                   //我方没有进入[深度规避策略]并且也没有[正在请求频道转发中...] ,那么 我们重新发起[频道转发请求]
                   startChannelMediaRelayForSDRPK("对方对我方解除了禁麦(收到WebSocket消息<--> room/link/info)");
               }
            }else{
               //我方已经建立频道转发成功,此时不管之前是否pause,都重新resume一下
               resumeAllChannelMediaRelay("对方对我方解除了禁麦(收到WebSocket消息<--> room/link/info)");
           }
        }else{
           //TODO :对方 对 我方 开启禁麦---------------------------------------------------
           if(!isChannelMediaRelaySuccess) {
               //我方还未建立频道转发成功,此时为了防止漏音,直接调用stop ,重置各种标记位
               stopChannelMediaRelayForSDRPK("对方对我方开启了禁麦(收到WebSocket消息<--> room/link/info)");
           }else{
               //我方已经建立频道转发成功,此时可以调用pause
               pauseAllChannelMediaRelay("对方对我方开启了禁麦(收到WebSocket消息<--> room/link/info)");
           }
        }
    }
}


设计细节说明 :

1] 收到对方禁麦我方的信令消息时, 如果我方本地已经建立频道转发成功了, 那么直接调用声网API pauseAllChannelMediaRelay, 而不是调用stopChannelMediaRelay 停止整个转发流程,这样的好处是, 如果对方对我方 “开麦” 时, 我方不需要重新建立频道转发请求, 直接调用 resumeAllChannelMediaRelay 回复频道转发即可;
2] 收到对方禁麦我方的信令消息时, 如果我方本地还没有建立频道转发成功, 那么直接调用 声网API stopChannelMediaRelay , 停止转发申请, 注意 : 一定要复位本地 “标记” 变量.
可以注意我封装的方法 stopChannelMediaRelayForSDRPK 中的实现.

/**
*开始发起声网频道转发请求
*
*@returntrue :发起成功(只是各种参数正常,最终调用了startChannelMediaRelay()而已,不是建立转发成功),
* false :发起失败(有无效参数,所有都没有调用startChannelMediaRelay())
*/
booleanstartChannelMediaRelayForSDRPK(String callScene) {
    DebugLog.e(TAG_SINGLE_DIFFERENT_ROOM_PK,"startChannelMediaRelayForSDRPK --> callScene = "+ callScene);

   //重置一些标记位
   isChannelMediaRelaying=false;
   isPauseAllChannelMediaRelay=false;
   isChannelMediaRelaySuccess=false;

   String failedReason ="失败原因";

    do{
       if(!isInVoiceRoom()) {
            failedReason ="当前登录用户已经不在语音房内玩耍";
            break;
       }

       //TODO :调用startChannelMediaRelay之前,一定要调用stopChannelMediaRelay
       intresultCode = getRtcEngine().stopChannelMediaRelay();
        if(resultCode <0) {
           //方法调用失败
       }

       if(!isAnchor()) {
            failedReason ="当前登录用户不是主播";
            break;
       }

       if(!isSingleDifferentRoomPKStarting()) {
            failedReason ="PK还未开启(status == 0)";
            break;
       }

       //本房间频道名
       finalString srcChannelName = getSingleDifferentRoomPKInfo().getMeAgoraChannelMediaRelayInfo().getAgoraChannelName();
       //待在本房间的机器人Token
       finalString srcRobotToken = getSingleDifferentRoomPKInfo().getMeAgoraChannelMediaRelayInfo().getAgoraToken();
       //待在本房间的机器人Uid (这个必须是0)
       final intsrcRobotUid =0;
       //对方房间频道名
       finalString descChannelName = getSingleDifferentRoomPKInfo().getOtherAgoraChannelMediaRelayInfo().getAgoraChannelName();
       //加入对方房间的机器人Token
       finalString descRobotToken = getSingleDifferentRoomPKInfo().getOtherAgoraChannelMediaRelayInfo().getAgoraToken();
       //加入对方房间的机器人Uid (这个必须是当前登录用户的声网UID)
       final intdescRobotUid = getRoomDetailInfo().getAgoraUID();

        if(TextUtils.isEmpty(srcChannelName)) {
            failedReason ="声网参数无效-本房间频道名-为空";
            break;
       }
       if(TextUtils.isEmpty(srcRobotToken)) {
            failedReason ="声网参数无效-待在本房间的机器人Token-为空";
            break;
       }
       if(TextUtils.isEmpty(descChannelName)) {
            failedReason ="声网参数无效-对方房间频道名-为空";
            break;
       }
       if(TextUtils.isEmpty(descRobotToken)) {
            failedReason ="声网参数无效-加入对方房间的机器人Token-为空";
            break;
       }
       if(descRobotUid <=0) {
            failedReason ="声网参数无效-加入对方房间的机器人Uid-为空";
            break;
       }

       finalChannelMediaRelayConfiguration config =newChannelMediaRelayConfiguration();
       ChannelMediaInfo srcChannelInfo =newChannelMediaInfo(srcChannelName,srcRobotToken,srcRobotUid);
       config.setSrcChannelInfo(srcChannelInfo);
       ChannelMediaInfo destChannelInfo =newChannelMediaInfo(descChannelName,descRobotToken,descRobotUid);
       config.setDestChannelInfo(descChannelName,destChannelInfo);
       resultCode = getRtcEngine().startChannelMediaRelay(config);
        if(resultCode <0) {
           //方法调用失败
       }

       //更改标记位
       isChannelMediaRelaying=true;

        return true;
   }while(false);

   //TODO :发起失败
   DebugLog.e(TAG_SINGLE_DIFFERENT_ROOM_PK,"startChannelMediaRelayForSDRPK --> callScene = "+ callScene +",发起频道转发失败,原因= "+ failedReason);
    return false;
}

设计细节说明 :

1] 良好的编程习惯, 要对相关参数做 [防御编程], 如果参数不合规, 就不无脑进行到下一步调用;
2] 进驻本房间的转发机器人 和 进驻对方房间的转发机器人 相关的声网参数.
3] 注意 : 在 声网3.x SDK中, srcRobotUid 必须设置成 0 , 4.x SDK中的设置, 要跟声网技术小哥做沟通(😆我们用的是3.x, 所以这里暂时不清楚4.x的设置)
4] 注意 : 声网机器人token必须由服务器生成, 并且机器人token目前客户端不支持动态刷新, 所以服务器生成此token时, 最好设计好有效时间(最长可以设置成 24小时)
5] 记得在调用 startChannelMediaRelay 之前, 一定要先调用 stopChannelMediaRelay


/**
*停止声网频道转发
*<p>
*TODO :在调用stopChannelMediaRelay()的同时,也会重置所有和频道转发有关的标记位.
*/
voidstopChannelMediaRelayForSDRPK(String callScene) {
    DebugLog.e(TAG_SINGLE_DIFFERENT_ROOM_PK,"stopChannelMediaRelayForSDRPK --> callScene = "+ callScene);

   isChannelMediaRelaying=false;
   isPauseAllChannelMediaRelay=false;
   isChannelMediaRelaySuccess=false;
   isNeedDeepEvadeStrategy=false;

    intresultCode = getRtcEngine().stopChannelMediaRelay();
    if(resultCode <0) {
       //方法调用失败
   }
}


设计细节说明 :

1] 注意调用 stop方法时, 要及时复位 “标记” 变量;

voidresumeAllChannelMediaRelay(String callScene) {
    DebugLog.e(TAG_SINGLE_DIFFERENT_ROOM_PK,"resumeAllChannelMediaRelay --> callScene = "+ callScene);
   isPauseAllChannelMediaRelay=false;
   getRtcEngine().resumeAllChannelMediaRelay();
}

voidpauseAllChannelMediaRelay(String callScene) {
    DebugLog.e(TAG_SINGLE_DIFFERENT_ROOM_PK,"pauseAllChannelMediaRelay --> callScene = "+ callScene);
   isPauseAllChannelMediaRelay=true;
   getRtcEngine().pauseAllChannelMediaRelay();
}


声网 IRtcEngineEventHandler 回调中的代码实现

@Override
public voidonChannelMediaRelayStateChanged(intstate, intcode) {

   if(state ==RELAY_STATE_FAILURE) {// (state == 3)
       /**
         *如果error=1、3、4、5、6、7、9中的任何一种,则触发常规重试策略,重新调用api来尝试重新发起一次跨频道连麦请求,
        *如果尝试重新发起后,仍然无法成功,则跳转到3.2深度规避策略;
        */
       if(code ==RELAY_ERROR_SERVER_ERROR_RESPONSE                 // 1
               || code ==RELAY_ERROR_NO_RESOURCE_AVAILABLE          // 3
               || code ==RELAY_ERROR_FAILED_JOIN_SRC                // 4
               || code ==RELAY_ERROR_FAILED_JOIN_DEST               // 5
               || code ==RELAY_ERROR_FAILED_PACKET_RECEIVED_FROM_SRC// 6
               || code ==RELAY_ERROR_FAILED_PACKET_SENT_TO_DEST     // 7
               || code ==RELAY_ERROR_INTERNAL_ERROR                 // 9
       ) {
           if(vcrSdk.isNeedDeepEvadeStrategy) {
               //TODO :开始执行[深度规避策略]
               runDeepEvadeStrategy(code);
           }else{
               //TODO :记录[深度规避策略]标记位
               vcrSdk.isNeedDeepEvadeStrategy=true;

               //TODO :执行[常规重试策略]
               runNormalRetryStrategy(code);
           }
        }
       /**
         *如果error=2或8,,大概率是弱网环境下,跨频道连麦功能连不上服务器导致的,建议调用leavechannel在重进转转推一下,或参考3.2深度规避策略;
        */
       else if(code ==RELAY_ERROR_SERVER_NO_RESPONSE               // 2
               || code ==RELAY_ERROR_SERVER_CONNECTION_LOST         // 8
       ) {
           //TODO :开始执行[深度规避策略]
           runDeepEvadeStrategy(code);
       }
       /**
         *如果error=10或11,则说明跨频道连麦的token出现了异常。需要重新发起跨频道连麦请求。
        */
       else if(code ==RELAY_ERROR_SRC_TOKEN_EXPIRED                // 10
               || code ==RELAY_ERROR_DEST_TOKEN_EXPIRED             // 11
       ) {
           //TODO :执行[常规重试策略]
           runNormalRetryStrategy(code);
       }
    }else if(state ==RELAY_STATE_RUNNING) {// (state == 2)
        //TODO :
       // 问:客户端如何判断[本次频道转发成功] ?
       // 答:当收到onChannelMediaRelayStateChanged(state == RELAY_STATE_RUNNING)时,就认为是转发成功了;失败的话,是不会返回这个回调的.
       vcrSdk.isChannelMediaRelaySuccess=true;

       //复位一些标记
       vcrSdk.isNeedDeepEvadeStrategy=false;
       vcrSdk.isChannelMediaRelaying=false;
       vcrSdk.isPauseAllChannelMediaRelay=false;

       DebugLog.e(vcrSdk.TAG_SINGLE_DIFFERENT_ROOM_PK,"---------------------频道转发成功-------------------");
   }
}


设计细节说明 :

1]客户端如何判断 [本次转发成功] : 在 onChannelMediaRelayStateChanged 收到 (state == RELAY_STATE_RUNNING) 时, 既可以判定本次转发成功.

//执行[常规重试策略]
private voidrunNormalRetryStrategy(final intcode) {
   handler.post(newRunnable() {
       @Override
       public voidrun() {
            DebugLog.e(vcrSdk.TAG_SINGLE_DIFFERENT_ROOM_PK,"runNormalRetryStrategy -->执行[常规重试策略]");

           vcrSdk.startChannelMediaRelayForSDRPK("请求频道转发失败,常规重试策略,本次声网错误代码= "+code+"");
       }
    });
}


//执行[深度规避策略]
private voidrunDeepEvadeStrategy(final intcode) {
   //TODO :目前执行到[深度规避策略]时,先不做任何处理,只做打点上报
   handler.post(newRunnable() {
       @Override
       public voidrun() {
            DebugLog.e(vcrSdk.TAG_SINGLE_DIFFERENT_ROOM_PK,"runDeepEvadeStrategy -->执行[深度规避策略]");
       }
    });
}


设计细节说明 :

1] 这里的兜底重试机制, 是参考声网文档实现的 (https://docs.qq.com/doc/DWXh2em9PeFpZUW1r),
对于执行到 [深度规避策略] 时, 根据声网的建议, 最好来一次 “大退重进”,
就是说, 先调用 stopChannelMediaRelay 停止转发请求, 然后调用 leaveChannel 退出频道, 然后调用 joinChannel 重新进入频道, 最后再重新发起转发请求.
因为我们服务器会监听声网用户退出频道的事件, 做一些业务处理, 所里暂时客户端这里不对 [深度规避策略] 做实际处理, 只在出现这种情况时, 做打点上报, 后续根据出现这种情况的频率再做优化处理.

2] 在离开房间时, 记得要先调用 startChannelMediaRelayForSDRPK() 停止频道转发, 然后再调用 leaveChannel() 离开频道.

3] 如何在本房间实现PK方主播的 说话时 “水波纹” 效果?

1] 依旧使用 用户音量提示回调 onAudioVolumeIndication(final AudioVolumeInfo[] speakerArray, final int totalVolume) 远端回调最大声音共三个,跨房转发的主播依旧会回调这个方法。
我们首先已经知道PK方主播的声网UID, 然后在收到onAudioVolumeIndication回调时, 循环AudioVolumeInfo[] speakerArray, 如果找到跟PK方主播声网UID相同的, 及判定是PK方主播在说话.


跟声网同学QA整理

请关注 [【开发者的减法日常】使用声网 [跨频道媒体流转发] 实现 [语音房 - 单人 - 跨房PK] 功能 2]

https://www.shengwang.cn/cn/community/blog/25584

推荐阅读
相关专栏
开发者实践
182 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。