15、mediasoup实战入门:核心概念与第一个SFU Demo

好,咱们今天来聊点真家伙。前面讲了那么多WebRTC的理论,从信令到NAT穿透,从SDP协商到媒体流控制。但说实话,没有实际跑过一个SFU服务,你永远觉得自己在纸上谈兵。

我个人觉得,mediasoup是当前Node.js生态里最成熟的SFU方案。它不像Janus那么重,也不像Licode那么难配。说白了,它就是一个轻量级、高性能的SFU引擎,底层用C++跑,上层用Node.js控制。今天我们就把它拆开看看。

mediasoup的核心概念

在动手之前,咱们先把几个关键概念理清楚。我当年第一次看mediasoup文档时,就被Router、Transport、Producer、Consumer这几个词绕晕了。其实它们的关系很简单——

一句话总结:Router是会议室,Transport是门,Producer是说话的人,Consumer是听的人。

来,我们逐个拆解。

Router

Router是mediasoup中最顶层的概念。它代表一个独立的媒体路由空间。你可以把它理解成一个虚拟的会议室。每个Router内部有自己的RTP端口池、编解码器配置、带宽估计策略。

我在项目中遇到过一个问题:同一个进程里创建了多个Router,结果某个Router的配置错误导致所有Router都受影响。后来才发现,Router之间虽然是隔离的,但它们共享同一个Worker进程的资源。所以,每个Router的配置要独立检查

// 创建Router的示例
const worker = await mediasoup.createWorker();
const router = await worker.createRouter({
  mediaCodecs: [
    {
      kind: 'audio',
      mimeType: 'audio/opus',
      clockRate: 48000,
      channels: 2
    },
    {
      kind: 'video',
      mimeType: 'video/VP8',
      clockRate: 90000
    }
  ]
});

Transport

Transport是客户端和Router之间的连接通道。它负责传输媒体数据。mediasoup支持两种Transport:

  • WebRtcTransport:基于ICE/DTLS的标准WebRTC传输
  • PlainTransport:纯RTP传输,适合与媒体服务器对接

嗯,这里要注意:每个客户端至少需要两个Transport——一个用于发送(Producer),一个用于接收(Consumer)。当然,你也可以复用同一个Transport,但我建议分开,方便后续做带宽控制和QoS。

我的习惯:每个客户端创建两个WebRtcTransport,一个叫sendTransport,一个叫recvTransport。命名清晰,排查问题也方便。

Producer

Producer就是媒体生产者。当客户端通过Transport向Router发送音视频流时,Router会创建一个Producer对象来管理这个流。

每个Producer对应一个媒体轨道。也就是说,如果你同时发送音频和视频,你会得到两个Producer——一个audio Producer,一个video Producer。

// 客户端创建Producer
const producer = await sendTransport.produce({
  kind: 'video',
  rtpParameters: {
    codecs: [...],
    encodings: [...]
  }
});

Consumer

Consumer是媒体消费者。当其他客户端想要接收某个Producer的流时,Router会创建一个Consumer对象。

Consumer有一个很重要的特性:它可以做流的选择性转发。比如,你可以只接收视频流中的某一层(Simulcast场景),或者只接收音频流。这在带宽受限的场景下非常有用。

我曾经踩过的坑:Consumer创建后,如果不主动调用consumer.resume(),客户端是收不到数据的。因为mediasoup默认是暂停状态,需要你显式恢复。这个细节文档里有写,但很容易忽略。

核心关系图

下面这张图把上面四个概念的关系画清楚了。你看一眼就明白整个数据流是怎么走的。

mediasoup Worker Router(会议室) WebRtcTransport WebRtcTransport Producer(发送者) Consumer(接收者) 媒体流(RTP) 客户端A(发送端) 客户端B(接收端) Worker Router Transport Producer Consumer 客户端

搭建mediasoup开发环境

环境搭建这块,我踩过的坑比代码写的还多。mediasoup依赖一些原生模块,编译起来挺折腾的。下面是我总结的、最稳的步骤。

系统要求

组件 推荐版本 备注
Node.js 18.x 或 20.x 不要用奇数版本,坑多
Python 3.8 - 3.11 编译node-gyp需要
C++编译器 GCC 10+ 或 MSVC 2022 Windows上装Visual Studio Build Tools
操作系统 Ubuntu 22.04 / macOS 14+ Windows也能跑,但生产环境建议Linux

安装步骤

  1. 安装mediasoup
mkdir my-sfu-demo && cd my-sfu-demo
npm init -y
npm install mediasoup@3

安装过程会编译原生模块。如果卡在编译步骤,八成是Python或C++工具链的问题。我建议先跑一下 node-gyp rebuild 测试环境是否正常。

我曾经遇到的坑:在Windows上,mediasoup编译需要Python 3.x,但系统里装了多个Python版本。结果node-gyp死活找不到正确的Python路径。解决方案是设置环境变量:set PYTHON=C:\Python311\python.exe

  1. 安装信令服务器依赖

mediasoup本身不处理信令,我们需要自己搭一个简单的WebSocket信令服务。

npm install ws uuid
  1. 创建项目结构
my-sfu-demo/
├── server.js          # mediasoup服务端
├── client.html        # 浏览器客户端
├── package.json
└── node_modules/

创建第一个SFU Demo

好了,环境搭好了,概念也清楚了。咱们直接写代码。这个Demo实现的功能很简单:两个浏览器页面通过mediasoup进行音视频通话。

服务端代码(server.js)

const mediasoup = require('mediasoup');
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');

let worker, router;
const peers = new Map();

async function start() {
  worker = await mediasoup.createWorker({
    logLevel: 'warn',
    rtcMinPort: 40000,
    rtcMaxPort: 49999
  });

  router = await worker.createRouter({
    mediaCodecs: [
      { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
      { kind: 'video', mimeType: 'video/VP8', clockRate: 90000 }
    ]
  });

  console.log('mediasoup Worker和Router已启动');
}

start();

// WebSocket信令服务
const wss = new WebSocket.Server({ port: 3000 });

wss.on('connection', (ws) => {
  const peerId = uuidv4();
  peers.set(peerId, { ws, transports: [], producers: [], consumers: [] });

  ws.on('message', async (data) => {
    const msg = JSON.parse(data);

    switch (msg.type) {
      case 'get-router-rtp-capabilities':
        ws.send(JSON.stringify({
          type: 'router-rtp-capabilities',
          data: router.rtpCapabilities
        }));
        break;

      case 'create-transport':
        const transport = await router.createWebRtcTransport({
          listenIps: [{ ip: '0.0.0.0', announcedIp: '127.0.0.1' }],
          enableUdp: true,
          enableTcp: true,
          preferUdp: true
        });
        peers.get(peerId).transports.push(transport);
        ws.send(JSON.stringify({
          type: 'transport-created',
          data: {
            id: transport.id,
            iceParameters: transport.iceParameters,
            iceCandidates: transport.iceCandidates,
            dtlsParameters: transport.dtlsParameters
          }
        }));
        break;

      case 'connect-transport':
        const transportToConnect = peers.get(peerId).transports
          .find(t => t.id === msg.data.transportId);
        await transportToConnect.connect({ dtlsParameters: msg.data.dtlsParameters });
        ws.send(JSON.stringify({ type: 'transport-connected' }));
        break;

      case 'produce':
        const transportForProduce = peers.get(peerId).transports
          .find(t => t.id === msg.data.transportId);
        const producer = await transportForProduce.produce({
          kind: msg.data.kind,
          rtpParameters: msg.data.rtpParameters
        });
        peers.get(peerId).producers.push(producer);
        // 通知其他peer有新的Producer
        broadcastProducers(peerId, producer);
        ws.send(JSON.stringify({
          type: 'producer-created',
          data: { id: producer.id }
        }));
        break;

      case 'consume':
        const transportForConsume = peers.get(peerId).transports
          .find(t => t.id === msg.data.transportId);
        const consumer = await transportForConsume.consume({
          producerId: msg.data.producerId,
          rtpCapabilities: msg.data.rtpCapabilities
        });
        peers.get(peerId).consumers.push(consumer);
        ws.send(JSON.stringify({
          type: 'consumer-created',
          data: {
            id: consumer.id,
            producerId: consumer.producerId,
            kind: consumer.kind,
            rtpParameters: consumer.rtpParameters
          }
        }));
        break;
    }
  });
});

function broadcastProducers(senderId, producer) {
  for (const [peerId, peer] of peers) {
    if (peerId !== senderId) {
      peer.ws.send(JSON.stringify({
        type: 'new-producer',
        data: {
          producerId: producer.id,
          kind: producer.kind
        }
      }));
    }
  }
}

客户端代码(client.html)

<!DOCTYPE html>
<html>
<head>
  <title>mediasoup Demo</title>
</head>
<body>
  <h2>mediasoup SFU Demo</h2>
  <button id="startBtn">开始通话</button>
  <div>
    <video id="localVideo" autoplay muted playsinline style="width:300px"></video>
    <video id="remoteVideo" autoplay playsinline style="width:300px"></video>
  </div>

  <script src="https://unpkg.com/mediasoup-client@3/dist/mediasoup-client.min.js"></script>
  <script>
    let device, sendTransport, recvTransport;
    let localStream, remoteStream;
    const ws = new WebSocket('ws://localhost:3000');

    document.getElementById('startBtn').onclick = async () => {
      // 获取本地媒体
      localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
      document.getElementById('localVideo').srcObject = localStream;

      // 创建mediasoup设备
      device = new mediasoupClient.Device();

      // 获取Router能力
      ws.send(JSON.stringify({ type: 'get-router-rtp-capabilities' }));
      ws.onmessage = async (event) => {
        const msg = JSON.parse(event.data);
        if (msg.type === 'router-rtp-capabilities') {
          await device.load({ routerRtpCapabilities: msg.data });
          await createTransports();
          await produceMedia();
        }
      };
    };

    async function createTransports() {
      // 创建发送Transport
      ws.send(JSON.stringify({ type: 'create-transport' }));
      // 创建接收Transport
      ws.send(JSON.stringify({ type: 'create-transport' }));
    }

    async function produceMedia() {
      // 发送音频
      const audioTrack = localStream.getAudioTracks()[0];
      const audioProducer = await sendTransport.produce({ track: audioTrack });
      // 发送视频
      const videoTrack = localStream.getVideoTracks()[0];
      const videoProducer = await sendTransport.produce({ track: videoTrack });
    }
  </script>
</body>
</html>

我的建议:第一次跑Demo时,先用两个浏览器标签页测试。一个开client.html,另一个也开client.html。点击「开始通话」后,如果两个页面都能看到对方的画面,说明SFU跑通了。

运行与测试

node server.js

然后在浏览器打开 client.html。注意,因为用了 getUserMedia,需要用HTTPS或者localhost访问。

如果一切正常,你会看到本地视频画面,同时另一个标签页也会收到你的视频流。这就是最基础的SFU Demo了。

核心要点回顾:

  • Router是媒体路由的核心,负责管理所有Transport
  • Transport是客户端与Router之间的连接通道
  • Producer负责发送媒体流,Consumer负责接收媒体流
  • mediasoup的信令需要自己实现,通常用WebSocket
  • 环境搭建时注意Python和C++编译器的版本兼容性

说实话,这个Demo虽然简单,但它包含了SFU最核心的流程。你把这个跑通了,后面加房间管理、加用户列表、加带宽自适应,都是在这个基础上做加法。

嗯,今天就先到这里。代码都在上面了,建议你亲手敲一遍。遇到问题别慌,mediasoup的日志很详细,打开 logLevel: 'debug' 看看,大部分问题都能找到线索。

公众号:蓝海资料掘金营,微信deep3321