消息 | 音视频 | 运维 | 消息·初版

音视频信令该归 IM 的部分

一对一、会议、直播:对照 Janus、LiveKit、P2P 与 TRTC 背后的占位、时钟、发证与拆房

一、信令与媒体面必须拆开

音视频系统里真正可比较、可移植的,从来不是 Janus、LiveKit 还是 TRTC 谁的 ICE 写得更好。媒体面负责 RTP、编解码、进房鉴权和房间销毁;IM 负责占位、振铃、接听仲裁、忙线、超时、凭证签发时机,以及业务终态之后是否拆掉媒体房。把这两层绑在一起讨论,会把不可比的东西排成名次,也会把不该抄的实现细节抄进错误的层。

对照仓里实际只有三类切分。第一类是「P2P 挂在 IM 话题上」:Tinode 用 {pub webrtc}{note what=call} 转发 SDP/ICE,Rocket.Chat 企业版一对一是浏览器 RTCPeerConnection 加可选 SIP,HuLa 是桌面端里的同一套 PeerConnection。这一类没有房间对象,ICE failed/closed 才挂断,服务器看不见媒体是否活着。第二类是「IM 当哑管道、自建 SFU」:野火社区一对一是 Mesh 加 TURN,付费线才把 videoroom 放到 Janus;broker 只持久化邀请类消息,ICE、SDP、会控命令透传。第三类是「IM 占位并发证、SFU 完全场外」:JuggleIM 对 Zego、Agora、LiveKit 只做 GenerateAuth,Turms 用插件签 LiveKit JWT,OpenIM 协议面写成 jwt+liveURL,layababa 走 TRTC UserSig 与 PMK。第三类里 ICE、iceRestart、RTP 转发都不进 IM;IM 必须自己拥有占位状态机、振铃与在呼双时钟、发证时机和终态拆房。缺任何一项,媒体宕机对业务面就是盲的。

因此后文只比较业务状态机和失败边。Janus 的 ICE-lite、MQTT 桥、rtp_forward,LiveKit 的 Twirp RoomService,浏览器 createOffer({iceRestart:true}),都不作为追赶对象。媒体品牌已经选定 TRTC;要偷的是谁在什么时刻占坑、谁超时、谁发证、谁拆房。

二、各家实际栈

项目媒体面IM 实际拥有一对一会议直播 / 连麦
layababa场外 TRTC占位状态机、双时钟、角色换证、会议与直播拆房已交付已交付已交付
JuggleIMZego / Agora / LiveKit 发证rtcroom actor、双超时、成员 grab信令已交付多人邀请,无会控
野火社区 Mesh+TURN;付费 JanusIM 透传 + 闭源 EngineKit客户端闭源社区 REST 是桩付费文档有 super-conference,本批 OSS 树未见可执行处理器
Rocket.Chat一对一 P2P+SIP;会议 join-URL一对一状态机;会议只生成 URL产品内已交付iframe 适配器无(broadcast 是公告)
TinodeP2P ICE话题级 currentCall仅 1:1非 P2P 话题直接拒绝
HuLaP2P ICE客户端 UI 状态仅 1:1,拦群未交付
TurmsLiveKit 插件会议预约日期机 + CreateRoom / token不是通话预约+发证,无 leave/kick/mute
OpenIM OSS遗留 RtcService 桩;SendSignalMessage 调空发送未实现在另一 AGPL 仓

这张表只用来决定谁能当老师。JuggleIM 与 layababa 同构,最值得对照。野火可偷的是消息分型与忙线词表,不能把闭源 EngineKit 或付费 Janus 客户端当成可编译证据。Rocket.Chat 与 HuLa 是产品界面,不是可嵌入引擎。Turms 只提供预约记录与发证隔离的插件形状。OpenIM 开源树在这一维上是反例:协议面写过邀请结构,网关却是恒失败的空发送。

三、一对一呼叫状态机与异常边

真正落地的一对一机器只有几家。JuggleIM 是 Incoming、Outgoing、Connecting、Connected:邀请给主叫发证并抓 Outgoing,被叫先落 Incoming 不算忙;Accept 才把被叫抓成 Connecting 并补发证;已经处于 Outgoing、Connecting 或 Connected 再被抓,返回忙线 16103。振铃三十秒走未接,接通后十秒心跳、五秒扫一次,超时走 PingTimeout。Tinode 更瘦:话题上一个 currentCall,三十秒建立超时,主叫挂是 missed,被叫挂是 declined,对端再邀是 486;忙线只按话题不算用户。野火客户端是闭源的 Idle 到 Connected,对外只露出一组状态码:忙线、信令错、挂断、媒体错、对端挂、摄像头失败、超时、他端已接。Rocket.Chat 原生一对一有完整呼叫状态加更细的客户端状态,一百二十秒租约随在呼信令续期。

layababa 已交付的机器是振铃到接通再到结束,以及振铃到拒绝、取消或超时。状态集合里没有忙线,也没有接通中。邀请先预检凭证配置,再查被叫是否已在进行中的会议,然后创建:先清掉可替换占位,把双方已有的振铃改成取消且理由为替换,再去占活动坑;占坑失败才抛忙线。邀请回执明确不发 UserSig,接听才签发。接听强制设备号,振铃改接通带已接设备的比较交换,败者得到他端已接。振铃权威是 Redis 有序集合加截止任务,默认六十秒;接通后是另一台扫描器,三十秒心跳,十二秒先推不稳定,再按心跳过期强制结束。杀进程策略写在客户端纯函数里:只有振铃可恢复界面,已接通当孤儿挂断,不重新加入。

异常边要分开看。被叫离线时,layababa 强于多数对照:占六十秒振铃槽、三秒在线兜底推、邀请不带进房材料、多端接听有胜者。忙线语义却弱于 JuggleIM,也弱于自家二〇二六年七月五日的设计稿——稿仍写双键占位、任一已存在即忙线回滚,并列出忙线状态;实现却是新邀请静默替换旧振铃。被叫离线、被叫拒接、被叫忙线共用同一个六十秒振铃槽,没有不可达。ICE 失败对第三类 IM 默认不可见:layababa 客户端把 TRTC 连接丢失标成恢复中,不立刻挂;错误与对端离开用世代号加通话号丢弃过期回调。真正的洞在别处:对端离开仍立即结束,没有先等心跳的预算;凭证刷新时刻算好了,客户端只解析、没有定时器;一对一挂断与心跳死亡只出信令、卡片和话单,不入队拆房。媒体面一旦因 UserSig 过期或房间残留出错,业务面只能从 TRTC 回调事后收拾。

Tinode 把 ICE 失败完全交给客户端。HuLa 把断开、失败、关闭一律五百毫秒挂断,这是反模式。Rocket.Chat 把断开当暂态、只在失败或关闭时挂,这个分类值得映射到 TRTC,但仓库里 iceRestart 没有调用方,不能写成他们会重谈 ICE。Turms 的私有会议不是呼叫。OpenIM 开源树没有状态机。

四、会议房间生命周期

可读树里,服务端权威的会议室状态机几乎只有两家:JuggleIM 的 rtcroom,和 layababa 的 HTTP 会议加 Redis 运行时加 TRTC 房间销毁。其余要么是桩,要么是预约,要么是加入链接。

JuggleIM 第一次邀请建房并抓主叫为去电;后续邀请要求邀请者已是成员;平行加入给非成员发证,已在邀请名单里的人报已是成员且不补发令牌,必须走接听。挂断是自己退。多人邀请在挂断路径上若剩余成员全是来电或空,则销毁房间;心跳超时那条路径只在成员数为零时删房。销毁没有房主校验,也不向成员广播。没有踢人、静音、主持人转让。

layababa 会议已经是对照集里的领先面。创建与加入按角色发证。离会撤销媒体,再按在线创建者、否则按入会顺序选在线管理员,写回管理属主和归还截止。踢人耐久:心跳在踢人推送丢失后仍会抛已移除。空房截止落在截止时间字段上,不是堆内定时器;有人回来就清截止。进行中且 Redis 丢失会重建运行时,不会假结束。终态独立入队 TRTC 拆房,与长连接扇出脱钩。主持人心跳过期先给六十秒宽限并推重连,宽限后才断开。

洞也清楚。这六十秒宽限记在进程内的哈希表上:多实例或进程重启会丢宽限,可能双断或跳过等待。会议连麦邀请只推送,没有耐久行和过期取消;接受邀请时,邀请已消失仍可把人推上麦。一对一挂断不走这条拆房端口,会议自己的终态纪律没有回灌给通话。

Turms 把建房与插入放进同一事务,接受时用不同错误码区分已取消、已结束、已过期、未开始、错密码,这个形状该学;但它的取消判断方向可疑,房间结束监听器类型不匹配,静音与踢人从未接到信令。野火社区的会议表没有 broker 处理器。Rocket.Chat 会议控制面诚实到只生成网址。这些都不能当会议室生命周期的老师。

五、直播与连麦

对照集里,独立直播产品几乎只有 layababa。野火付费超级会议是唯一可类比的「多路发布加多路观众」叙述,但本批开源树与 wf-janus 里没有可执行的会议管理器,不能当已读源码。JuggleIM 的多人邀请全员发布,没有观众。其余没有群通话入口,或把广播写成公告。

layababa 直播是第一方对象:在场、是否在麦、举手时间、会话世代、媒体授权世代都落在观众行上。观众进房拿只加入的密钥;举手有五秒拒绝冷却;批准后抬媒体世代,客户端再换证切主播角色。主播不能离开,只能结束或把主持转给在线管理员。结束先吸收全部在场行,再耐久拆房。权限位是对的:观众进房、连麦可发布、主持可管理发布。

失败边却是开的。批准上麦先数在麦人数是否已满四,再比较交换观众行;比较交换只篱笆是否已离场、是否举手、是否未在麦、媒体世代,不管房间席位。两个管理员同时看到三人,可以都写成第四、第五个发布者。TRTC 会兑现每一张可发布的密钥,第五路是业务泄漏,不是媒体拒绝。主播心跳超过约一百二十秒后只标记离开,注释写明观众扫描永不自动结束主持房间,状态仍是直播中,不入队拆房。观众会留在一个没有发布者的 TRTC 房里,直到有人点结束。刷新时刻签发了,直播换证在恢复端口上是空实现。

可偷的不是 Janus 插件,而是发布席位必须是房间级比较交换,最后一名发布者消失必须终态拆房。映射到现有栈的写法是:先对房间做「直播中且在麦数小于四」的原子加一,成功后再做观众行交换,失败则回滚计数;主播宽限到期必须转交或结束并拆房。不要抄野火客户端每秒轮询结束时间当服务端时钟。

六、重连、恢复、多端、后台

稳健性不是一个分数,是六座独立时钟。

重连上,layababa 一对一有服务端快照和对账栅栏;Rocket.Chat 用租约续期,但带旧合约号刷新会把通话挂成未知;Tinode 与 HuLa 把媒体绑在单个连接上,离开即死;JuggleIM 中途掉线等心跳,不立刻挂。会话恢复上,layababa 只恢复振铃界面,接通孤儿挂断——这是产品选择,不是漏写。多端上,layababa 的已接设备比较交换是唯一带类型的胜负;野火用状态码停其他铃;Rocket.Chat 用合约号把其他端标成他处忙。后台与系统来电上,野火是唯一证据充分的推送套件加前台服务加独立静音通道;Tinode 形状对,但系统来电在仓库里仍是待办;layababa 没有 CallKit。崩溃策略上,layababa 写得最清楚。再邀请上,野火有加参与者协议;Tinode 同话题拒绝;JuggleIM 同房间报已存在;layababa 替换振铃而不是忙线。

会议与直播的恢复不对称。会议能从 Redis 回水、转交主持、空房截止;直播主播失联不是终态;主持人宽限在堆上。凭证时钟全线空转:一对一、会议、直播都算出了刷新时刻,客户端没有调度器在过期前换证。这对最长四小时的通话和直播是确定会撞上的墙,只是撞的时刻取决于 UserSig 寿命,不取决于业务心跳。

七、layababa 的长处与短处

长处要按业务流程说,不按厂商口号说。在「IM 占位加场外媒体」这一类里,layababa 是唯一把一对一、会议、直播三条产品线都做成服务端权威机的。邀请不发等待房 UserSig,从根上切断振铃阶段的媒体泄漏。接听是设备级比较交换,他端有明确错误码,不是谁先进入媒体房谁赢。振铃超时在任意节点都能扫到的有序集合上,不绑单机。杀进程合同写死:铃可捡、通话不可诈尸。会议离会会转交、会撤证、会耐久踢人、会在终态拆 TRTC 房;空房有可恢复的截止而不是假结束。直播把观众和连麦的特权写进密钥,而不是给所有人同一张发布票。客户端对过期回调按世代丢弃,避免旧会话打死新会话。接通后心跳先通知再终止,比一掉线就挂断克制。

短处同样具体。占位合同与设计稿相反:新邀请可以偷走别人的振铃,忙线只是错误码,不是状态,也不是任一活动键存在即失败。没有接通中状态,接听后立刻开三十秒心跳,进房慢就会被当成对端断网——把寿命从十秒加到三十秒是在补这个结构洞,不是消灭它。一对一终态不拆 TRTC 房,业务结束后提供商房间可以残留;会议和直播已经证明这条发件箱路径存在,通话只是没接。刷新时刻是死字段。直播主播失联后房间仍在播。连麦席位先数再写。会议连麦邀请是一次性推送。主持人宽限不能跨实例。客户端对端离开仍然瞬时结束。没有系统来电,也没有中途加成员的协议。文档里的忙线状态和「邀请就发双方 UserSig」已经过时,继续对照那份旧稿会把评审带偏。

这些短处没有一项要求换成 Janus 或 LiveKit。它们都是第三类切分里 IM 该做而未做完的事。

八、可偷的机制与追赶顺序

先定判定。说不出谁、在什么场景、多久内会撞上的,一律降为改进。主路径邀请、振铃、接听、挂断已经跑通,没有「不修则当前一对一不能用」的阻断项。下面按产品合同而不是按论文完整度排序。

应当先做的有四件。第一,删掉替换振铃,让占活动坑失败直接忙线。机制来自 JuggleIM:来电不算忙,去电、接通中、已接通才忙;新邀请不得取消一通还在响的铃。可证伪:甲打给乙振铃中,丙再打乙必须忙线,甲到乙仍振铃直到超时、拒绝或取消。这是第二路来电的日常路径,不是边角。第二,一对一所有终态走与会议相同的拆房入队。可证伪:接通后挂断,对应房间号在发件箱时限内出现拆房动作。第三,给已经算出的刷新时刻接定时器,在过期前走现成的换证,并按世代换票。可证伪:半个寿命之后客户端必须换到新 UserSig,下一次鉴权失败只能是栅栏后的业务终态,不能是静默掉媒体。长通话和长直播会撞。第四,直播主播宽限到期不得停留在直播中:转给在线管理员,否则结束并拆房。可证伪:停主播心跳不少于一百二十秒后,状态不再是直播,或出现新的在线主持,且若已结束则有拆房记录。

第二批:连麦席位改房间级比较交换,两人同时批第四、第五个座位时恰好一个满员;主持人六十秒宽限搬到 Redis 心跳键;连麦邀请做成带过期的来电等价行;接通后对端离开先走心跳预算,不要让媒体回调单独终态;心跳死亡的话单不得写成正常完成。

改进项包括补上接通中状态,把进房窗口从加长寿命改成状态不允许心跳杀;把终态理由收成闭集。学野火把挂断原因做成可推送、可入话单的显式枚举,而不是学它的闭源引擎。学 Tinode 把通话记录写成可替换的那条会话气泡。系统来电、中途拉人、把会控命令放到 IM 而媒体面只保留踢出,都是下一阶段产品能力,不是当前合同修复。

不要抄的清单应当写死。不要把短暂断开当致命。不要在握手里下发静态中继密码。不要接没有鉴权的房间结束回调。不要邀请即发证、复进不补票。不要用旧合约号当恢复。不要用进程内一秒轮询当房间时钟。不要把 Janus 或 LiveKit 的 ICE 细节搬进 TRTC 适配器。

验收顺序应当反过来写:先让第二路来电不再偷铃,再让一对一终态拆得掉 TRTC 房,再让长会话能换票,再让无主播的直播结束。这四步都是现有端口上的接线,不引入新媒体栈。做完之后,再谈词表、接通中状态和系统来电。过期设计稿应随第一批修改一起改掉,避免下一轮评审再被带偏。

上一篇消息可靠性对照:im-reliability.pages.dev。本篇由工作流 compare-im-rtc 的源码阅读与对抗核验收口,人工排成文。核验订正过:JuggleIM 全来电拆房只在挂断路径、不在心跳超时;野火超级会议不在本批开源树;layababa 客户端已按世代丢弃过期 TRTC 回调。

限制。阅读时未能对 layababa 做可靠的 origin/dev 对齐,一对一替换占位、拆房缺口、刷新空转、直播宿主残留、席位先数再写,均按当时工作树静态确认。未跑真实 TRTC、双设备或房间回调故障。野火付费会控与 JuggleIM 客户端是否在令牌过期前换票,不在本批本地树的可证范围。