11、媒体流统计与监控:getStats API详解、获取音频/视频的收发统计、丢包率、延迟、抖动计算、实时监控面板实现

做WebRTC开发,最怕什么?

我最怕用户说「你那个视频通话卡成PPT了」,但我自己这边一切正常。这时候你拿什么去跟用户对线?凭感觉?不行。

所以,getStats API 就是我们手里最趁手的「黑匣子」。它能告诉你每一帧、每一个包到底经历了什么。今天我就带你把它彻底吃透。

11.1 getStats API 基础用法

先看最核心的东西。getStats 是 RTCPeerConnection 上的一个方法,调用它会返回一个 RTCStatsReport 对象。这个对象里装满了各种统计信息。

// 基础调用
const stats = await peerConnection.getStats();
stats.forEach(report => {
  console.log(report.type, report);
});

嗯,就这么简单。但实际项目中,你不可能把所有的报告都打印出来。你需要知道怎么筛选。

我个人习惯先判断 report.type,然后只处理我关心的类型。常见的类型有:

type 值 含义 关键字段
inbound-rtp 接收端 RTP 统计 packetsReceived, bytesReceived, jitter, packetsLost
outbound-rtp 发送端 RTP 统计 packetsSent, bytesSent, retransmittedPacketsSent
remote-inbound-rtp 远端接收反馈 roundTripTime, fractionLost
track 媒体轨道统计 framesReceived, framesDecoded, framesDropped
candidate-pair ICE 候选对统计 availableOutgoingBitrate, currentRoundTripTime
我的小技巧: 每次调用 getStats 时,先打印一份完整的报告看看。不同浏览器返回的字段名可能略有差异,尤其是 Safari 和 Chrome。我在项目中就踩过这个坑——Safari 的 packetsLost 字段名居然叫 packetsLostByRemote。

11.2 音频收发统计

音频统计,说白了就是看「有没有声音断掉」或者「声音是不是忽大忽小」。

我一般这样获取音频的接收统计:

async function getAudioStats(pc) {
  const stats = await pc.getStats();
  let audioStats = {};

  stats.forEach(report => {
    if (report.type === 'inbound-rtp' && report.kind === 'audio') {
      audioStats = {
        packetsReceived: report.packetsReceived,
        packetsLost: report.packetsLost,
        jitter: report.jitter,
        totalAudioEnergy: report.totalAudioEnergy,
        totalSamplesDuration: report.totalSamplesDuration
      };
    }
  });

  return audioStats;
}

这里有个关键点——totalAudioEnergytotalSamplesDuration 这两个字段,可以用来计算平均音量。公式很简单:

const averageVolume = totalAudioEnergy / totalSamplesDuration;

这个值越大,说明对方说话声音越大。我在做会议系统时,就用这个来判断用户是不是「闭麦了但没完全闭麦」——音量一直为零,但麦克风状态显示开启,那八成是硬件问题。

11.3 视频收发统计

视频统计比音频复杂一些。因为视频有帧率、分辨率、编码质量这些维度。

async function getVideoStats(pc) {
  const stats = await pc.getStats();
  let videoStats = {};

  stats.forEach(report => {
    if (report.type === 'inbound-rtp' && report.kind === 'video') {
      videoStats = {
        framesReceived: report.framesReceived,
        framesDecoded: report.framesDecoded,
        framesDropped: report.framesDropped,
        frameWidth: report.frameWidth,
        frameHeight: report.frameHeight,
        framesPerSecond: report.framesPerSecond,
        jitter: report.jitter,
        packetsLost: report.packetsLost
      };
    }

    if (report.type === 'outbound-rtp' && report.kind === 'video') {
      videoStats.bitrate = report.bitrateMean || report.targetBitrate;
      videoStats.qualityLimitationReason = report.qualityLimitationReason;
    }
  });

  return videoStats;
}

注意 qualityLimitationReason 这个字段。它告诉你为什么视频质量下降了。常见值有:

  • bandwidth —— 带宽不够,自动降质
  • cpu —— 编码器跑不动了
  • none —— 一切正常
我曾经踩过的坑: 有一次用户反馈视频模糊,我查了带宽、丢包率都正常。最后发现是 qualityLimitationReason 显示为 cpu。用户那台老笔记本的 CPU 编码能力跟不上,导致编码器主动降分辨率。后来我加了个「CPU 过载」的提示,用户换台电脑就好了。

11.4 核心指标计算:丢包率、延迟、抖动

这三个指标是衡量通话质量的「三驾马车」。我一个个说。

11.4.1 丢包率

丢包率 = 丢失的包数 / 总包数。但要注意,这个值要取「远端反馈」的才准。

function calculatePacketLoss(stats) {
  let lossRate = 0;

  stats.forEach(report => {
    if (report.type === 'remote-inbound-rtp') {
      const total = report.packetsReceived + report.packetsLost;
      if (total > 0) {
        lossRate = (report.packetsLost / total) * 100;
      }
    }
  });

  return lossRate.toFixed(2); // 返回百分比
}

丢包率超过 5%,用户就能感知到卡顿。超过 10%,基本没法正常通话了。

11.4.2 延迟(RTT)

RTT 就是数据从你这里到对方再回来的时间。这个值可以从 remote-inbound-rtpcandidate-pair 里拿。

function calculateRTT(stats) {
  let rtt = 0;

  stats.forEach(report => {
    if (report.type === 'candidate-pair' && report.nominated) {
      rtt = report.currentRoundTripTime * 1000; // 转为毫秒
    }
  });

  return rtt.toFixed(0);
}

RTT 在 100ms 以内算优秀,200ms 以内可接受,超过 300ms 就会有明显的延迟感。

11.4.3 抖动(Jitter)

抖动是包到达时间的不稳定性。WebRTC 内部已经帮你算好了,直接从 inbound-rtpjitter 字段拿就行。

function calculateJitter(stats) {
  let jitter = 0;

  stats.forEach(report => {
    if (report.type === 'inbound-rtp' && report.kind === 'audio') {
      jitter = report.jitter * 1000; // 转为毫秒
    }
  });

  return jitter.toFixed(1);
}

音频抖动超过 50ms,声音就会「断断续续」。视频抖动容忍度高一些,但超过 100ms 也会出现画面撕裂。

核心公式总结:
  • 丢包率 = (packetsLost / (packetsReceived + packetsLost)) × 100%
  • RTT = currentRoundTripTime × 1000 (ms)
  • 抖动 = jitter × 1000 (ms)

11.5 实时监控面板实现

光有数据不行,你得把它展示出来。我习惯做一个「三栏式」的监控面板。

先看整体结构:

实时监控面板架构 数据采集层 getStats() 定时轮询(每秒1次) 数据处理层 丢包率 / RTT / 抖动 / 帧率 计算 音频统计面板 丢包率 / 抖动 / 音量 音频编码 / 采样率 接收/发送包数 视频统计面板 帧率 / 分辨率 / 码率 丢帧数 / 编码质量 接收/发送帧数 网络统计面板 RTT / 可用带宽 ICE 连接状态 候选对类型

实现代码其实不复杂。核心就是一个定时器,每秒调用一次 getStats,然后更新 UI。

class StatsMonitor {
  constructor(pc, containerId) {
    this.pc = pc;
    this.container = document.getElementById(containerId);
    this.timer = null;
  }

  start() {
    this.timer = setInterval(async () => {
      const stats = await this.pc.getStats();
      const data = this.processStats(stats);
      this.render(data);
    }, 1000);
  }

  processStats(stats) {
    let result = {
      audio: {},
      video: {},
      network: {}
    };

    stats.forEach(report => {
      // 音频接收
      if (report.type === 'inbound-rtp' && report.kind === 'audio') {
        result.audio.packetsLost = report.packetsLost;
        result.audio.jitter = (report.jitter * 1000).toFixed(1);
        result.audio.packetsReceived = report.packetsReceived;
      }

      // 视频接收
      if (report.type === 'inbound-rtp' && report.kind === 'video') {
        result.video.framesPerSecond = report.framesPerSecond;
        result.video.frameWidth = report.frameWidth;
        result.video.frameHeight = report.frameHeight;
        result.video.framesDropped = report.framesDropped;
      }

      // 网络 RTT
      if (report.type === 'candidate-pair' && report.nominated) {
        result.network.rtt = (report.currentRoundTripTime * 1000).toFixed(0);
        result.network.availableBitrate = report.availableOutgoingBitrate;
      }
    });

    // 计算丢包率
    const audioTotal = (result.audio.packetsReceived || 0) + (result.audio.packetsLost || 0);
    result.audio.lossRate = audioTotal > 0
      ? ((result.audio.packetsLost / audioTotal) * 100).toFixed(2)
      : '0.00';

    return result;
  }

  render(data) {
    this.container.innerHTML = `
      <div class="stats-panel">
        <div class="stats-section">
          <h4>🎵 音频统计</h4>
          <p>丢包率: ${data.audio.lossRate}%</p>
          <p>抖动: ${data.audio.jitter}ms</p>
          <p>接收包数: ${data.audio.packetsReceived}</p>
        </div>
        <div class="stats-section">
          <h4>🎬 视频统计</h4>
          <p>帧率: ${data.video.framesPerSecond}fps</p>
          <p>分辨率: ${data.video.frameWidth}x${data.video.frameHeight}</p>
          <p>丢帧数: ${data.video.framesDropped}</p>
        </div>
        <div class="stats-section">
          <h4>🌐 网络统计</h4>
          <p>RTT: ${data.network.rtt}ms</p>
          <p>可用带宽: ${(data.network.availableBitrate / 1000).toFixed(0)}kbps</p>
        </div>
      </div>
    `;
  }

  stop() {
    clearInterval(this.timer);
  }
}

// 使用方式
const monitor = new StatsMonitor(peerConnection, 'stats-container');
monitor.start();
我的建议: 不要在生产环境每秒都调 getStats。我一般设成 2 秒一次,既保证实时性,又不给浏览器太大压力。另外,记得在页面销毁时调用 monitor.stop(),否则定时器会一直跑。

11.6 监控面板的视觉设计

数据有了,怎么展示才直观?我总结了几条原则:

  • 颜色预警: 丢包率 < 1% 绿色,1%-5% 黄色,> 5% 红色
  • 数值变化: 用动画或闪烁提示用户「数据在变化」
  • 关键指标置顶: RTT、丢包率、抖动这三个放在最显眼的位置

嗯,其实你想想看,用户最关心的就是「我现在的通话质量好不好」。所以面板上最好有一个「综合评分」,把三个指标加权算出一个分数。我在项目中就是这么做的:

function calculateQualityScore(stats) {
  let score = 100;

  // 丢包扣分
  if (stats.lossRate > 5) score -= 30;
  else if (stats.lossRate > 2) score -= 15;
  else if (stats.lossRate > 1) score -= 5;

  // RTT 扣分
  if (stats.rtt > 300) score -= 25;
  else if (stats.rtt > 200) score -= 10;
  else if (stats.rtt > 100) score -= 5;

  // 抖动扣分
  if (stats.jitter > 100) score -= 20;
  else if (stats.jitter > 50) score -= 10;

  return Math.max(0, score);
}

这个评分虽然简单,但很实用。用户一看就知道「现在是绿色,没问题」还是「红色,该检查网络了」。

好了,getStats 这块的内容差不多就这些。从 API 调用到指标计算,再到面板实现,一条线串下来。你只要照着做,就能给你的 WebRTC 应用加上一双「眼睛」。


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