移动直播连麦解决方案

场景

      最开始观看直播是主播在那边又唱又跳,而你想与女神互动,只能简单的刷刷弹幕送送礼物。直到有了连麦,你才能用音视频的方式和主播互动,让女神看到你的画面,一起诉说风花雪月。

      其实连麦简单说就是直播场景下,观众需要与主播音视频互动的功能。其中有三个角色,直播间里最开始的主播我们称为大主播,请求连麦的称为小主播,然后就是第三方观众。大致流程是,大主播端推一路自己的画面,拉一路小主播的画面;小主播端推一路自己的画面,拉一路大主播的画面;第三方观众拉一路大小主播混流后的画面。

      主要流程就是这样简单,但是实际过程中还需要考虑一些细节,比如请求和接受连麦通信怎么做、大小主播怎样实现低延时交流、连麦前后不同流状态的处理。考虑到这些因素,腾讯云针对这部分逻辑进行了封装,提供了一套前后端完整的解决方案(MLVBLiveRoom)。

名词解释

      低延时流/加速流(ACC):区别于普通的直播流走的是CDN,延迟大概3秒左右;低延时流采用超级节点内网专线构建的超级链路将大小主播之间地域的传输延迟降至最低,延迟可以控制在500ms以内。生成低延时流地址的方法和生成推流地址类似,通过rtmp拉流地址后面加上推流防盗链key计算的防盗链就可以了。

      回音消除(AEC):对于大主播和小主播端,由于需要一边采集本地音视频数据推流出去,一边播放对方的音视频,这样就可能重复采集音频数据,导致回音现象,所以连麦场景需要打开回音消除。

      云端混流:对于第三方观众,如果想同时看到大主播和小主播的画面,最简单的办法就是拉两路流。但是这里的缺点是这两条流的延时不好控制,以及多拉一条流产生多一路流的费用。所以通过在云端把这两条流混成一路流分发,就是云端混流。

整体流程

  1. 主播 A 正常推流直播,直播码为 streamA
  2. 主播 B 正常推流直播,直播码为 streamB
  3. 主播 B 向主播 A 请求连麦,并带上自己的推流地址 streamB
  4. 主播 A 如果同意连麦,向主播 B 回应一下,于此同时,主播 A 拉取 streamB 的流进行播放(播放器设置为 PLAY_TYPE_LIVE_RTMP_ACC)
  5. 主播 B 拉取 streamA 的流进行播放(播放器设置为 PLAY_TYPE_LIVE_RTMP_ACC)
  6. 主播 A (或主播B) 根据需要通知服务器做一下混流,这样 CDN 的观众就能看到大小视频叠加的画面了。

代码实现

以下代码以iOS为例,其中涉及的原理和接口名在Android端也基本一致。iOS和Android相关具体代码可直接下载TXLiteAVSDK,参考压缩包TXLiteAVDemo里面的MLVBLiveRoom封装类。

步骤一:主播 A 推流

主播 A 从您的业务后台获取推流防盗链地址 streamA,之后可以用 TXLivePusher 进行推流。

TXLivePushConfig* _config = [[TXLivePushConfig alloc] init];
_config.audioSampleRate = AUDIO_SAMPLE_RATE_48000;           //音频采样率默认就是48K,不要设为其它值 
_txLivePush = [[TXLivePush alloc] initWithConfig: _config];
_txLivePush.delegate = _pushDelegate;
[_txLivePush setVideoQuality:VIDEO_QUALITY_HIGH_DEFINITION]; //非连麦模式:高清
[_txLivePush startPreview:previewView];
[_txLivePush startPush:rtmpUrl];

在连麦开始前,推流的 setVideoQuality 要切换为 VIDEO_QUALITY_LINKMIC_MAIN_PUBLISHER。该模式中会开启回声抑制(AEC),避免连麦中有回音。

setVideoQuality 支持推流中直接改变场景模式。

步骤二:主播 B 推流

主播 B 从您的业务后台获取推流防盗链地址 streamB,之后可以用 TXLivePusher 进行推流。

TXLivePushConfig* _config = [[TXLivePushConfig alloc] init];
_config.audioSampleRate = AUDIO_SAMPLE_RATE_48000;                 //音频采样率默认就是48K,不要设为其它值 
_txLivePush = [[TXLivePush alloc] initWithConfig: _config];
_txLivePush.delegate = _pushDelegate;
[_txLivePush setVideoQuality:VIDEO_QUALITY_LINKMIC_SUB_PUBLISHER]; //连麦模式:小主播
[_txLivePush startPreview:previewView];
[_txLivePush startPush:rtmpUrl];

在连麦开始前,推流的 setVideoQuality 要切换为 VIDEO_QUALITY_LINKMIC_SUB_PUBLISHER。该模式中会开启回声抑制(AEC),避免连麦中有回音。

SUB_PUBLISHER 的分辨率和码率都要低于 MAIN_PUBLISHER,毕竟那么小的窗口,用很高的分辨率是浪费的。

步骤三:连麦请求和响应

主播 B 向主播 A 发起连麦请求,请求可以由您的业务服务器中转,也可以使用腾讯云的 IM 云通讯解决方案。请求中要带上主播 B 的推流直播码,否则主播 A 无法去播放主播 B 的视频流。下面示例代码就是通过IMSDK发送C2C自定义消息实现信令的传输:

- (void)sendCCCustomMessage:(NSString *)userID data:(NSData *)data {
    TIMCustomElem *elem = [[TIMCustomElem alloc] init];
    [elem setData:data];
    
    TIMMessage *msg = [[TIMMessage alloc] init];
    [msg addElem:elem];
    
    TIMConversation *conversation = [[TIMManager sharedInstance] getConversation:TIM_C2C receiver:userID];
    if (conversation) {
        [conversation sendMessage:msg succ:^{
            NSLog(@"sendCCCustomMessage success");
        } fail:^(int code, NSString *msg) {
            NSLog(@"sendCCCustomMessage failed, data[%@]", data);
        }];
    }
}

SDK Demo源码中使用了腾讯云的 IM 云通讯解决方案实现了连麦请求和响应逻辑,详情参考Demo里面的RoomUtil封装组件。

步骤四:主播 A 播放 streamB

主播 A 如果接受主播 B 的连麦请求,可以进行应答,这样主播 B 就知道连麦请求是否已经被同意了。

主播 A 此时需要使用 TXLivePlayer 播放 streamB 的 低延时 地址,<font color='red'>特别注意</font>,这里不能播放普通的 CDN 观看地址。区别在于前者的延迟一般在 500ms 以内,而 CDN 的延迟一般在 2s 以上,CDN 地址只能给普通观众观看,不能用于主播之间的连麦。

所以,要得到 500ms 左右的低延迟播放效果,需要:

4.1 给播放地址加 防盗链签名

低延时链路使用的是腾讯云核心机房的BGP资源,需要有带防盗链签名的 rtmp-liveplay 地址才能访问,所以主播 A 和 主播 B 都要给播放地址加上防盗链签名(txTime 和 txSecret)才能低延迟播放,如下是一个正确的低延迟播放地址:

rtmp://8888.liveplay.myqcloud.com/live/8888_streamB?bizid=8888&txSecret=xxxxx&txTime=5C2A3CFF

4.2 使用 TXLivePlayer 的 PLAY_TYPE_LIVE_RTMP_ACC 播放模式

LIVE_RTMP_ACC 的模式会开启播放器自带的精准延迟控制模块,该模式下的缓冲处理和音画同步技术相比于普通直播要求高很多。

NSString * playUrl = @"rtmp://8888.liveplay.myqcloud.com/live/8888_test?bizid=8888&txSecret=xxxx&txTime=xxx"; //加速拉流地址必须带防盗链key
TXLivePlayConfig * playConfig = [[TXLivePlayConfig alloc] init];
_livePlayer = [[TXLivePlayer alloc] init];
_livePlayer.deletate = _playDelegate;
[_livePlayer setConfig: playConfig];
[_livePlayer setupVideoWidget:CGRectMake(0, 0, 0, 0) containView: videoView insertIndex:0];
[_livePlayer setRenderMode:RENDER_MODE_FILL_SCREEN];
[_livePlayer startPlay:playUrl type:PLAY_TYPE_LIVE_RTMP_ACC]; //开始播放,type参数必须设置为PLAY_TYPE_LIVE_RTMP_ACC

由于低延时流使用腾讯云核心机房的BGP资源,所以需要购买计费套餐才能使用,如果您拉流报<font color='red'>获取加速拉流地址失败</font>错误,请先检查是否购买套餐包,腾讯云提供了1元套餐包方便开发者体验测试。

1.涉及业务功能:直播连麦(MLVBLiveRoom)功能、视频通话(RTCRoom)功能、低延时播放(RTMP_ACC)功能。

2.涉及终端包括:微信小程序端、Windows端、Web端、iOS端、Android端。

3.目前低延时拉流支持的最高并发数为10路。

步骤五:主播 B 播放 streamA

主播 B 在接到主播 A 同意连麦的请求后,可以开始播放 streamA 的低延时地址,同样需要:

  • 给播放地址加上防盗链签名
  • 使用 TXLivePlayer 播放,并设置为 PLAY_TYPE_LIVE_RTMP_ACC 播放模式,代码调用同上述步骤4.2。

步骤六:云端混流

低延时链路使用的是腾讯云核心机房的BGP资源,如果用于普通观众观看,延迟是挺低的,但是费用也挺高的。所以,普通观众观看还是要使用普通的 CDN 地址。拼接混流参数在SDK Demo里面使用的是下面接口,建议开发者先按照下面示例代码调通再根据自己需求自定义修改:

混流参数是json格式的字符串,用来指定对哪些视频流进行混流操作以及混流的方式,下面举例说明:

{
    "timestamp":int(time.time()),           # UNIX时间戳,即从1970年1月1日(UTC/GMT的午夜)开始所经过的秒数
    "eventId":int(time.time()),             # 混流事件ID,取时间戳即可,后台使用
    "interface":
    {
        "interfaceName":"Mix_StreamV2",	    # 固定取值"Mix_StreamV2"
        "para":
        {
            "app_id": appid,                # 填写直播APPID
            "interface": "mix_streamv2.start_mix_stream_advanced",  # 固定取值"mix_streamv2.start_mix_stream_advanced"
            "mix_stream_session_id" : "3891_zachary1",                # 填大主播的流ID
            "output_stream_id": "3891_zachary1",                      # 填大主播的流ID
            "input_stream_list":
            [
                # 主播1
                {
                    "input_stream_id":"3891_zachary1",    # 填大主播的流ID
                    "layout_params":
                    {   
                        "image_layer": 1,               # 图层标识号
                    }   
                 },
                # 主播2
                 {
                     "input_stream_id":"3891_zachary2",  #填小主播的流ID
                     "layout_params":
                     {
                         "image_layer": 2,
                         "image_width": 160,        # 小主播画面宽度
                         "image_height": 240,       # 小主播画面高度
                         "location_x": 380,         # x偏移:相对于大主播背景画面左上角的横向偏移
                         "location_y": 630          # y偏移:相对于大主播背景画面左上角的纵向偏移
                     }
                 }
            ]
        }
    }
}

详细解释一下混流参数

  • 上面混流参数里,以#开始的内容是python格式的注释;
  • 字段timestamp和eventId都取当前时间(秒)即可;
  • 字段mix_stream_session_id和output_stream_id都填大主播的流ID;
  • 字段input_stream_list是一个数组,包含了需要混流的视频流信息;这个数组里必须包含大主播的视频流,混流后台目前最多支持16路混流;
  • 字段layout_params用于设置视频流排布参数;大主播的画面默认铺满整个屏幕,只需要将字段image_layer填写为1,不需要填写其它字段;image_layer是图层标识号,小主播请按照顺序,依次填写2、3或者4;
  • 字段image_width、 image_height、 location_x、 location_y用来定义小画面相对于大画面的位置;需要注意的是,小画面的上、下、左、右四个位置都不能超过大画面的范围,即:location_x不能小于0,不能超过大画面的宽度;location_y不能小于0,不能超过大画面的高度; location_x + image_width之和不能超过大画面的宽度;location_y + image_height之和不能超过大画面的高度;

CGI返回的是一段json格式的字符串,如下所示

{"code":0, "message":"Success!", "timestamp":1490079362}
  • code: 错误码,0表示成功,其它表示失败
  • message:错误描述信息
  • timestamp:时间戳,取值与混流参数里的timestamp相同

具体使用方案可以参考 云端混流 API

常见问题

纯音频连麦混流

步骤一:调用纯音频推流接口

iOS示例

// 只有在推流启动前设置启动纯音频推流才会生效,推流过程中设置不会生效。
txLivePush.config.enablePureAudioPush = YES;   // true 为启动纯音频推流,而默认值是 false;
[_txLivePublisher setConfig:_config];          // 重新设置 config

NSString* rtmpUrl = @"rtmp://2157.livepush.myqcloud.com/live/xxxxxx";      
[_txLivePush startPush:rtmpUrl];

Android示例

// 只有在推流启动前设置启动纯音频推流才会生效,推流过程中设置不会生效。
mLivePushConfig.enablePureAudioPush(true);   // true 为启动纯音频推流,而默认值是 false;
mLivePusher.setConfig(mLivePushConfig);      // 重新设置 config

String rtmpUrl = "rtmp://2157.livepush.myqcloud.com/live/xxxxxx";
mLivePusher.startPusher(rtmpUrl);

步骤二:拼接混流接口参数修改input_type输入源类型

iOS端示例代码来源于LiveRoom.m文件里面连麦合流参数拼接的接口createLinkMicMergeParams,在原有基础上修改了下面12行和45行,设置了input_type输入源类型为4表示输入源为音频:

// 连麦合流参数
- (NSDictionary*)createLinkMicMergeParams:(NSArray<NSString *> *)playUrlArray {
    NSString *mainStreamId = [self getStreamIDByStreamUrl:_pushUrl];
    
    NSMutableArray * inputStreamList = [NSMutableArray new];
    
    //大主播
    NSDictionary * mainStream = @{
                                  @"input_stream_id": mainStreamId,
                                  @"layout_params": @{
                                          @"image_layer": [NSNumber numberWithInt:1],
                                          @"input_type": [NSNumber numberWithInt:4]
                                          }
                                  };
    [inputStreamList addObject:mainStream];
    
    NSString * streamInfo = [NSString stringWithFormat:@"mainStream: %@", mainStreamId];
    
    
    int mainStreamWidth = 540;
    int mainStreamHeight = 960;
    int subWidth  = 160;
    int subHeight = 240;
    int offsetHeight = 90;
    if (mainStreamWidth < 540 || mainStreamHeight < 960) {
        subWidth  = 120;
        subHeight = 180;
        offsetHeight = 60;
    }
    int subLocationX = mainStreamWidth - subWidth;
    int subLocationY = mainStreamHeight - subHeight - offsetHeight;
    
    NSMutableArray *subStreamIds = [[NSMutableArray alloc] init];
    for (NSString *playUrl in playUrlArray) {
        [subStreamIds addObject:[self getStreamIDByStreamUrl:playUrl]];
    }
    
    //小主播
    int index = 0;
    for (NSString * item in subStreamIds) {
        NSDictionary * subStream = @{
                                     @"input_stream_id": item,
                                     @"layout_params": @{
                                             @"image_layer": [NSNumber numberWithInt:(index + 2)],
                                             @"input_type": [NSNumber numberWithInt:4],
                                             @"image_width": [NSNumber numberWithInt: subWidth],
                                             @"image_height": [NSNumber numberWithInt: subHeight],
                                             @"location_x": [NSNumber numberWithInt:subLocationX],
                                             @"location_y": [NSNumber numberWithInt:(subLocationY - index * subHeight)]
                                             }
                                     };
        ++index;
        [inputStreamList addObject:subStream];
        
        streamInfo = [NSString stringWithFormat:@"%@ subStream%d: %@", streamInfo, index, item];
    }
    
    NSLog(@"MergeVideoStream: %@", streamInfo);
    
    //para
    NSDictionary * para = @{
                            @"app_id": [NSNumber numberWithInt:[_appID intValue]] ,
                            @"interface": @"mix_streamv2.start_mix_stream_advanced",
                            @"mix_stream_session_id": mainStreamId,
                            @"output_stream_id": mainStreamId,
                            @"input_stream_list": inputStreamList
                            };
    
    //interface
    NSDictionary * interface = @{
                                 @"interfaceName":@"Mix_StreamV2",
                                 @"para":para
                                 };
    
    
    //mergeParams
    NSDictionary * mergeParams = @{
                                   @"timestamp": [NSNumber numberWithLong: (long)[[NSDate date] timeIntervalSince1970]],
                                   @"eventId": [NSNumber numberWithLong: (long)[[NSDate date] timeIntervalSince1970]],
                                   @"interface": interface
                                   };
    return mergeParams;
}

Android端示例代码来源于LiveRoom文件里面连麦合流参数拼接的接口createRequestParam,在原有基础上修改了下面13行和38行,设置了input_type输入源类型为4表示输入源为音频:

private JSONObject createRequestParam() {

            JSONObject requestParam = null;

            try {
                // input_stream_list
                JSONArray inputStreamList = new JSONArray();

                // 大主播
                {
                    JSONObject layoutParam = new JSONObject();
                    layoutParam.put("image_layer", 1);
                    layoutParam.put("input_type", 4);

                    JSONObject mainStream = new JSONObject();
                    mainStream.put("input_stream_id", mMainStreamId);
                    mainStream.put("layout_params", layoutParam);

                    inputStreamList.put(mainStream);
                }

                int subWidth  = 160;
                int subHeight = 240;
                int offsetHeight = 90;
                if (mMainStreamWidth < 540 || mMainStreamHeight < 960) {
                    subWidth  = 120;
                    subHeight = 180;
                    offsetHeight = 60;
                }
                int subLocationX = mMainStreamWidth - subWidth;
                int subLocationY = mMainStreamHeight - subHeight - offsetHeight;

                // 小主播
                int layerIndex = 0;
                for (String item : mSubStreamIds) {
                    JSONObject layoutParam = new JSONObject();
                    layoutParam.put("image_layer", layerIndex + 2);
                    layoutParam.put("input_type", 4);
                    layoutParam.put("image_width", subWidth);
                    layoutParam.put("image_height", subHeight);
                    layoutParam.put("location_x", subLocationX);
                    layoutParam.put("location_y", subLocationY - layerIndex * subHeight);

                    JSONObject subStream = new JSONObject();
                    subStream.put("input_stream_id", item);
                    subStream.put("layout_params", layoutParam);

                    inputStreamList.put(subStream);
                    ++layerIndex;
                }

                // para
                JSONObject para = new JSONObject();
                para.put("app_id", Long.valueOf(mAppID));
                para.put("interface", "mix_streamv2.start_mix_stream_advanced");
                para.put("mix_stream_session_id", mMainStreamId);
                para.put("output_stream_id", mMainStreamId);
                para.put("input_stream_list", inputStreamList);

                // interface
                JSONObject interfaceObj = new JSONObject();
                interfaceObj.put("interfaceName", "Mix_StreamV2");
                interfaceObj.put("para", para);

                // requestParam
                requestParam = new JSONObject();
                requestParam.put("timestamp", System.currentTimeMillis() / 1000);
                requestParam.put("eventId", System.currentTimeMillis() / 1000);
                requestParam.put("interface", interfaceObj);
            }
            catch (Exception e) {
                e.printStackTrace();
            }

            return requestParam;
        }

混流后的画面有黑边

混流后输出的画面有黑边一般是大小主播推流实际分辨率与混流参数layout_params里面设置的image_width和image_height不一致导致的,服务端对流画面进行了裁切所以出现黑边现象。解决办法:

1.设置image_width和image_height与推流分辨率比例保持一致,比如推流使用SDK接口设置了VIDEO_QUALITY_LINKMIC_SUB_PUBLISHER类型,分辨率为320480,那么混流参数设置160240就没问题

                     "input_stream_id":"3891_zachary2",  #填小主播的流ID
                     "layout_params":
                     {
                         "image_layer": 2,
                         "image_width": 160,        # 小主播画面宽度
                         "image_height": 240,       # 小主播画面高度
                         "location_x": 380,         # x偏移:相对于大主播背景画面左上角的横向偏移
                         "location_y": 630          # y偏移:相对于大主播背景画面左上角的纵向偏移
                     }

2.混流的输入流宽image_width和高image_height,不仅支持像素类型(建议在0-3000以内),也支持百分比类型(建议0.01-0.99)。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

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

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券