背景如下~我们公司有一个新的web端产品,同时兼容PC端和手机端。主要用于在线研讨会、教育培训等视频通话场景。在测试阶段,我们的团队会在各种需要在线交流的场景下使用这款产品进行连麦通话。真实的用户场景帮助我们发现了很多平时被忽略的测试用例,比如在电梯里、在车库里、在地铁里等等,不断切换场景。今天要解决的是我们的产品小哥在地铁上发现的问题之一。具体组合下的一个bug:AndroidChrome+蓝牙耳机+WebRTC遇到问题。一天团队开会,到了下班时间,大家依然兴致勃勃,丝毫没有散会的意思。可就在关键时刻,产品小哥突然收到小姑子的信息,让他马上回家。会议不能突然终止,嫂子的吩咐也不能不听。不幸的是,我们的新产品在这种场景下就可以派上用场了:入住的人和产品小哥都加入了在线会议室并进行了麦克风通话,会议资料通过屏幕共享、PPT、白板等形式。这样产品小哥回家开会就不会耽误了。就这样,产品小哥一边回家,一边打开手机参加会议。但到了地铁后,产品小哥觉得放在外面很不道德,于是拿出蓝牙耳机,熟练地戴上,成功连上了手机。就在这时,意想不到的事情发生了,声音不是从耳机里播放出来的,而是从手机喇叭里输出的!发生了什么?今天耳机坏了吗?产品小哥简直不敢相信自己新买的耳机这么快就“报废”了!不可能,我今天还在用这些耳机听音乐。对了,先试试看还能不能听音乐。(切到音乐app听歌)没问题!(又切回)耳机里怎么还是没有声音?经过多次尝试,最终确定不是耳机的问题,而是我们新产品的问题。确认问题为了排除业务代码的影响,我们通过一个简单的demo进行了测试。本demo由WebRTC团队实现,通过基本的WebRTCAPI创建本地音视频流,使用video标签进行播放。让我们先了解一下演示中使用的WebRTCAPI。后续解决方案也依赖于这两个API:WebRTCAPI函数navigator.mediaDevices.enumerateDevices()获取媒体输入输出设备列表。设备类型包括音频输入设备、音频输出设备和视频输入设备。navigator.mediaDevices.getUserMedia()使用默认参数或指定的设备信息来捕获媒体流,可以使用视频元素播放。考虑到WebRTCAPI对浏览器版本要求较高,考虑到中国浏览器的市场占有率,我只测试了Android下的Chrome浏览器,iOS下的Safari浏览器和Chrome浏览器。测试用例及结果:经过一些测试,基本确定遇到的问题都可以在AndroidChrome上找到。后来在ChromiumBug1285166中发现,Chromium团队肯定了问题的存在,但是列表状态被标记为“WontFix”。看来短期内只能自己补,AndroidChrome兼容。正如ChromiumBug1285166所建议的,我们应该监听devicechange事件,然后通过setSinkId()切换音频输出设备。但是,在了解setSinkId()兼容性后,我放弃了这个想法。结合测试时发现的一个表现——使用指定的音频输入设备通过getUserMedia()创建视频流后,音频输出通道会自动切换到对应的设备——突发奇想:通过切换音频输入设备来实现切换音频输出设备的效果。回过头来看,我们最初的期望是同时切换音频输入和输出设备。这不是两全其美吗?问题的备选方案及优缺点分析根据以上测试结果,初步确定了两种方案:方案一:用户自主选择音频输入输出设备思路:使用enumerateDevices()获取设备列表,并提供下拉列表供用户自主选择音频输入设备;用户主动选择切换设备后,使用getUserMedia()重新创建本地音视频流;通过devicechange事件监听设备变化,更新下拉列表。优点:不确定在什么情况下会出现实际使用的设备与预期设备不符的问题,所以如果随时出现问题,用户可以自主选择设备。缺点:需要用户手动切换,成本高。如果没有音频输出(例如,即使房间里的其他人关闭麦克风或只拉流不推流),用户无法感知到他正在使用的设备不符合预期,因此他可能不手动切换设备。实现过程中有两点需要注意:需要先使用getUserMedia()获取媒体设备权限,才能获取有效的设备列表。devicechange事件兼容性较差,在AndroidChrome上完全不受支持。对于第2点,兼容性是通过使用计时器轮询设备列表来实现的://MonitordevicechangeeventsfunctioninitDeviceChangeListener(){log(`[support]是否支持devicechange事件:${isSupportDeviceChange}`);如果(isSupportDeviceChange){navigator.mediaDevices.addEventListener('devicechange',checkDevicesUpdate);}else{setInterval(checkDevicesUpdate,1000);}}constprevDevices=awaitgetDevices();asyncfunctioncheckDevicesUpdate(){//获取更改后的设备列表以与prevDevices进行比较constdevices=awaitgetDevices();//添加设备列表constdevicesAdded=devices.filter(device=>prevDevices.findIndex(({deviceId,kind})=>device.kind===kind&&device.deviceId===deviceId)<0);//移除设备列表constdevicesRemoved=prevDevices.filter(prevDevice=>devices.findIndex(({deviceId,kind})=>prevDevice.kind===kind&&prevDevice.deviceId===deviceId)<0);//设备改变if(devicesAdded.length>0||devicesRemoved.length>0){//TODO}prevDevices=devices;}这个实现是基于测试时发现的经验:“通过getUserMedia()创建指定音频输入设备的视频流后,音频输出通道会自动换到相应的设备”但是真的可靠吗?在本地开发环境简单验证了代码的可行性后,我把相关文件放到了服务器上,继续使用真机进行验证(这里的MDN文档简单解释了为什么局域网内的IP地址不能用于访问测试代码)。没想到又踩了一个坑:麦克风从默认设备切换到蓝牙耳机后,声音是从听筒输出的。测试有问题的机型和浏览器:小米11+微信、HUAWEIMate20Pro+Chrome94.0.4606.85。问题原因:选择deviceId:'default'以外的音频输入设备,自动切换的音频输出设备不符合预期。有关详细信息,请参阅Chromium错误1277467。这里说的“non-deviceId:'default'device”怎么理解?我们在场景二中解释。这样一来,方案一不仅不靠谱,还会引入新的问题。方案二:通过代码自动为用户切换设备思路:通过devicechange事件监听设备变化;添加或移除音频输入设备时,使用系统默认设备重新创建本地音视频流。优点:减轻用户负担,满足用户期望,自动切换过程无感知。但是,如何知道系统的默认设备呢?我们先来看看大部分AndroidChrome下获取到的设备列表数据是什么样子的。下面的截图是在红米手机的chrome上截的,截屏的时候连接了蓝牙耳机。从截图中可以看出,浏览器返回了4条设备信息(MediaDeviceInfo),设备信息中的标签代表了对设备的描述。截图中第一个设备代表系统默认设备,后面的2、3、4分别代表免提、耳机受话器、蓝牙耳机(即提到的“non-deviceId:'default'device”在解决方案1)中。每个Android设备下系统默认设备的标签值可能不同,但其对应的deviceId为'default',所以我们可以使用deviceId==='default'作为判断系统默认设备的依据。至此在最终的兼容解决方案中,问题的解决方案呼之欲出。这里先放上方案实现代码的GitHub链接。简单总结一下步骤:如果不是Android,不用处理,直接结束。保留设备列表,表示为prevDevices。监听设备更改行为。当发生设备变化时,获取一个新的最新设备列表,记录为curDevices。比较prevDevices和curDevices以获得新添加和删除的设备列表。如果是“主播”角色:如果添加或删除的设备列表中有audioinput类型的设备,则使用系统默认的audioinput设备作为音频源,重新创建一个本地音视频流。替换本地音视频流(或者只是替换本地音视频流的音轨,使用RTCRtpSender.replaceTrack())。如果是“旁观者”角色:如果新添加的设备包含蓝牙耳机,可能会提示用户可能没有声音,可以通过刷新页面解决。PC端设备热插拔处理为什么要处理热插拔?相比移动设备的热插拔,PC端浏览器的实现基本没有问题,包括devicechange事件兼容,切换音频输入设备后无法自动切换到对应的音频输出通道等.,PC上不存在。PC端需要处理的问题是什么?事情是这样的,在某场直播中,流屏卡在最后一帧,经过一轮排查,最终确定是外接摄像头数据线接触不良的问题。在这些情况下,我们更多能做的是优化交互,比如提醒用户或者提供切换设备的快捷入口。下面是PC端新增设备的用户提示效果图。我们的解决方案我们把PC端的设备热插拔分为两种情况,添加一个设备和移除一个设备。一般不需要在添加新设备时自动切换到新设备,而是提醒用户并提供切换到新设备的快捷方式。但是有一个例外,当新添加的设备是该类型设备列表中唯一的设备时,自动切换是在代码内部实现的。整体流程图如下:当移除一个设备时,只需要处理当前正在使用的设备的移除:自动切换到其他可用的设备。整体流程如下:如何判断两个流程中提到的“deviceinuse”?很简单,首先获取本地正在使用的媒体流对象,分别通过MediaStream.getVideoTracks()和MediaStream.getAudioTracks()获取视频轨和音频轨对应的MediaStreamTrack实例;然后通过mediaStreamTrack.getSettings().deviceId可以知道当前使用的设备的deviceId,从而判断是否是“正在使用的设备”。实现代码:/***从给定的设备列表中查找当前使用的设备*@param{MediaStream}localStream-正在使用的本地音视频流*@param{MediaDeviceInfo[]}devices-给定的设备列表*@returnsMediaDeviceInfo[]*/functiongetInUseDevices(localStream,devices){//localStream为本地音视频流if(!localStream)return[];constaudioTrack=localStream.getAudioTrack();constvideoTrack=localStream.getVideoTrack();constinUseMic=audioTrack?audioTrack.getSettings().deviceId:'';constinUseWebcam=videoTrack?videoTrack.getSettings().deviceId:'';constinUseDevices=[];for(leti=0;i
