WebRTC addTrack 背后:一条 MediaStreamTrack 从采集到发送的完整链路
2026/9/18 3:47:03 网站建设 项目流程

做WebRTC开发的朋友一定都遇到过这种尴尬:明明调用了pc.addTrack(track),对端死活看不到画面;或者一切看起来正常,但带宽统计里视频码率就是上不去;还有更诡异的——本地预览好好的,一进通话就黑屏,查了半天才发现是enabled=falseremoveTrack没区分清楚。

这些问题的根子,往往不在应用层,而是在addTrack这行代码往下走的整条链路上。我这些年排查音视频问题的经验是,只要你能把“一条Track从采集到发送”这条链路完整讲清楚,80%的疑难杂症都能自己找到排查方向。这篇就从最基础也最容易被忽略的地方开始——你写的那行addTrack,底层到底做了哪些事,一条Track是怎么一步步走进PeerConnection,最终变成对端屏幕上的画面的。

1. 一条媒体轨的一生:从采集设备到对端屏幕

1.1 先弄清楚几个容易搞混的对象

很多人一开始会把MediaStreamMediaStreamTrack混为一谈,实际上它们是两个完全不同的抽象。

MediaStream更像是一个“容器”或者“播放列表”,里面可以放多个轨。你从canvas.captureStream()getUserMedia()getDisplayMedia()拿到的返回值都是这个容器。而MediaStreamTrack才是真正承载媒体数据的最小单元——它对应一路摄像头画面、一路麦克风声音、一路屏幕共享画面。

这里有个很关键的点:WebRTC虽然用MediaStream做API层的组织,但真正被发送、被编码、被接收的单元是Track,不是Stream。两条流可以共享同一条Track,比如你同时往两个RTCPeerConnectionaddTrack同一个视频轨,浏览器是允许的,实际效果就是这一路画面被并行编码发送给两个对端。反过来说,如果你把一条Track从流A里removeTrack,流B里的对应Track也会跟着失效,因为它俩引用的是同一个源。

在排查问题的时候,这个区分尤为重要。经常有同事拿着代码来问:“我明明把stream add进去了,为什么对端没画面?”我一看代码,他add进去的是一个只包含Track A的stream,但真正想发的其实是Track B,问题就出在拿错Track上。

1.2 采集到的Track真的“进”了PC吗

严格来说,Track本身从来没有被“放”进PeerConnection里。PeerConnection里保存的不是Track对象,而是RTCRtpSenderRTCRtpTransceiver,Track只是被“绑定”到Sender上的一个属性。

打个比方,RTCPeerConnection相当于一条运输线路,RTCRtpTransceiver是这条线路上的一条车道,RTCRtpSender是车道上的发送装置,Track则是装进装置里的货。你调addTrack,不是把“货”直接扔进“线路”里,而是先在车道上装了一个发送装置,再把货放上去。

这个模型的直接推论是:Track可以换,但Sender和车道不会因此重建。你随时可以调用sender.replaceTrack(newTrack)把原来的摄像头换成屏幕共享,对端不需要重新协商,SSRC、传输信道、编码参数这些底层状态都会尽量保持不变。这种设计是为了让媒体切换尽量平滑,避免对端频繁触发重协商和画面中断。

所以回答标题的问题:一条Track被添加进pc,本质是PC上新增了一个Sender,并把Track挂到了这个Sender上。链路是Track -> RTCRtpSender -> RTCRtpTransceiver -> m=行 -> DTLS/SRTP -> 网络

2. addTrack背后的三个动作

2.1 第一件事:创建RTCRtpSender

当你调用pc.addTrack(track, stream)时,浏览器会同步创建一个RTCRtpSender对象。这个Sender负责后面的一切发送工作,包括编码器的生命周期管理、RTP打包、RTCP反馈处理、拥塞控制配合等等。

从代码层面看,你通常会这么写:

const pc = new RTCPeerConnection({ iceServers: [...] }); const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); const track = stream.getVideoTracks()[0]; const sender = pc.addTrack(track, stream);

注意,新版规范里addTrack返回的是RTCRtpSender,但如果你用的是addTransceiver的方式,返回值则是RTCRtpTransceiver,再通过transceiver.sender拿到Sender。

const transceiver = pc.addTransceiver(track, { direction: 'sendonly' }); const sender = transceiver.sender;

这两种方式在实际开发里都很常见。addTrack更偏“我有一条现成的轨要发”,addTransceiver更偏“我先占一条收发通道,后面再决定放什么轨”。后者在需要控制direction或者要setCodecPreferences的场景下更灵活。

关于stream参数,它在addTrack里其实不是用于发送的,而是用于在对端建立MediaStream语义。浏览器会把添加了Track的Stream信息写进SDP的a=msid属性,这样对端收到后可以把Track重新归组到对应的MediaStream里,方便ontrack回调里按流来组织画面。很多人在实现“一屏多流”时直接传null,也是可以的,但这样对端拿到的stream会是空流,需要自己处理Track到流的映射。

2.2 第二件事:准备一条“透明车道”——RTCRtpTransceiver

创建Sender只是第一步,紧接着浏览器会为这条Track创建一个RTCRtpTransceiver。Transceiver的职责是维护这条媒体通道的完整状态,包括本端方向、协商后的方向、关联的m=行、当前使用的编解码器、传输参数等。

每个Transceiver最终都会对应SDP里的一个m=行。比如你有一个音频Track和一个视频Track,PC里就有两个Transceiver,SDP里就有两个m=行,一个m=audio,一个m=videom=行mid属性会和Transceiver一一对应,协商完成后,你通过pc.getTransceivers()看到的就是这些通道对象。

这里有个容易被忽略的坑:一个Transceiver只能承载一条同类型媒体轨。你不能把音频和视频塞进同一个Transceiver,也不能让一条视频轨产生两个Transceiver。如果你要同时发摄像头和屏幕共享这两路视频,必须创建两个Transceiver(本质是两条独立媒体流,使用不同的SSRC),而不是把两条视频轨塞到一个Transceiver里。

Transceiver还有个重要属性direction,取值范围是sendrecvsendonlyrecvonlyinactiveaddTrack时会自动设为sendrecv,如果你只想发不收,可以显式设置sendonly。注意direction只是本端“期望”的方向,最终生效的是协商后的currentDirection。我曾经排查过一个很隐蔽的问题:本端设了sendonly,但对端发来了recvonly,结果协商完currentDirection变成inactive,媒体一条都没发出去。最后发现是两端同时都在用“完美协商”模式,却都以为自己才是offerer,把方向搞反了。

2.3 第三件事:触发协商,让对端知道你要发

Sender和Transceiver创建好,只是本端的内部状态准备好了。要让对端真正能收到媒体,还需要完成SDP协商。这就是onnegotiationneeded回调诞生的原因。

调用addTrack之后,浏览器内部会打上一个“需要重新协商”的标记,然后在当前任务队列空闲时触发onnegotiationneeded事件。你在这个回调里调用createOffer()->setLocalDescription()-> 发送offer -> 接收answer ->setRemoteDescription(),整套流程走完,两条媒体轨才真正进入可发送状态。

标准的Perfect Negotiation流程大致是这样:

pc.onnegotiationneeded = async () => { try { await pc.setLocalDescription(); signaling.send({ description: pc.localDescription }); } catch (e) { console.error('协商失败', e); } };

这里有个常见误解:很多人以为addTrack成功调用了,媒体就开始发送了。不是的。在协商完成之前,Track只是“待命状态”,SRTP密钥没建立,RTP包也不会出网卡。如果对端始终没收到画面,先去看双方的协商有没有成功,pc.connectionState是不是connected,而不是一上来就怀疑编码器。

协商完成后,Transceiver内部的currentDirection会更新,Sender开始真正调度编码器对Track进行采集和编码,一条媒体轨的“发送之旅”才算正式开始。

3. 开关与换轨:Track状态对发送链路的影响

3.1 readyState、enabled、muted分别管什么

MediaStreamTrack有三个属性经常让人踩坑:readyStateenabledmuted。它们的含义差别很大,直接影响发送行为。

  • readyState:表示Track的生命周期状态,只有liveended两个值。ended意味着采集源已经彻底结束,比如摄像头被拔掉、屏幕共享被用户主动停止。此时Track不可恢复,继续发送也是空数据。
  • enabled:应用层的“软开关”,默认true。设为false后,Track不再输出新鲜帧。注意,它还是会输出帧的,只是输出的是黑帧/静音帧。视频会变成全黑,音频会是静音数据包。
  • muted:表示底层采集是否暂时不可用,比如应用切到后台、设备被别的进程抢占。mutedtrue时,同样不会产生有效数据帧。它通常会伴随mute/unmute事件触发。

为什么enabled=false还要继续发黑帧?这是有意的设计。在实时通信里,保持RTP包持续流动,可以让接收端的解码器、抖动缓冲、回声消除(AEC)等模块维持稳定状态,避免频繁进入饥饿模式进而产生卡顿和爆音。但代价就是带宽并没有省下来。

3.2 replaceTrack的正确打开方式(真正的换轨)

如果你需要在通话过程中把摄像头画面切换成屏幕共享画面,正确操作是使用sender.replaceTrack(newTrack),而不是removeTrack再加addTrack

const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); const screenTrack = screenStream.getVideoTracks()[0]; await sender.replaceTrack(screenTrack);

replaceTrack的好处是协商状态不重建,Transceiver、SSRC、传输通道保持原样,切换更平滑。而且它允许传入null,表示停止发送媒体,但保留Sender和Transceiver结构,后续还可以再replaceTrack回来。

这里要注意几个限制:

  • newTrack和原Track的kind必须一致。你不能用一个音频Track去替换视频Track,浏览器会直接抛TypeError
  • 换轨后,编码器会重新配置。如果新旧分辨率、帧率差异很大,可能短暂出现画面卡顿或花屏,这属于正常现象。
  • 不要在新Track上重复调用getUserMedia后忘记停止旧Track。屏幕共享结束后,旧Track的采集源没有释放,摄像头指示灯可能一直亮着,这在隐私敏感的场景下非常致命。

3.3 停流为什么不能依赖muted/enabled=false

这是我想重点强调的一个坑:不要用enabled=falsemuted来“停掉”一条流

之前帮一个做在线教育产品的团队排查问题,他们反馈“学员关摄像头之后,服务端统计发现视频码率还是居高不下”。我看代码,他们的实现是track.enabled = false,于是带宽照常消耗。因为在WebRTC的设计里,enabled=false发送的是黑帧,RTP包仍然持续产生,编码器照样工作,packetsSentbytesSent还是在涨。

真正要停流,有几条路:

  • pc.removeTrack(sender):把Sender从PC里移除,触发重新协商,对端会收到ontrack对应Track的ended事件。这是最彻底的停止方式。
  • sender.replaceTrack(null):停止发送媒体数据,但保留Transceiver位置,不需要重新协商就能立刻生效。适合临时关闭或切换场景。
  • 直接track.stop():停止采集源,Track进入ended,但并不自动从PC里移除,RTP层会自动发一些RTCP BYE之类的通知,但SDP协商状态不会主动更新。

具体选哪种,取决于你需不需要保留这条通道。如果只是临时关闭摄像头、后续还可能打开,推荐replaceTrack(null);如果是彻底不再发了,用removeTrack更干净。

4. 走到SDP这一步:编码协商决定了这条Track能被谁看懂

4.1 m行与sendrecv/sendonly的语义

当SDP被创建时,每个Transceiver会生成一个m=行。简化后的视频m行长这样:

m=video 49170 UDP/TLS/RTP/SAVPF 96 98 102 a=mid:0 a=sendonly a=rtpmap:96 VP8/90000 a=fmtp:96 ... a=rtpmap:98 H264/90000 a=fmtp:98 profile-level-id=42E01F; packetization-mode=1 a=rtpmap:102 H264/90000 a=fmtp:102 profile-level-id=640C1F; packetization-mode=1 c=IN IP4 0.0.0.0

m=video后面的49170是端口,在offer里通长是09,表示“还没有选路”;UDP/TLS/RTP/SAVPF表示用DTLS做密钥协商、SRTP加密,这是现代WebRTC的标准协议栈。后面那串数字是payload type编号,同一个媒体类型可以对应多个PT。

这一行有几个关键语义:

  • a=sendonly表明本端只发送不接收。对端看到这个方向后,就不会往这条m行发送RTP。
  • a=mid:0对应Transceiver的mid,控制消息、BUNDLE分组、SSRC关联都靠它。
  • a=rtpmapa=fmtp声明支持的编解码器及其参数,可能是多个,供对端选择。

协商的本质是“双方找交集”。Offer列出自己能编码的、Answer者列出自己能解码的,最终通过m=行里保留的PT列表确定双方都能用的编解码器。浏览器在内部会选择第一个双方都支持的编解码器作为实际使用的编码器。所以你在webrtc-internals看到的实际编码格式,和你在getUserMedia里设置的videoConstraints有关,但最终决定权在协商结果里。

4.2 编解码器取舍:VP8、H264、AV1实测要注意什么

WebRTC默认的编解码器在各浏览器里有差异。Chrome和Firefox通常默认首选VP8,Safari更偏好H264,因为苹果设备硬件解码能力强。做跨端互通时,如果协商不到双方都支持的编解码器,媒体就会失败。

以H264为例,SDP里的profile-level-idpacketization-mode是两个最常出问题的参数。

  • profile-level-id=42E01F对应Constrained Baseline Profile Level 3.1,这是WebRTC里兼容性最好的H264配置。640C1F对应Constrained High Profile,画质更好,但部分老旧设备不支持硬解。
  • packetization-mode=1表示支持Non-Interleaved模式,即每个NAL单元可以单独打包(Single NAL Unit Packet),这对于低延迟传输很重要。如果对端SDP里没有这个参数,或默认是0,容易出现花屏。

做H264推送服务对接时,我踩过一个大坑:服务端用FFmpeg编码H264,把profile设置成了High,结果Chrome端一直协商失败。排查到最后发现是Chrome的H264解码只支持到Constrained High Profile的特定level,服务端编码出来的SPS信息超出了解码能力边界。后来统一在服务端限制profile:v baselineprofile:v main,问题才解决。

VP8的优势在于它不像H264那样有专利授权和profile地狱,且Chrome对它的软编软解优化做得很好。缺点是同等画质下码率通常比H264高10%~20%。如果你的场景是纯Web端互连,VP8是最省心的选择;如果涉及硬件终端、小程序、SIP网关等外部系统,H264通常是更好的兼容性选择。AV1目前已经在Chrome里支持编码,但软编成本较高,移动端硬解覆盖还不行,生产环境要谨慎。

如果你想主动控制首选编码器,可以在创建Transceiver之后设置偏好:

transceiver.setCodecPreferences( RTCRtpReceiver.getCapabilities('video').codecs.filter( codec => codec.mimeType === 'video/VP8' || codec.mimeType === 'video/H264' ) );

注意setCodecPreferences必须在创建Offer/Answer之前调用,一旦协商开始就来不及了。

4.3 setParameters调码率的正确姿势

协商好的编码参数不是一成不变的,你可以通过Sender的getParameters()setParameters()在运行时调整。

const params = sender.getParameters(); params.encodings[0].maxBitrate = 500_000; params.encodings[0].maxFramerate = 24; await sender.setParameters(params);

这里有几个经验值:

  • maxBitrate的调整通常会在几百毫秒内生效,可用于“手动降码率”之类的场景。
  • 实际码率是编码器根据内容复杂度、拥塞控制算法动态调整的,maxBitrate只是上限,不是目标值。如果场景复杂,实际码率会接近上限;如果画面静止,可能远低于上限。
  • scaleResolutionDownBy参数可以控制分辨率降档,例如2表示宽高各缩小一半。先降分辨率再压码率,比单纯调maxBitrate画质会好很多,因为模糊和块效应是有区别的。
  • 音频编码器的码率一般不建议动,Opus内部有自适应机制,乱调容易影响语音质量。

5. 编码之后:RTP包是怎么给媒体帧“贴快递单”的

5.1 RTP头里的关键字段为什么长这样

编码器输出的是压缩后的视频帧,比如一个H264的I帧可能达到几十KB甚至更大。但这些数据不能直接扔到网络上,必须按照RTP协议拆成一个个小包,并为每个包填上标准的RTP头,相当于给货物贴上快递单。

RTP固定头是12字节,关键字段包括:

  • payload type (PT):7位,标识负载格式。比如96代表VP8,98代表H264。这个值和SDP里协商的PT一一对应。
  • sequence number:16位,每个RTP包递增。接收端用它做丢包检测和排序。
  • timestamp:32位,标识媒体采样时间。视频一般用90kHz时钟,音频用采样率(通常48kHz)。
  • SSRC:32位,用于标识同一媒体源的RTP包。一条视频Track的SSRC是固定的,音频和视频的SSRC不同。如果对端收到两个不同SSRC的包,会认为是两条媒体流。

这些字段不是随便定的。sequence number是传输层的连续性保证,和显示时间无关;timestamp是播放层的同步基础,告诉接收端这个包应该在什么时间点被解码和渲染。两者分开设计,是因为网络传输会抖动、会丢包,接收端需要根据时间戳重排播放节奏,而不是简单按到达顺序播放。

5.2 H264/H265 NALU拆分与打包模式

对于H264/H265这种基于NAL单元的编码格式,一帧图像编码后可能产生多个NAL单元。RTP打包时最关键的是分片策略:对于一个超过MTU(典型值1200字节)的大NAL单元,必须切成多个RTP包发送。

H264 RTP封装有三种典型模式:

  • 单一NAL单元模式(Single NALU Packet):一个RTP包正好装一个完整的NAL单元,适合尺寸较小的NAL。
  • 聚合包(STAP-A):多个小NAL单元(如SPS、PPS、SEI)合并进一个RTP包,减少包头开销。
  • 分片包(FU-A):一个大NAL单元按字节切分成多个RTP分片,每个分片用FU头里的S(起始)、E(结束)标记位来标识顺序。

实际抓包时你会发现,I帧通常会产生大量FU-A分片,而P帧、B帧因为本身小,可能一个RTP包就能装下。这也解释了为什么I帧丢失对画面影响巨大——一个I帧分片丢了,整个帧解码失败,接收端只能等下一个I帧或者主动发PLI请求关键帧。所以排查卡顿时,如果看到大量PLIFIR反馈,说明I帧在网络上持续丢失,问题多半出在路由、防火墙或者MTU设置上。

5.3 同一时刻的音视频如何对得上:同步是个系统工程

音频和视频是两条独立的RTP流,各自有独立的SSRC、独立的sequence number和时间戳。音频时间戳按48kHz采样率走,视频时间戳按90kHz时钟走,它们的“刻度”完全不同。接收端怎么知道音画是否对齐?

答案是靠RTCP里的SR(Sender Report)报文。SR里不仅包含RTP时间戳,还包含对应的NTP时间戳(全球统一绝对时间)。接收端拿到音频SR和视频SR后,通过NTP这一共同基准,就可以把两个RTP时间戳映射到同一时间轴上,实现音画同步。

这个设计很优雅,但在实际使用中有一个常见坑:如果发送端的采集链路里音频和视频来自不同的时钟源,且没有打上同一个NTP基准,就会出现音画不同步。这在Web端很少见,因为浏览器内部统一调度;但在服务端混合转发/重新打包时,如果对音频和视频分别用两套时钟参考,出来的流就可能持续漂移。

排查音画不同步时,不要只看播放器,要先去getStats()里看音频和视频的RTCP发送报告,核对各自的timestamp和NTP关联字段是否合理。

6. 最后的物理出口:加密、平滑发送与带宽限制

6.1 SRTP加密后,抓包还能看到什么

很多刚接触WebRTC的人会问我:“为什么我用Wireshark抓包,看不到H264的起始码?”

因为RTP包在进入网络之前,已经被SRTP加密了。现代WebRTC强制使用DTLS-SRTP,媒体负载通过AES-GCMAES-CM(AES-CTR模式)加密,只有DTLS握手完成后两端的SRTP密钥才可用。所以你在网上看到的裸流量里,能看到的是UDP包、DTLS握手包、ICE的STUN包,以及加密后的SRTP密文——内容无法直接解析。

这给调试带来了麻烦,但可以采用几个变通手段:

  • chrome://webrtc-internals看浏览器内部的RTP统计和解码状态,不从网络层看内容。
  • 在服务端SFU里,可以在媒体数据被转发前后做一次“降密”日志,记录SSRC、PT、序列号等信息,用于分析丢包、乱序、时序问题。
  • 如果必须要看RTP明文内容,可以在应用层通过RTCRtpSender的回调或者自建采集管道,在进入SRTP之前记录原始数据。但这通常需要改造浏览器端实现,生产环境不太推荐。

6.2 发送节奏为什么不是“采集一帧发一帧”

有一个很常见的误解:摄像头以30fps采集,RTP是不是就以每33ms一个帧的节奏发出去?

实际上WebRTC内部有一个专门的平滑发送器(Pacer),它的功能是:把编码器产生的突发帧数据,按照当前估算的发送码率,打散到多个很小的发送间隔里均匀地发送出去。

为什么要这么做?因为编码器输出是突发的。一个I帧可能瞬间产生几MB数据,如果一次性全丢到网络上,会瞬间占满链路缓冲,造成网络拥塞和丢包;而P、B帧之间又可能隔很久不发。平滑发送可以避免这种突发流量,让UDP包在时间轴上尽量均匀,从而降低延迟和丢包率。

这也就解释了为什么你在getStats()里看到的bitrate是平滑的曲线,而不是突然的尖峰。

6.3 GCC与带宽探测:为什么video经常优先被砍

真正的发送码率不是随便定的,它由WebRTC的拥塞控制算法在每一个周期动态计算。Chrome目前主要使用GCC(Google Congestion Control)体系,结合丢包率、单向延迟梯度和REMB/Transport-CC反馈,估算出当前路径可用的带宽,再决定视频编码器的目标码率。

这里有一个浏览器内置的升降级逻辑:带宽充足时逐步提高码率探测上限,带宽不足时先降视频码率,再降分辨率(通过qualityLimitationReason可以看到是bandwidth还是cpu限制)。音频的优先级通常高于视频,因为音频包小、对延迟更敏感,一旦音频开始丢包,通话基本就不可用了。

所以在实际多路互通时会出现一个现象:网络恶化时,视频码率被压到极低,画面开始模糊,但语音仍然清晰。这不是故障,而是拥塞控制刻意保护音频的结果。

7. 实战排查:一条Track没发出去的5类原因

7.1 从getStats和webrtc-internals快速定位

遇到“Track加了,但媒体没发出去”的问题,我的排查顺序是固定的:

  1. 打开chrome://webrtc-internals,看RTCPeerConnectionconnectionState是否变成connected。如果不变化,问题在ICE/DTLS层面,跟Track无关。
  2. outbound-rtp统计里的bytesSent是否持续增长。如果不增长,说明Sender没有拿到有效数据,重点检查Track是否liveenabled是否正确、是否有帧被编码(framesEncoded)。
  3. framesEncodedframeWidth/frameHeight。如果framesEncoded为0,说明采集侧或者编码器没工作,看看是不是replaceTrack(null)之后忘换回来了。
  4. qualityLimitationReason字段。如果是cpu,说明性能不够;如果是bandwidth,说明网络估算带宽太低;如果是none,代表没有限制。
  5. nackCountpliCount。大量NACK说明网络丢包严重;大量PLI说明接收端频繁请求关键帧,通常是I帧丢失或解码失败。

getStats()从业务侧拿数据也同样可靠:

const stats = await pc.getStats(); stats.forEach(report => { if (report.type === 'outbound-rtp' && report.kind === 'video') { console.log('bytesSent', report.bytesSent); console.log('framesEncoded', report.framesEncoded); console.log('bitrate', report.bitrate); } });

7.2 经验速查表

现象优先排查点常见原因
connectionState一直new/checkingICE、STUN/TURN配置、防火墙未配置TURN导致对称型NAT打洞失败
connectionState是connected但没画面outbound-rtp的bytesSent是否增长Track被replaceTrack(null)removeTrack
framesEncoded为0采集源是否正常enabled=falsereadyState不是live
bytesSent增长但fps很低CPU受限或分辨率设置过大qualityLimitationReason=cpu
接收端频繁花屏网络丢包或I帧丢失观察nackCount/pliCount
音画不同步RTCP SR时间戳基准不一致服务端重新打包时NTP基准错位
H264协商失败SDP里profile-level-id不匹配服务端编码用了High Profile但解码端不支持

再补一个隐蔽问题的经验:千万不要同时依赖track.onendedremoveTrack来判断对端状态onended只在Track被stop()或者采集源结束时触发,removeTrack对端收到的是ontrack里的Track进入muted还是ended,各浏览器实现细节有差异,被坑过一次之后我建议你始终以信令层的明确通知为准,不要把媒体层事件当成业务状态机。

大概就是因为这套逻辑撑起了WebRTC的灵活性,我到现在还经常把这些底层知识点当成排查工具栏。如果你正在做多路推流或者SFU接入,下一篇我打算把“一条PC里多条Track”的复用和带宽分配一起讲透,到时候我们接着聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询