一次视频会议的“生命旅程”:从点击加入到大屏相见,Mediasoup 背后发生了什么?

0 阅读5分钟

一、故事的开端:你有没有想过?

当你在腾讯会议、Zoom、飞书会议里点击"加入会议"后,几秒钟内就能看到其他人的画面、听到他们的声音——这背后发生了什么?

微信图片_20260307224848_5604_6.png 最简单的方案是"点对点"连接,但10个人开会就需要45个连接!更好的方案是 SFU(选择性转发单元) :大家把视频发给服务器,服务器转发给其他人。Mediasoup 就是这样的服务器。本文讲基于Mediasoup讲述这背后服务之间是如何进行配合的。

二、三个角色,各司其职

image.png

服务比喻职责
mediasoup-ui电视机采集画面、播放声音、用户交互
signal-bridge信号转换器协议翻译(JSON ↔ protoo)
signal-server播控中心管理房间、转发媒体流

三、一次视频会议的"生命旅程"

让我们跟随一个用户"小马"的视角,看看他从加入会议到看到其他人画面的完整过程:

第一步:小马打开网页 📺

sequenceDiagram
小马->>UI: 点击加入会议
UI->>Server: 建立websocket连接
Server-->>小马: 准备好接收和发送媒体流

第二步:获取"电视频道列表" 📋

// 小马问服务器:你们支持哪些视频格式?
const routerRtpCapabilities = await this.signaling.request('getRouterRtpCapabilities');

// 小马的浏览器检查:这些格式我支持吗?
this.device = new mediasoupClient.Device();
await this.device.load({ routerRtpCapabilities });
// 如果没有报错,说明可以正常通信!

通俗解释:就像你买了一个新电视,先要检查能不能收到当地电视台的信号格式(高清还是标清)。

第三步:铺设"信号线" 🔌

小马需要两条"线":

  • 发送线:把小马的画面传给服务器
  • 接收线:从服务器接收其他人的画面
async createTransports() {
    // 📤 创建发送线
    const sendInfo = await this.signaling.request('createWebRtcTransport', {
        forceTcp: false,
        appData: { direction: 'producer' },  // 我是生产者
    });

    this.sendTransport = this.device.createSendTransport({
        id: sendInfo.transportId,
        iceParameters: sendInfo.iceParameters,      // 冰块参数(网络地址)
        iceCandidates: sendInfo.iceCandidates,      // 候选地址列表
        dtlsParameters: sendInfo.dtlsParameters,    // 加密参数
    });

    // 📥 创建接收线(代码类似)
    const recvInfo = await this.signaling.request('createWebRtcTransport', {
        appData: { direction: 'consumer' },  // 我是消费者
    });
    this.recvTransport = this.device.createRecvTransport({...});
}

Transport: 就像一根水管,你需要两根——一根往里注水(发送),一根往外放水(接收)。

第四步:服务器端铺设"水管" 🏗️

服务器收到请求后,在 mediasoup 里创建真正的 Transport:

// signal-server/Room.ts
const transport = await mediasoupRouter.createWebRtcTransport({
    webRtcServer: mediasoupWebRtcServer,  // 共享端口服务器
    enableUdp: true,   // 支持UDP(更快)
    enableTcp: true,   // 支持TCP(更稳定)
    appData: { direction },  // 记录这是发送还是接收
});

// 返回给客户端
resolve({
    transportId: transport.id,
    iceParameters: transport.iceParameters,
    iceCandidates: transport.iceCandidates,
    dtlsParameters: transport.dtlsParameters,
});

第五步:小马打开摄像头 📹

async enableMic({ stream } = {}) {
    // 1. 向浏览器申请摄像头/麦克风权限
    const localStream = await navigator.mediaDevices.getUserMedia({ 
        audio: true, 
        video: false 
    });
    const track = localStream.getAudioTracks()[0];

    // 2. 通过发送线,把画面发出去
    this.micProducer = await this.sendTransport.produce({ track });
}

关键来了!  当调用 produce() 时,会触发一个事件:

// 监听 'produce' 事件 - 这是 WebRTC 的核心!
this.sendTransport.on('produce', async ({ kind, rtpParameters }, callback) => {
    // 通知服务器:我要发送一个媒体流
    const { producerId } = await this.signaling.request('produce', {
        transportId: this.sendTransport.id,
        kind,              // 'audio' 或 'video'
        rtpParameters,     // 编码参数
    });
    
    // 告诉本地 Transport:服务器已经准备好了
    callback({ id: producerId });
});

第六步:服务器创建 Producer 🎙️

服务器收到请求后,创建一个"生产者"对象:

// signal-server/Peer.ts
case 'produce': {
    const { transportId, kind, rtpParameters, appData } = data;
    const transport = this.getTransport(transportId);
    
    // 🎯 核心API:创建 Producer
    const producer = await transport.produce({
        kind,           // 音频还是视频
        rtpParameters,  // 编码参数
        appData: { 
            peerId: this.id,    // 是谁发的
            source: 'mic',      // 来源是什么
        },
    });

    // 🔔 重要:触发事件,通知房间里其他人
    this.emit('new-producer', { producer });
    
    // 返回 Producer ID 给客户端
    accept({ producerId: producer.id });
}

第七步:其他用户收到小马的画面 👥

Room 监听到 new-producer 事件后,会为其他用户创建 Consumer:

// signal-server/Room.ts
peer.on('new-producer', async ({ producer }) => {
    // 获取房间里除了小明以外的所有人
    const otherPeers = this.getOtherPeers(peer);
    
    // 为每个人创建 Consumer(消费者)
    for (const otherPeer of otherPeers) {
        await otherPeer.consume({ producer });
    }
});

创建 Consumer 的详细过程:

// signal-server/Peer.ts
async consume({ producer }) {
    const transport = this.getRecvTransport();
    
    // 🎯 创建消费者(初始暂停状态)
    const consumer = await transport.consume({
        producerId: producer.id,
        rtpCapabilities: this.rtpCapabilities,
        paused: true,  // 先暂停,等客户端准备好
    });

    // 📢 通知客户端:有新的媒体流可以消费
    await this.request('newConsumer', {
        peerId: producer.appData.peerId,   // 谁发的
        consumerId: consumer.id,
        producerId: producer.id,
        kind: consumer.kind,               // 音频还是视频
        rtpParameters: consumer.rtpParameters,
    });

    // 客户端确认后,恢复传输
    await consumer.resume();
}

第八步:小王的浏览器显示小马的画面 🖥️

// mediasoup-ui 处理 newConsumer 请求
async handleServerRequest(request) {
    if (request.method === 'newConsumer') {
        const { consumerId, producerId, kind, rtpParameters } = request.data;
        
        // 📥 消费这个媒体流
        const consumer = await this.recvTransport.consume({
            id: consumerId,
            producerId,
            kind,
            rtpParameters,
        });

        // 🎬 获取媒体轨道,创建可播放的流
        const stream = new MediaStream([consumer.track]);
        
        // 把流绑定到 video/audio 标签
        const videoElement = document.getElementById('remote-video');
        videoElement.srcObject = stream;
        
        // 接受请求,服务器开始传输
        request.accept();
    }
}

四、完整流程图

sequenceDiagram
    participant UI as mediasoup-ui<br/>(小马浏览器)
    participant Bridge as signal-bridge<br/>(协议转换)
    participant Server as signal-server<br/>(媒体服务器)

    Note over UI,Server: 1️⃣ 建立连接
    UI->>Bridge: WebSocket 连接
    Bridge->>Server: protoo 连接
    Server-->>Bridge: 连接成功
    Bridge-->>UI: protooOpen

    Note over UI,Server: 2️⃣ 获取路由能力
    UI->>Bridge: getRouterRtpCapabilities
    Bridge->>Server: 转发请求
    Server-->>Bridge: router.rtpCapabilities
    Bridge-->>UI: 返回能力
    UI->>UI: Device.load()

    Note over UI,Server: 3️⃣ 创建传输通道
    UI->>Bridge: createWebRtcTransport
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Transport
    Server-->>UI: {transportId, iceParams...}
    UI->>UI: 创建 SendTransport/RecvTransport

    Note over UI,Server: 4️⃣ 加入房间
    UI->>Bridge: join {displayName, rtpCapabilities}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Peer
    Server-->>UI: {peers: [已在线用户]}

    Note over UI,Server: 5️⃣ 打开摄像头
    UI->>UI: getUserMedia()
    UI->>UI: sendTransport.produce()
    UI->>Bridge: produce {kind, rtpParameters}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Producer
    Server-->>UI: {producerId}

    Note over UI,Server: 6️⃣ 其他用户接收
    Server->>Server: 触发 new-producer 事件
    Server->>Server: 为其他 Peer 创建 Consumer
    Server-->>UI: newConsumer 请求
    UI->>UI: recvTransport.consume()
    UI-->>Server: accept
    Server->>Server: consumer.resume()

五、媒体流路由示意图

image.png

六、信令 vs 媒体

flowchart TB
    subgraph Signaling[信令通道 - 控制面]
        S1[WebSocket]
        S2[JSON/protoo 协议]
        S3[传输控制消息]
    end

    subgraph Media[媒体通道 - 数据面]
        M1[WebRTC]
        M2[ICE/DTLS/SRTP]
        M3[传输音视频数据]
    end

    Client[客户端] --> S1
    Client --> M1
    S1 --> Server[服务器]
    M1 --> Server
类型协议传输内容
信令WebSocket + JSON控制消息(加入房间、创建Transport等)
媒体WebRTC (ICE/DTLS/SRTP)音视频数据流

七 关键 API 速查表

mediasoup-client(浏览器端)

API说明使用场景
new Device()创建设备对象初始化时
device.load({ routerRtpCapabilities })加载服务器能力加入房间前
device.createSendTransport()创建发送通道准备发送媒体
device.createRecvTransport()创建接收通道准备接收媒体
transport.produce({ track })生产媒体流打开摄像头/麦克风
transport.consume({ id, ... })消费媒体流接收远程媒体

mediasoup(服务器端)

API说明使用场景
worker.createRouter({ mediaCodecs })创建路由器创建房间时
router.createWebRtcTransport()创建传输通道用户加入时
transport.produce({ kind, rtpParameters })创建生产者用户发送媒体
transport.consume({ producerId, rtpCapabilities })创建消费者分发媒体给其他人
router.pipeToRouter({ producerId, router })跨路由传输高级场景,分离生产/消费

八、写在最后

理解 Mediasoup 的关键点:

  1. SFU 架构:服务器只转发,不编解码,所以延迟低
  2. Transport 是核心:一切媒体传输都通过 Transport
  3. Producer/Consumer 模式:一人生产,多人消费
  4. 信令与媒体分离:WebSocket 传控制消息,WebRTC 传媒体数据
  5. 事件驱动new-producer 事件触发 consume,形成完整链路