声网Web SDK构建在线研讨会

背景
有别于在线会议和预格式化文本视频直播两种场景,在线研讨会参与人数介于两者之间,但又同时结合了在线会议及会议实况直播。在线主播之间可以实时相互音视频沟通,与此同时,场外听众也观看秒级延迟的研讨实况,并实时连麦或者通过文字与在线主播互动。在线研讨会的典型场景有助于更好的传播少数主播音视频信息,更好的管理直播间秩序。

目前主流的会议工具包括zoom、腾讯会议等都支持在线研讨会模式,但是本地应用方案极大限制了用户参加会议的条件,参会听众需要预先安装各类客户端软件,并且会议报名、审核、提醒等安排仍然需要借助第三方平台完成。

我司产品的直播间采用了声网Web SDK,在H5/Web环境中构建研讨会应用,并且与平台现有的用户体系打通,提供一站式的报名、审核、提醒、参会、回放生成等服务。

研讨会平台架构
image
上图基本描述我司在线研讨会平台的模块和架构通信流程,声网服务作为非常重要一个实时通信组件,它的稳定性和低延时决定了用户体验和友好性,不过从目前使用情况来看,除非主播端自身的网络异常,声网服务体验是非常不错的,我们尝试过上海、南京、新加坡、旧金山等多地主播在线分享,延迟和音视频质量都超乎我们的预期,感觉声网在实时通信专线建设等基础设施上做了大量的物理和软件上的优化。

废话不多说,稍微描述一下上述架构的主要关注点:
1)身份授权控制。我们采用了阿某云、声网、我们自己的应用服务器,其中阿某云的推流及声网的实时通信服务为典型的PaaS服务,主播及授权听众是我们平台的注册用户,由我们平台来控制用户身份验证,并通过鉴权的方式,授权用户使用阿某云及声网的特定服务,避免不明身份乱入及安全性问题的出现。
2)信息同步控制。声网、阿某云以及我们的应用,在复杂的网络环境下,用户可能各种原因断开其中一个服务,其它服务仍然保持连接状态,在断开的服务恢复时,如何回到断开前的状态,复现的步骤,需要有我们应用服务器来引导,这部分可能需要不同应用根据自己的业务场景,引导用户或者由程序自动完成。

核心代码逻辑
声网的通信API都是异步,以注册事件加回调为主,使用也比较简单,基本上根据回调函数的状态更新页面状态即可,不过由于在线研讨会的特殊性,我们为每位主播创建了3个client对象,分别对应计算机桌面共享流、摄像头视频流,音频流,其中音频client会负责订阅频道内其它的音视频流。

优点:任意主播可以随时终止,切换,开启音频、视频、桌面共享流;
缺点:由于声网最多只支持18位主播,我们最多就只能支持6位主播,当然这个可以继续优化;频道内主播数增加,声网的直播费用成倍攀升;每位真实主播需要占用3个uid,给uid管理带来一定的麻烦,需要通过一定的算法才能辨别三个逻辑uid实际对应是同一个用户,从而避免订阅用户自己的音视频流。

为了让大家更直观了解声网API回调使用方式,大致代码步骤如下:
`this.client_video = AgoraRTC.createClient({ mode: ‘live’, codec: ‘vp8’ });
this.client_video.on(‘error’, function(err) {
that.on_error(ERR_UNKNOWN.withNewMsg(err.reason));
});

                this.client_video.on('stream-added', function (evt) {
                    let stream = evt.stream;
                    let uid    = stream.getId();
    
                    if(that.is_local_stream(stream)) {
                        // will not subscribe for user himself
                        return;
                    }
    
                    that.client_video.subscribe(stream, function (err) {
                        that.on_error(ERR_SUBSCRIBE_REMOTE_STREAM);
                    });
                    console.log("New stream added: " + uid);
                });
    
                this.client_video.on('stream-subscribed', function (evt) {
                    let stream = evt.stream;        
                    if (stream.hasAudio()) {
                        that.on_subscribe_audio(stream);
                    }
                    that.append_stream(stream);
                    if (that.is_desktop_stream(stream)) {
                        that.on_subscribe_desktop(stream);
                    } else {
                        that.on_subscribe_video(stream);
                    }
                });
    
                this.client_video.on('stream-published', function (evt) {
                    let stream = evt.stream;
                    if (stream.hasAudio()) {
                        that.on_publish_audio(stream);
                    }
                    
                    if (stream.hasVideo()) {
                        that.on_publish_video(stream);
                    }
                });
    
                this.client_video.on('stream-removed', function (evt) {
                    that.__unsubscribe(this.client_video, evt.stream);
                });
    
                this.client_video.on("liveStreamingFailed", function(evt) {
                     this.on_error(ERR_CDN_LIVE_STREAM);
                });
    
                this.client_video.on('liveStreamingStarted', function(evt) {
                     this.on_error(ERR_CDN_LIVE_STREAM_OK);
                });
    
                this.client_video.on("unmute-audio", function (evt) {
                    that.on_subscribe_audio(evt.stream);
                    append_logger(" user unmute audio:" + evt.uid);
                });
    
                this.client_video.on("mute-audio", function (evt) {
                    // ignore the desktop audio
                    if (that.real_uid(evt.stream) == evt.uid) {
                        that.on_unsubscribe_audio(evt.uid);
                    }
                    append_logger(" user mute audio:" + evt.uid);
                });

                this.client_video.on("unmute-video", function (evt) {
                    that.on_subscribe_video(evt.stream);
                    append_logger(" user unmute video:" + evt.uid);
                });

                this.client_video.on("mute-video", function (evt) {
                    that.on_unsubscribe_video(evt.uid);
                    that.__unsubscribe(client, evt.stream);
                    append_logger("user mute video:" + evt.uid);
                });
    
                this.client_video.on("liveStreamingStarted", function(evt) {
                    append_logger(`liveStreamingStarted: ${JSON.stringify(evt)}`);
                });
    
                this.client_video.on("liveStreamingStopped", function(evt) {
                    append_logger(`liveStreamingStopped:  ${JSON.stringify(evt)}`);
                });
    
                this.client_video.on("liveStreamingFailed", function(evt) {
                    append_logger(`liveStreamingFailed: ${JSON.stringify(evt)}`);
                });
    
                this.client_video.on("liveTranscodingUpdated", function(evt) {
                    append_logger(`liveTranscodingUpdated: ${JSON.stringify(evt)} `);
                });
    
                this.client_video.on("connection-state-change", function(evt){
                    // DISCONNECTED
                    // CONNECTING
                    // CONNECTED
                    // DISCONNECTING            
                    // console.log(evt.prevState, evt.curState);
                    if (evt.curState == 'CONNECTED') {
                        that.on_connected();
                    } else if (evt.curState == 'DISCONNECTED') {
                        that.on_disconnected(evt.curState);
                    }
                });
    
                this.client_video.on("network-quality", function(stats) {
                    // "0":质量未知
                    // "1":质量极好
                    // "2":用户主观感觉和极好差不多,但码率可能略低于极好
                    // "3":用户主观感受有瑕疵但不影响沟通
                    // "4":勉强能沟通但不顺畅
                    // "5":网络质量非常差,基本不能沟通
                    // "6":网络连接断开,完全无法沟通
                    // console.log("uplinkNetworkQuality", stats.uplinkNetworkQuality);
                    that.connection_quality = stats.uplinkNetworkQuality;
                });
    
                this.client_video.init(that.event.appid, function () {
                    append_logger("current user id:" + that.me.id);
                    that.client_video.join(rtc_token.video_token, room, rtc_token.video_uid, function(uid) {
                        that.on_join(that.me);
                    }, function(err) {
                        that.on_error(ERR_JOIN_CHANNEL);
                    });
                }, function (err) {
                    that.on_error(ERR_INIT_DEVICE);
                });`

以上代码基本涵盖了回调事件中比较重要的方面:
1) 音视频流发布、订阅、终止,值得说明的事,在接收到“stream-added”事件后,用户需要判断要不要订阅该流,如果订阅才会开始计费,这里是很重要的用户真实身份判断的逻辑点。
2) 已经订阅的流在指定位置播放;
3) 上行及下行网络状况
4) CDN旁路推流状态
5) 每位用户的声音大小变化
6) ……
通过上述的回调,更新相关状态值,由于我们web端应用完全基于vue来完成,应用程序相对比较简单容易控制。也推荐大家在使用声网web sdk的时候,采用vue之类的响应式框架,极大简化编程和调试的难度。

总结
在线研讨会场景中,涉及到的组件和应用功能繁多,往往是多个云平台的功能合集,声网除了提供完备的API外,后台软件和网络基础设施优化,是应用层用户体验的重要保障,我们的产品也在不断挖掘声网特性,逐步加入到新版本中,为用户提供更方便、简单易用的功能。