一、从一个“看起来很美好”的录制需求说起

很多人第一次接触 MediaSoup,都会觉得它就像一个聪明的“搬运工”:把说话人的声音和画面,原封不动地搬到其他听众的屏幕上。搬运这件事,MediaSoup 做得又快又好。于是你就会冒出一个念头:“既然它都能搬,那我在这条路上放一个录音机,把正在搬的货也存一份,不就成了吗?”

我有个朋友,就真的这么干了。他用 MediaSoup 搭了一个在线课堂,课程结束后要把老师讲课的视频一键保存下来。他以为只要在服务器上找个地方,把每个学生的音视频 RTP 包写进文件,就大功告成。结果录出来的东西,画面里的人嘴巴明明已经闭上了,声音却还在嗡嗡地说着上一句话。那种感觉,就像看译制片配音对不上口型一样难受。更诡异的是,有时候画面和声音的延迟还在来回漂移,一会儿快,一会儿慢,看得人头皮发麻。

为什么会这样?真的不是 MediaSoup 太弱,而是我们对 RTP 时间戳的理解太天真。本文要聊的,就是一套能真正避开这个坑的录制方案。不整高深理论,只讲大白话,然后把每一步该怎么做,代码怎么落地,都摊开给你看。

二、为什么时间戳会乱掉?不藏着掖着

要理解音画为什么不同步,先得知道 RTP 包上的时间戳到底是个什么东西。你可以把它理解成包上的“出厂日期”,表示这个包里装的媒体数据是在哪一刻“出生”的。但是,这个日期并不是全世界统一的,而是每个设备自己暗暗记下的。

比如,一台手机在录像时,它的视频时间戳从 0 开始,每 1/90000 秒加一。另一台手机同时也在录像,它的视频时间戳也从 0 开始。但是,这两台手机并不是同时开机,也不共用同一个晶振。所以它们的“0”根本不对齐。音频也一样,只不过音频的采样率可能是 48000,时间戳每跳一下代表 1/48000 秒。

现在,MediaSoup 把这些流都转发给你。你手上拿到了一堆 RTP 包,每个包都有自己的时间戳。但这些时间戳是“各自为政”的,没有可比性。你如果把音频和视频的时间戳直接放在一起比较,就好比一个用北京时间和纽约时间记比赛成绩,最后还要比谁跑得快,那肯定一团糟。

更麻烦的是,不同设备的物理时钟跑得也不一样快。有的芯片温度高一点,时钟可能就快一点点;有的省电模式开着,时钟可能又慢一点点。这就是所谓的“时钟漂移”。哪怕一开始你把两边对齐了,过上几分钟,漂移又会把误差一点一点放大。

所以,在你做录制之前,第一步要意识到:RTP 时间戳不能当“绝对时间”用。它只是每个流内部的一条相对刻度。要让音频和视频画在同一条时间轴上,必须找到每个流与一个共同绝对时钟的对应关系。而这个关系,藏在 RTCP 的 SR 报文里。

三、录制方案的整体思路:先拿到流,再修正时间

既然问题根源明确了,解决方案也就顺理成章了。我们只需要做两件事:

第一,把需要录制的音频和视频 RTP 流,从 MediaSoup 里“导出”到我们自己控制的录制进程里。第二,在导出的时候,同时把 RTCP 的 SR 报文也拿过来。SR 报文里会写明:当前绝对时间是多少,此刻我的 RTP 时间戳又是多少。有了这个关系,我们就能把一个流内部的所有 RTP 时间戳都换算成绝对时间。

等音频和视频都被换算到同一个绝对时间坐标上,它们就同步了。后面你可以把它们丢给封装工具,比如 FFmpeg,让它按绝对时间把音视频写入同一个文件。

3.1 选好“出口”:PlainTransport

MediaSoup 提供了好几种传输类型,但录制场景下,最顺手的是 PlainTransport。为什么要用它?因为它不挑“对象”,像一条普通的网线,只要把你的 IP 和端口告诉它,它就能把 RTP 包直接塞到那个端口上。相比 WebRTCTransport 那种还要跟浏览器握手的复杂方式,PlainTransport 清爽很多。

另外,录制时我们不希望 RTP 和 RTCP 混在一起,因为混在一起会增加解析难度。PlainTransport 可以设置 rtcpMux: false,让 RTP 和 RTCP 分别走两个不同的 UDP 端口。这样我们就能在录制端用两个 socket 各自监听,一个专门收媒体包,一个专门收控制包,互不干扰。

3.2 让 RTCP SR 来“对表”

RTCP 是 RTP 的“控制台”。SR 报文的全称是 Sender Report,由发送方周期性发出。它里面最值钱的信息是一对时间戳:一个 NTP 时间戳,一个 RTP 时间戳。NTP 时间戳是全世界通用的绝对时间,精确到可以表示小数秒;RTP 时间戳就是发送方那时内部的媒体计数器。

每次收到一个 SR,就相当于对方在广播:“请注意,我的 RTP 时间戳 X,对应的绝对时间是 Y。”你多收到几条这样的广播,就能在它们之间做换算。音频和视频虽然各自有各自的 SR,但它们的 NTP 时间都来自同一个标准体系,所以换算出来的绝对时间是可以直接互相比较的。这就是“对表”的含义。

四、关键一步:在 MediaSoup 中导出音视频流(Node.js 示例)

现在开始写代码。为了照顾大多数读者,这里统一用 Node.js 和 mediasoup 官方库。下面这段代码会创建一个 PlainTransport,把某个 Producer 的流导出到你本机的录制服务。

// 技术栈:Node.js + mediasoup v3
const mediasoup = require('mediasoup');

// 一个已经创建好的 Router,以及一个你想录制的 Producer
async function startRecording(router, producerId, rtpCapabilities) {
  // 1. 创建 PlainTransport,专门用于把 RTP 包送出去
  const transport = await router.createPlainTransport({
    listeningIp: {
      ip: '0.0.0.0',
      announcedIp: '127.0.0.1'   // 对外通告的 IP,这里先用本机
    },
    rtcpMux: false,               // RTP 和 RTCP 分开,简单好解析
    comedia: false                // 不使用自动发现,我们明确告知地址
  });

  // 2. 告诉 MediaSoup,你的录制服务监听的 IP 和端口
  //    这里假设录制服务已经在本机的 5000 端口收 RTP,
  //    在 5001 端口收 RTCP。
  await transport.connect({
    ip: '127.0.0.1',
    port: 5000,
    rtcpPort: 5001
  });

  // 3. 在这个传输上创建 Consumer,指定要录制的 Producer
  const consumer = await transport.consume({
    producerId: producerId,
    rtpCapabilities: rtpCapabilities,
    paused: false
  });

  // 4. 调用 resume,让数据真正开始流动
  await consumer.resume();

  console.log(`已经成功启动录制:${producerId} -> 127.0.0.1:5000`);

  return { transport, consumer };
}

这段代码里的 rtpCapabilities 不能乱填。它必须是客户端在加入房间时上报给服务端的那份能力,服务端一般会存到一个地方,比如内存或数据库。你创建 Consumer 时,MediaSoup 会根据这份能力去协调编码,如果传错了,Consumer 可能创建不成功。

另外,PlainTransport.connect() 里面的 port 是指录制服务监听 RTP 的 UDP 端口,rtcpPort 是监听 RTCP 的 UDP 端口。如果你的录制进程和 MediaSoup 在同一台机器上,可以用 127.0.0.1。如果不在同一台机器,就要写实际的内网地址,并且确保防火墙放行这些 UDP 端口。

五、核心算法:把 RTP 时间戳换算成同一个“绝对时间”(Node.js 示例)

这一步是整个方案的心脏。我们会用 Node.js的dgram 模块分别监听 RTP 和 RTCP 端口,然后解析 SR 报文,再把收到的 RTP 包时间戳换算成绝对时间。为了让你看得更明白,下面代码里保留了很多注释。

// 技术栈:Node.js
const dgram = require('dgram');

// 用一个 Map 保存每个 SSRC 对应的同步信息
const syncTable = new Map();

// 解析 RTCP SR 报文(RFC 3550 中的 Sender Report,类型为 200)
function parseRtcpSr(packet) {
  // 第三个字节是分组类型,200 代表 SR
  const packetType = packet.readUInt8(1);
  if (packetType !== 200) return;

  // SR 的结构:前 4 字节是头部,8 字节是发送者 SSRC
  const ssrc = packet.readUInt32BE(8);

  // NTP 时间戳占 8 字节,前 4 字节是秒数,后 4 字节是 2^-32 秒的小数
  const ntpMsw = packet.readUInt32BE(16);
  const ntpLsw = packet.readUInt32BE(20);
  const ntpSeconds = ntpMsw + ntpLsw / 4294967296;

  // RTP 时间戳占 4 字节,在偏移 24 的位置
  const rtpTimestamp = packet.readUInt32BE(24);

  // 把这对映射关系存起来
  syncTable.set(ssrc, { ntpSeconds, rtpTimestamp });
}

// 把一个 RTP 包的时间戳换算成绝对时间
// sampleRate 是该流的时间戳频率,比如视频通常是 90000,音频可能是 48000
function convertRtpToAbsoluteTime(rtpPacket, sampleRate) {
  // RTP 包头里,SSRC 在偏移 8,时间戳在偏移 12
  const ssrc = rtpPacket.readUInt32BE(8);
  const rtpTs = rtpPacket.readUInt32BE(12);

  const ref = syncTable.get(ssrc);
  if (!ref) {
    // 还没收到这个 SSRC 的 SR,暂时无法换算
    return null;
  }

  // 当前 RTP 时间戳距离参考点的时间差(秒)
  const rtpDiff = rtpTs - ref.rtpTimestamp;
  const secondOffset = rtpDiff / sampleRate;

  return ref.ntpSeconds + secondOffset;
}

// 创建 UDP socket,一个收 RTP,一个收 RTCP
const rtpSocket = dgram.createSocket('udp4');
const rtcpSocket = dgram.createSocket('udp4');

rtpSocket.on('message', (msg) => {
  // 这里假设收到的全是视频流,采样率是 90000
  const absTime = convertRtpToAbsoluteTime(msg, 90000);
  if (absTime !== null) {
    console.log(`视频包绝对时间:${absTime.toFixed(6)} 秒`);
  }
});

rtcpSocket.on('message', (msg) => {
  parseRtcpSr(msg);
});

rtpSocket.bind(5000);
rtcpSocket.bind(5001);

代码里最值得琢磨的地方是 rtpTs - ref.rtpTimestamp 这一步。因为 RTP 时间戳是 32 位无符号整数,绕一圈后可能会变成更小的数。严谨的做法是用无符号回绕减法,也就是把差值控制在 2^31 范围内。对于长时间运行的录制,一定要处理这一点,否则会产生一个巨大的突跳。

实际上,在生产环境中,你多半不会只写一个 console.log,而是要把转换后的时间戳附在数据包上,送给后续的封装模块。你可以把 RTP 负载、绝对时间、SSRC 等信息合成一个新的对象,再交给混流器。混流器根据绝对时间排序,音视频自然就同步了。

六、怎么保存成音画同步的文件?可以找 FFmpeg

时间戳换算是“对表”,但是最终要把音频和视频写进同一个文件,还是需要封装工具。老牌工具 FFmpeg 是很多人的首选,它能从 UDP 端口拉取 RTP 流,也能使用 RTCP SR 做自动同步。你只需要给 FFmpeg 一个 SDP 文件,把每个流的编码、端口、采样率、负载类型等信息都写清楚。

比如,你可以让 Node.js 在启动录制时,根据 Consumer 的 rtpParameters 生成一份 SDP,然后启动 FFmpeg 进程去读取。

下面是一条典型的 FFmpeg 命令格式,具体 SDP 文件需要你自行生成:

ffmpeg -protocol_whitelist file,udp,rtp -f sdp -i recording.sdp -c copy output.mp4

-c copy 的意思是直接复制已经编码好的 H264 和 Opus 数据,不重新编码,所以速度飞快。如果封装成 MP4 时提示不支持该编码,也可以改成 MKV 或者 WebM,具体看你的编码类型。

要注意的是,FFmpeg 的同步算法也不是万能的。如果你的 RTP 流里 RTCP SR 包间隔过长,或者本身就没有 SR,那 FFmpeg 也只能靠运气。

七、应用场景与优缺点

7.1 适合用这套方案的地方

视频会议录制是第一个典型场景。与会者的设备五花八门,时钟各不相同,音画同步要求却很高。在线课堂回放也一样,老师讲一段,学生提问,这些内容后期要剪成片段,如果音画不同步,根本没法剪。直播存档、游戏比赛复盘,也都依赖这套“先对表、再封装”的思路。

7.2 技术优缺点

优点方面,这套方案没有给客户端添任何麻烦。所有录制逻辑都在服务器侧或专用录制服务里,客户端的 WebRTC 连接照常使用。同时,它用的是标准 RTP/RTCP 协议,而不是 MediaSoup 的私有格式,所以录制模块可以独立于 MediaSoup 发展,换了别的 SFU 也能复用一部分。

缺点方面也很明显。第一,你要自己处理时间戳换算、回绕、漂移等细节,代码量不小。第二,UDP 本身会丢包,虽然 MediaSoup 可以做重传,但录制端如果直接用裸 UDP 接收,依然可能遭遇网络抖动。第三,多路流录制时需要管理很多端口,在运维上比普通 WebRTC 通信要复杂一点。第四,如果后期要混多路人声和画面,你需要额外做混流和音量归一化,这已经超出了录制本身的范围。

八、注意事项:坑都在哪

这里单独列出几个很容易踩的坑,也算是对前面内容的补充。

第一个坑是 RTP 时间戳回绕。视频 90000 赫兹,32 位时间戳大概 13 小时就会绕一圈。音频 48000 赫兹,大概 24 小时也会绕一圈。长时间录制时必须用无符号差值法,否则时间轴会突然倒转。

第二个坑是时钟漂移。即便有 SR 对表,不同设备的时钟快慢也不同。建议每隔几秒就用新的 SR 更新一次映射表,不要只取第一个 SR 当成永久换算公式。

第三个坑是丢包。RTP 包丢了,时间戳就会产生一个缺口。如果丢的是视频关键帧,画面会花屏。录制端最好记录丢包率,并且在封装时标记这些不连续点,方便后期补救。

第四个坑是端口阻塞。录制服务如果部署在云主机上,一定要检查安全组是否放行了 UDP 端口。很多人明明代码写得没错,最后发现数据根本没到,就是因为防火墙拦住了。

第五个坑是采样率要匹配。解析 SR 和换算时间戳时,必须知道每个 Payload Type 对应的采样率。比如 H264 通常 90000,Opus 通常是 48000。如果搞错了,换算出来的绝对时间会翻倍或减半,同步自然就乱了。

第六个坑是不要在发送端使用“本地接包时间”代替绝对时间。因为网络抖动会让包到达时间忽早忽晚,根本不能代表真实采集时间。一定要解析 SR 里的 NTP 时间戳,那才是源端的客观时间。

九、文章总结

说了这么多,其实核心就是一句话:别直接拿 RTP 时间戳当绝对时间用。要让它变得可靠,就得靠 RTCP SR 报文里的 NTP 信息,把每个流的时间戳都“翻译”到同一条时间线上。

本文给出了一条完整的录制路径:用 PlainTransport 把 MediaSoup 中的音视频流导出来,再把 RTCP SR 包收集起来,用 Node.js 做时间戳换算,最后交给 FFmpeg 封装。整个过程中,最难的不是写代码,而是建立起“对表”的思维。理解了这一点,剩下的细节都可以边做边补。

MediaSoup 本身是一个出色的 SFU,它不帮你解决录制问题,但给了你足够的能力去自己解决。希望这篇文章能让你在录制的路上少踩几个坑,录出来的东西真正“音画同步”。