这篇文章主要分享我的视频会议项目实现思路,其中有ai完善的部分但是重点还是在于记录其中的细节处理和参数选择,毕竟音视频项目的突出特点就是繁杂.项目中,

FFmpeg 负责采集、编解码、重采样、封装;Qt 负责线程调度、信号槽与最终渲染;libdatachannel 负责 WebRTC 侧的 PeerConnection、Track 和 RTP packetizer;SRS 负责 RTMP 接入、WHIP/WHEP 信令入口,以及 rtc_to_rtmp / rtmp_to_rtc 这类桥接能力。

一、采集

媒体入口非常直接:音频和视频采集都通过 Capture 类完成。代码上它是一个单类双通道实现,内部同时维护视频和音频两套 AVFormatContext、stream index、参数和读取状态。Windows 下设备输入格式固定走 dshow,也就是 DirectShow + FFmpeg libavdevice 这条组合。

采集这一步看起来简单,真正重要的是两件事。

第一件事是设备打开后,必须尽快把 codec parameters 和 time base 固定下来。项目在 openVideoopenAudio 里做的事情很明确:打开输入设备,调用 avformat_find_stream_info,再用 av_find_best_stream 拿到目标媒体流,然后把 AVCodecParameters 拷贝出来,把对应流的 time_base 也一起带走。这些参数后面会直接影响解码、PTS 推导、重采样、编码时间基和网络发送。

第二件事是处理设备包没有可靠 PTS这个问题。采集链路里最容易被忽视的坑就是:本地设备输入并不总能给出可直接消费的 packet->pts。我的做法很简单:如果 AVPacket 的 PTS 为空,就用 av_gettime() 和设备打开时的起始时间做差,先得到一个微秒级时间戳,再通过 av_resc5 ale_q 映射回采集流自己的 time_base。也就是说,没有把采集出来的裸包时间戳不稳定这个问题继续往后传,而是在入口先做了一次兜底。

这一点非常关键。因为后面的本地预览、编码器送帧、RTMP mux 和 WebRTC 发送都在假设时间轴至少是单调可解释的。入口如果不做兜底,后面会在完全不同的模块里爆出更难排查的问题。

采集出来的数据没有直接扔给某个同步函数去硬处理,而是全部进入线程安全队列。项目里 QUEUE_DATA<T> 是整个媒体系统真正的骨架,它让采集、解码、编码、推流、拉流和渲染都变成了一组通过队列耦合的异步阶段。

二、编解码:本地预览和网络发送共享同一套中间态

这个项目的一个核心选择,是没有把本地预览和网络发送分成两套完全独立的处理路径,而是让解码后的帧成为中间态:视频解码后既可以转 QImage 做预览,也可以继续作为 AVFrame 送去编码;音频解码后先重采样、进 FIFO,再按目标帧长切成一帧一帧的编码输入。

1. 视频路径

视频路径的入口是采集出来的 AVPacketffmpegVideoDecoder 收到包之后,先 avcodec_send_packet / avcodec_receive_frame 把它解成 AVFrame。接下来做了两件事。

第一件事是把解码帧 clone 一份送入后续编码队列。为了让视频编码器能在比较稳定的时基上工作,这里没有直接沿用解码帧的原始 PTS,而是用输入流 time_basedecodedFrame->pts 重映射到一个假定 25fps 的帧序号时间轴上。本地预览和网络发送最终都需要更可控的编码节奏,所以在 decoder 和 encoder 之间先做一次统一整理,往往比指望所有上游时钟天然一致更稳。

第二件事是把视频转成 RGB,生成 QImage,送进显示队列。这里用的是 sws_getContext + sws_scale。项目里特意保留了 formatChanged 检测,如果宽高或像素格式变化,就重新初始化 SwsContext 和 RGB buffer。对于远端拉流场景,分辨率和像素格式都不该假设永远不变。

另外值得注意的是:视频帧送入编码队列前,会检查队列深度,超过阈值就丢帧,而不是继续无脑堆积。因为本地预览不能被编码器反压拖死。项目在这里做的是一种典型的优先保 UI 和实时性,牺牲部分完整性的选择。

2. 音频路径

相比视频,音频链路更复杂的部分不在 avcodec_receive_frame,而在如何把上游各种形状的 PCM 统一成下游编码器和播放设备都愿意接受的固定格式。

项目里本地采集后的音频先进入 ffmpegAudioDecoder。这个模块把音频目标格式收敛到:

  • 48000 Hz
  • mono
  • AV_SAMPLE_FMT_FLTP
  • 固定帧长 960

这里的 960 很重要。因为 WebRTC 侧最终走的是 Opus,而 Opus 在 48k 下 20ms 的典型帧长就是 960 sample。AAC(RTMP)Opus(WebRTC) 对帧长的要求并不一致,这也是开发过程中经常出现音频边界问题的根源之一。

解决方式不是做一个通用的、动态自适应的音频帧调度器,而是先把音频在 decoder 阶段固定进 AVAudioFifo,然后只要 FIFO 积累到 960 个 sample,就切出一个完整 AVFrame 送给编码器。这种先归一化,再切帧的方法,对调试和稳定性都更友好。

另一个很有价值的实现细节是 SwrContext 的自动重建。项目没有假设输入音频的 channel layout、sample format、sample rate 永远稳定,而是允许 swr_convert_frame 失败后把旧的 SwrContext 直接释放,下一个循环再根据当前真实的 decodedFrame 参数重建。这种失败即重建的策略,比起把所有输入格式变化都提前枚举出来,更符合现实世界里媒体流的脏数据特性。

3. 编码:H264/AAC/Opus 三套目标,对应两条协议链路

编码器由 ffmpegEncoder 统一承载,但实际有三种初始化路径:

  • 视频:libx264
  • 音频 RTMP:AAC
  • 音频 WebRTC:libopus

视频编码参数非常典型,重点在于低延迟而非高画质:

  • pix_fmt = YUV420P
  • time_base = 1/25
  • framerate = 25/1
  • bit_rate = 2Mbps
  • gop_size = 25
  • max_b_frames = 0
  • preset = ultrafast
  • tune = zerolatency
  • profile = baseline
  • level = 3.1
  • refs = 1

这些参数背后的取舍非常清楚:牺牲压缩效率,换取更低的编码延迟、更简单的解码兼容性,以及更适合 WebRTC 的时序行为。

音频编码则分成两类目标:

  • RTMP 侧走 AAC,采样率固定 48k,码率 64kbps
  • WebRTC 侧走 Opus,采样率固定 48k,码率 48kbps,并显式设置 application=voipvbr=on

三、协议对接:libdatachannel 与 SRS 是怎么真正接上的

如果说前面的采集和编解码解决的是我手里有没有稳定的媒体帧,那么协议对接解决的就是这些帧到底怎么进 SRS,又怎么从 SRS 出来。

1. SRS 侧配置:真正决定成败的是 candidate、ICE 角色和桥接开关

项目使用的 SRS 配置并不复杂,但有几个开关非常关键:

  • 打开 http_api,监听 1985
  • 打开 http_server,监听 8080
  • 打开 rtc_server
  • 设置 candidate
  • vhost 里打开 rtc
  • 开启 rtmp_to_rtc on
  • 开启 rtc_to_rtmp on
  • 开启 nack on
  • 允许 SRS 在答复里以 ICE-Lite 角色工作
  • 低延迟相关配置:tcp_nodelay onmin_latency on
  • 播放侧关闭 gop_cache

这里最重要的不是支持 WebRTC,而是三件更底层的事。

第一,candidate 必须是对端真正能到达的地址。WebRTC 里最常见的一类信令成功但没媒体的问题,本质上不是 SDP 协商失败,而是 answer 里给出的 candidate 根本不可达。这个项目的 SRS 配置显式指定了 candidate 10.0.0.7,容器启动命令里也通过环境变量注入候选地址,这一步实际上是在为 ICE 成功铺路。

第二,要理解 SRS 在这套链路里的 ICE/DTLS 角色。结合项目日志可以看到,SRS 回答 SDP 时带了 a=ice-litea=setup:passive。从协议角度看,这很合理:WHIP 标准里服务端在公网可达的前提下可以使用 ICE-Lite,而客户端必须实现 full ICE;DTLS 方面,发布端通常以 actpass 发起,服务端再在 answer 中落到 passive。也就是说,这条链路不是两端对等的浏览器 PeerConnection,而是原生客户端主动发起,SRS 以可控服务器角色收敛握手。

第三,rtc_to_rtmprtmp_to_rtc 要一起打开。因为这个项目并没有把 RTMP 和 WebRTC 当成两套完全隔离的世界,而是默认 SRS 要承担两边互通的桥。后面 PLI 的处理逻辑其实也能看到这一点:当 WebRTC 侧播放器向 SRS 请求关键帧时,SRS 会把这个需求再反向传递回发布端。

2. 推 WebRTC:用 libdatachannel 直接构建 sendonly PeerConnection

WebRTC 推流由 WebRTCPublisher 完成。它没有额外接一个浏览器式信令层,也没有引入更重的 SDP 管理器,而是直接用 libdatachannel 创建 PeerConnection

配置阶段主要做了几件事:

  • 设置多个 STUN server
  • 指定 mtu = 1500
  • 限制 UDP 端口范围

接下来分别创建视频和音频描述:

  • 视频使用 rtc::Description::Video

  • 添加 H264 编码,payload type 为 96

  • 同时声明 RTX,payload type 为 97

  • direction 设为 SendOnly

  • 音频使用 rtc::Description::Audio

  • 添加 Opus 编码,payload type 为 111

  • direction 设为 SendOnly

然后用 RtpPacketizationConfig 给两条 Track 配上 packetizer:

  • 视频走 H264RtpPacketizer
  • 音频走 OpusRtpPacketizer

这一步非常关键,因为它明确了项目在 WebRTC 发送侧的边界:FFmpeg 负责编出已经压缩好的 H264/Opus elementary stream,libdatachannel 负责把这些编码后数据继续 packetize 成 RTP。

也就是说,这里不是FFmpeg 一路负责到底,而是FFmpeg 到压缩码流为止,RTP 打包权交给 libdatachannel。这个边界是清晰的,也是合理的。

如果从更细的实现视角看,这里其实还隐含了几个对实时传输很有帮助的选择:

  • 视频 payload type 固定为 96,RTX 为 97
  • 音频 payload type 固定为 111
  • 视频时钟按 90000
  • 音频时钟按 48000
  • 发送方向明确为 SendOnly

这些都在主动缩窄 SDP 和 RTP 层的自由度,减少互操作中的不确定性。对于原生客户端来说,这种少即是多的策略通常比做一套大而全的编解码协商更容易稳定。

3. WHIP/WHEP 的标准形态,与当前实现的差距

项目与 SRS 的 WebRTC 对接采用的是 WHIP/WHEP 风格的 HTTP 信令入口:

  • 发布走 /rtc/v1/whip/
  • 拉流走 /rtc/v1/whep/

先说标准形态。

截至整理文档时的协议状态,WHIP 已经在 2025 年 3 月 成为正式 RFC,也就是 RFC 9725WHEP 仍处于工作组草案阶段,最新公开版本是 draft-ietf-wish-whep-03,发布时间为 2025 年 8 月 18 日。两者在信令面上的共同核心都很简单:客户端用 HTTP POST 发送 application/sdp 的 Offer,服务端返回 201 Createdapplication/sdp 的 Answer,并附带 Location 头指向会话资源;后续如果要做 Trickle ICE 或 ICE Restart,则通过会话资源上的 PATCH 完成,ETag/If-Match 用来保护增量更新的一致性。

4. 为什么还要处理 H264 起始码:因为 WebRTC 不接受模棱两可的 NALU 边界

这是整个协议对接里最值得单独拿出来讲的点之一。

视频编码器用 libx264,同时设置了:

  • annexb=1
  • repeat_headers=1

这背后的根本原因,是 WebRTC 侧 RTP 发送更偏好 in-band 的 SPS/PPS,也就是关键帧附近的 NALU 流里就要带上解码所需参数;而 RTMP/FLV 侧更常见的做法,是把 H.264 解码配置放进 AVCDecoderConfigurationRecord 这一类封装元数据里,再把后续 NALU 流交给播放器。

项目甚至在注释里直接写明了这件事:WebRTC 的 RTP 流通常要求 SPS/PPS 在 IDR 前面的 NALU 流中直接发送,而 RTMP 更偏向把参数写到 FLV/RTMP 头。

但是光有 repeat_headers=1 还不够。实际工程里还会遇到另一个问题:编码出来的 H264 起始码既可能是 00 00 01,也可能是 00 00 00 01。而 packetizer 这一层如果对起始码边界不够确定,最终就可能在对端造成帧界切分问题。

所以项目又额外做了一步 normalizeH264StartCodes,把所有三字节起始码统一补成四字节起始码,再送进 m_videoTrack->send()。这是一个很典型的理论上不一定必须,工程上强烈建议的动作。它不优雅,但非常有效。

5. PLI 与关键帧:把浏览器播放器的诉求真正传回编码器

SRS 配置里开了 nack,日志里也能看到它会在播放端需要恢复视频时触发 PLI。项目在 WebRTCPublisher 中监听这类事件,然后通过 PLIReceived 信号一路转发给 MainWindow,最终落到 ffmpegEncoder::requestKeyFrame()

这里的关键不是收到 PLI 了,而是把 PLI 从协议层一直传回编码层。否则播放器请求关键帧,发布端如果完全无感知,就只能等下一个自然到来的 IDR,恢复速度会明显变差。

编码器侧的处理也很直接:收到请求后,把下一帧的 pict_type 强制设成 AV_PICTURE_TYPE_I。这是整个系统中很漂亮的一次纵向打通:SRS/RTCP -> libdatachannel -> Qt signal -> x264 encoder

6. 拉 WebRTC:接收端真正的难点不是 SDP,而是 RTP 之后的那一段

接收端 WebRTCPuller 走的是另一条对称但不完全相同的路径。

它同样建立 PeerConnection,但 Track 全部设成 RecvOnly

  • 视频声明 H264 + RTX
  • 音频声明 Opus

连接建立后,真正收到的是 rtc::binary 形式的 RTP 数据。这里有个很值得注意的 libdatachannel 侧语义:项目不是等远端动态触发 onTrack 再去分配消费者,而是预先用 addTrack(recvonly) 把媒体槽位建好,再在已有 Track 上通过 onMessage 收 RTP。对于单流、固定媒体类型的原生客户端来说,这是一种非常直接的 transceiver 预配置思路。

也就是说,接收端不是拿到一个可以直接喂给 FFmpeg 的完整压缩帧,而是拿到一堆 RTP packet。这个阶段如果不自己做 jitter、重排序和组帧,FFmpeg 根本吃不下去。

于是项目在 WebRTCPullerffmpegVideoDecoder/AudioPlayer 之间插了一个非常重要的桥:RTPDepacketizer

它做的事情包括:

  • RTP 包先进入 RTPJitter
  • 视频包按 90000 时钟建 jitter buffer
  • 音频包按 48000 时钟建 jitter buffer
  • 视频 H264 处理 FU-A
  • 音频 Opus 直接取 payload
  • 最终拼回 FFmpeg 能接受的 AVPacket

这里的设计边界也很清楚:libdatachannel 只负责把 Track 数据交出来,RTPDepacketizer 负责把 RTP 世界翻译回 FFmpeg 世界。

四、推流与拉流:同一套本地骨架,在 RTMP 与 WebRTC 上分叉

协议层接好了之后,整个系统就自然分成了两条推流链路和两条拉流链路。

1. RTMP 推流:FFmpeg 负责到底

RTMP 这一侧相对传统。RtmpPublisher 直接读取编码后的统一 packet 队列,根据 stream_index 区分音视频,再把编码器时间基下的 PTS/DTS 用 av_packet_rescale_ts 转到输出流时间基,最后调用 av_interleaved_write_frame 写入 FLV muxer。

这条链路最核心的经验其实只有一句话:不要偷懒地假设编码器时间基和输出流时间基天然一致。项目在 init 阶段显式保存了视频和音频编码器的 time_base,推流时再做严格 rescale。这能避开大量本地看着没问题,RTMP 一上服务器就时间戳错乱的坑。

2. WebRTC 推流:编码后码流进入 RTP packetizer

WebRTC 推流则不是送给 muxer,而是送给 Track。视频包先做起始码归一化,再通过 m_videoTrack->send() 发给 H264 RTP packetizer;音频包直接送到 Opus track。

这意味着 RTMP 和 WebRTC 的真正分叉点在编码后的码流交给谁:

  • RTMP:交给 FFmpeg muxer
  • WebRTC:交给 libdatachannel packetizer

这正是项目中最值得保留的边界之一。因为它把压缩码流生产和协议输出明确拆开了。

3. RTMP 拉流:demux 之后分别进解码和播放

RtmpPuller 的逻辑比较直白:打开输入流、找到音视频 stream、持续 av_read_frame,然后分别塞进内部的音视频队列。视频继续走 ffmpegVideoDecoder,音频走 AudioPlayer

这里其实也可以看出项目的一个务实选择:RTMP 拉流不尝试把音视频重新统一成某个更抽象的远端媒体帧对象,而是直接复用现有 decoder/player 组件。这让播放器模块足够快地可用。

4. WebRTC 拉流:把 RTP 世界重建回播放器世界

WebRTCPuller 的拉流路径要复杂得多。因为它收到的不是 demux 后的压缩帧,而是 RTP packet。前面协议节已经说过,这里要经过 jitter buffer 和 depacketizer,最后才能得到 FFmpeg 能接受的 H264/Opus AVPacket

视频这边,如果是单 NALU 包,直接补上 00 00 00 01 起始码;如果是 FU-A 分片,就要根据 startBit/endBit 重组 NALU。音频这边则简单很多,Opus payload 直接封成 AVPacket 即可。

这一步完成后,WebRTC 拉流链路才终于和 RTMP 拉流链路重新会合:

  • 视频统一进入 ffmpegVideoDecoder
  • 音频统一进入 AudioPlayer

这也解释了为什么这个项目虽然同时支持 RTMP 和 WebRTC,但最终没有分裂成两套完全独立的播放系统。它们在网络入口分叉,在本地播放前重新汇合。

五、渲染:最终落地只有两个出口,QImage 和 QAudioSink

无论媒体是本地采集来的,还是 RTMP/WebRTC 拉回来的,最后真正落到用户界面的出口只有两个。

第一个是视频。ffmpegVideoDecoder 把 YUV 帧转成 RGB,构造 QImage,塞进显示队列,再由 VideoWidgetpaintEvent 里按保持长宽比的方式绘制。这部分没有太多技巧,重点在于它没有让 UI 线程直接碰 FFmpeg 帧内存,而是通过 QImage::copy() 做了一次安全的深拷贝,把生命周期问题彻底隔离开。

第二个是音频。远端音频最终都进入 AudioPlayer,统一重采样到 48000 / stereo / S16,再写给 QAudioSink。这里项目做得比较细的一点,是播放前先根据设备名称做一次输出设备选择,并且在写声卡之前用 bytesFree() 做缓冲区剩余容量检查。

这个检查看起来普通,实际上非常有用。因为它等于在播放器尾部实现了一个最轻量的背压保护:声卡缓冲区空间不够,就不继续把 FIFO 内容往下倒,直接等下一轮事件循环。这样做虽然会牺牲一点吞吐效率,但能避免播放线程因为写阻塞而拖垮前面的解码链路。

六、关键点

1. 入口先兜底 PTS,比在链路后半段修时序更便宜

本地设备采集得到的包不一定带稳定 PTS,这件事如果不在采集阶段处理,后面会演变成 decoder 时序漂移、encoder 输出异常、RTMP mux 时间戳错乱,或者 WebRTC 播放端抖动。项目在采集入口用 av_gettime() 手动补 PTS,是一个很工程化的选择。

2. 视频编码要低延迟,B 帧基本就别想了

这个项目的视频编码器明确关掉了 max_b_frames,同时设成 ultrafast + zerolatency。对实时通信来说,这是比追求更好压缩率更正确的方向。B 帧、多参考帧和复杂 lookahead 会显著抬高编码延迟,也会让时间顺序更复杂。

3. WebRTC 侧 H264 最容易出问题的地方不是编码本身,而是参数集和起始码

annexb=1 + repeat_headers=1 + 起始码归一化 这一整套组合,几乎就是项目里为 WebRTC/H264 打出来的稳定性护栏。单独只做其中一步,往往都不够。

4. PLI 一定要穿透到编码器,不然恢复速度会非常差

很多 demo 级 WebRTC 项目只做到收到远端流,但没有把播放端对关键帧的请求真正回传到编码器。这套代码里 PLI -> 强制 I 帧 的路径,是整条链路最值得保留的反馈闭环之一。

5. 音频问题几乎都绕不开固定帧长

项目里无论是本地音频编码还是远端音频播放,最后都不约而同落到了 AVAudioFifo 和固定帧长上。这不是巧合。音频链路稳定性的本质,往往不是能不能解码,而是能不能把任意输入形状收敛成下游愿意接受的固定时间片。

6. SwrContext 要允许失败后重建,不要把输入格式变化当异常分支

这条经验同时出现在本地音频解码和远端音频播放里。真实媒体系统里,输入格式变化不是极端情况,而是常见情况。失败后释放旧 swr,下一轮按真实帧参数重建,远比试图维护一个永不出错的静态配置更靠谱。

7. jitter buffer 的名义毫秒数有时比精确时长更稳定

RTPDepacketizer 给视频包设置 payload_ms = 5 这一点非常有意思。它不是严格从每个包推出真实帧时长,而是用一个足够稳定的名义值,让 jitter buffer 更早进入可播放状态。这种做法看上去不够理论正确,但在乱序和抖动条件下反而更稳。

8. 播放尾部一定要做非阻塞保护

无论是显示队列深度控制,还是 QAudioSink::bytesFree() 检查,本质上都是一件事:不要让渲染和播放把前面的实时处理链路反压死。项目里没有引入复杂的流控框架,而是用几个小的限流点把最危险的阻塞位置卡住了,这很有效。

9. 标准信令做最小实现可以很快跑通,但互操作边界要心里有数

当前实现没有把 LocationETagPATCH、Trickle ICE、ICE Restart 和 WHEP counter-offer 这些完整协议能力做完,所以它的优势是路径短、调试面小、非常适合单一服务端联调;代价是它对 SRS 当前行为有依赖,迁移到更严格的 WHIP/WHEP 服务端时,互操作风险会明显上升。

这类实现可以作为非常好的原型,但如果目标从跑通切换到跨服务端兼容,信令面一定是下一步最值得补完的部分。

10. 协议问题最好配合服务端日志一起看

这个项目保留了 srs.txt 和 HTML 推流调试日志,这件事比单纯保留客户端日志更有价值。尤其是在排查 WHEP empty remote sdpPLI、候选地址或 H264 协商问题时,只看客户端代码往往不够,必须对照 SRS 的 session 日志才能判断到底是 Offer 发错了、Answer 没吃进去,还是媒体面建立了但解码面有问题。

11. 要正确评估 WebRTC->RTMP 桥的行为,必须把服务端已知限制也算进来

SRS 官方文档里明确提到一个已知现象:当 WebRTC 流被桥接到 DVR、RTMP 播放或 HTTP-FLV 时,前 4-6 秒 的音频可能缺失,这被归因于它的音视频同步机制,而不是单纯的 bug。这个信息很重要,因为它提醒我们一件事:当你在做端到端调优时,不能把所有问题都归到客户端编码器、网络抖动或 depacketizer 头上,服务端桥接路径本身也可能带有结构性代价。


本站由 Edison.Chen 创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。