项目实战:直播系统(二)——WHIP推流与WHEP拉流实现

上一章我们把直播系统的整体架构搭起来了。今天咱们来啃两块硬骨头:WHIP推流客户端WHEP拉流播放器。说白了,就是让主播能把自己的画面声音推上去,观众能流畅地看直播。

我个人习惯把这两个模块放在一起讲,因为它们本质上是WebRTC的"发"和"收"两端。你搞懂了推流,拉流就是反过来走一遍流程。但这里有个坑——延迟控制和QoS优化,才是真正拉开专业和业余差距的地方。

WHIP推流客户端实现

WHIP(WebRTC-HTTP ingestion protocol)是IETF的标准协议。它让浏览器或客户端通过HTTP信令,把音视频流推给服务器。我当年第一次接触时觉得这玩意儿挺绕,后来发现核心就三步:

  1. 创建PeerConnection
  2. 采集本地音视频
  3. 通过HTTP交换SDP

来看代码。这是推流客户端的核心逻辑:

class WHIPPusher {
  constructor(whipEndpoint) {
    this.endpoint = whipEndpoint;
    this.pc = null;
    this.stream = null;
  }

  async start() {
    // 1. 采集本地流
    this.stream = await navigator.mediaDevices.getUserMedia({
      video: { width: 1280, height: 720, frameRate: 30 },
      audio: { echoCancellation: true, noiseSuppression: true }
    });

    // 2. 创建PeerConnection
    this.pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    });

    // 3. 添加轨道
    this.stream.getTracks().forEach(track => {
      this.pc.addTrack(track, this.stream);
    });

    // 4. 创建Offer并发送
    const offer = await this.pc.createOffer();
    await this.pc.setLocalDescription(offer);
    
    // 5. 通过HTTP POST发送给WHIP服务器
    const response = await fetch(this.endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/sdp' },
      body: offer.sdp
    });

    const answerSdp = await response.text();
    await this.pc.setRemoteDescription({
      type: 'answer',
      sdp: answerSdp
    });

    console.log('WHIP推流已启动');
  }

  stop() {
    this.stream?.getTracks().forEach(t => t.stop());
    this.pc?.close();
  }
}
避坑指南:我曾经在生产环境遇到过一个问题——推流端SDP里没有指定codec优先级,结果服务器选了H.264,但客户端只支持VP8。画面直接黑屏。后来我强制在createOffer时设置codec顺序,问题解决。

这里有个细节:WHIP服务器返回的HTTP响应里,Location头部会带一个资源URL。你可以用这个URL来做后续的推流控制,比如暂停或停止。嗯,这个设计挺巧妙的,把WebRTC的状态管理映射到了RESTful API上。

WHEP拉流播放器实现

WHEP(WebRTC-HTTP egress protocol)是WHIP的镜像协议。观众端通过HTTP获取SDP,然后建立WebRTC连接接收流。说白了,就是把推流的"发"变成"收"。

我建议你直接复用WHIP的代码结构,把方向反过来就行:

class WHEPPlayer {
  constructor(whepEndpoint) {
    this.endpoint = whepEndpoint;
    this.pc = null;
    this.videoElement = null;
  }

  async play(videoElement) {
    this.videoElement = videoElement;

    this.pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    });

    // 接收远程流
    this.pc.ontrack = (event) => {
      if (event.track.kind === 'video') {
        this.videoElement.srcObject = event.streams[0];
      }
    };

    // 创建Offer(注意:WHEP是客户端发Offer)
    const offer = await this.pc.createOffer();
    await this.pc.setLocalDescription(offer);

    const response = await fetch(this.endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/sdp' },
      body: offer.sdp
    });

    const answerSdp = await response.text();
    await this.pc.setRemoteDescription({
      type: 'answer',
      sdp: answerSdp
    });
  }

  stop() {
    this.pc?.close();
    this.videoElement?.srcObject?.getTracks().forEach(t => t.stop());
  }
}
注意:WHEP和WHIP虽然流程相似,但信令交互的语义不同。WHIP的POST是创建资源,WHEP的POST是订阅流。服务器端处理逻辑完全不一样。别想着偷懒共用同一个端点。

延迟控制与QoS优化

直播最怕什么?卡顿和延迟。我见过不少团队,推流拉流都通了,但延迟3秒以上,观众体验极差。这里我分享几个实战中验证过的优化手段。

1. 码率自适应

网络状况是动态变化的。你不能固定一个码率。我习惯用RTCRtpSender.getStats()实时监控发送质量,然后动态调整编码参数:

async function adaptiveBitrateControl(sender) {
  const stats = await sender.getStats();
  let roundTripTime = 0;
  
  stats.forEach(report => {
    if (report.type === 'candidate-pair' && report.state === 'succeeded') {
      roundTripTime = report.currentRoundTripTime;
    }
  });

  // RTT > 200ms 说明网络差,降码率
  if (roundTripTime > 0.2) {
    const params = sender.getParameters();
    params.encodings[0].maxBitrate = 500000; // 降到500kbps
    await sender.setParameters(params);
    console.log('网络变差,降码率到500kbps');
  }
}

核心思路:不要等用户抱怨卡顿了再调整。用RTT、丢包率、抖动这些指标做主动预测。我一般在RTT超过150ms时就触发降码率,等恢复后再升回去。

2. NACK与FEC的平衡

WebRTC默认用NACK(丢包重传)来恢复丢包。但直播场景下,重传会增加延迟。我建议这样配置:

场景 推荐策略 说明
低延迟直播(<500ms) FEC + 少量NACK 前向纠错提前恢复,减少重传等待
普通直播(1-3s) NACK为主 带宽利用率高,延迟可接受
弱网环境 FEC + 降分辨率 先保证流畅,再考虑清晰度

你想想看,FEC虽然会多消耗一些带宽,但能避免重传带来的RTT等待。在实时互动场景里,这个取舍是值得的。

3. JitterBuffer优化

接收端的抖动缓冲是延迟的隐形杀手。默认的jitter buffer会为了平滑而引入额外延迟。我建议在直播场景下调小它:

// 在创建PeerConnection时设置
const pc = new RTCPeerConnection({
  iceServers: [...],
  // 减小抖动缓冲
  rtcpMuxPolicy: 'require',
  // 启用BWE(带宽估计)
  bwe: 'transport-cc'
});

// 接收端可以设置playoutDelayHint
const receiver = pc.getReceivers()[0];
if (receiver) {
  receiver.playoutDelayHint = 0.2; // 200ms缓冲
}
我的经验:playoutDelayHint这个参数很多人不知道。它直接告诉浏览器"我最多能接受200ms的缓冲延迟"。默认值通常是500ms甚至更高。调小后延迟能降一半,但偶尔会有轻微卡顿。你需要根据场景做权衡。

知识体系总览

下面这张图把WHIP推流、WHEP拉流和QoS优化的关系串起来了。你可以看到,推流端和拉流端通过信令服务器交换SDP,然后建立P2P连接。QoS优化贯穿整个链路。

WHIP/WHEP直播系统架构 WHIP推流客户端 采集 → 编码 → 打包 SDP Offer → HTTP POST 信令/媒体服务器 SDP交换 流媒体转发 WHEP拉流播放器 解包 → 解码 → 渲染 SDP Offer → HTTP POST SDP Offer SDP Answer QoS优化策略(贯穿全链路) 码率自适应 RTT/丢包率监控 NACK + FEC 丢包恢复策略 JitterBuffer优化 playoutDelayHint 目标:延迟 < 500ms,丢包率 < 1%

这张图里,WHIP和WHEP通过信令服务器交换SDP,建立媒体通道。QoS优化不是某个点的任务,而是从采集到渲染全链路都要考虑。我习惯把码率自适应放在推流端,把jitter buffer优化放在拉流端,NACK/FEC两端都要配合。

总结一下:WHIP推流和WHEP拉流的核心就是SDP的HTTP交换。QoS优化的本质是在延迟和流畅度之间找平衡。没有银弹,只有根据你的场景做取舍。我建议你先把基础流程跑通,再逐步加入优化策略,每次只改一个参数,观察效果。

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