媒体采集与设备管理

讲透浏览器端媒体采集:getUserMedia 的约束模型(ideal/exact/min/max)、enumerateDevices 与 devicechange 热插拔、getDisplayMedia 屏幕共享、applyConstraints 动态调参、轨道启停与权限安全,并给出采集质量与性能权衡的工程实践与常见坑。

实时通信的第一公里是采集。无论后面的编解码、拥塞控制做得多好,如果采到的画面是模糊的、声音是带回声的,用户体验就无从谈起。浏览器把采集能力收敛到 getUserMedia 与 getDisplayMedia 两个 API,但它们的约束模型(constraints)语义丰富且容易误用。

本文从约束的精确语义讲起,覆盖设备枚举与热插拔、屏幕共享、运行时调参与权限管理,最后给出采集质量的工程权衡。读完你应该能针对不同设备稳定地采到合适的媒体,而不是靠猜测写死一组分辨率。

一、getUserMedia 基础

1.1 最小调用

const stream = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: true,
});

document.querySelector("#local").srcObject = stream;
stream.getTracks().forEach((track) => {
  console.log(track.kind, track.label, track.getSettings());
});

getUserMedia 返回一个 MediaStream,其中包含一条或多条 MediaStreamTrack。轨道是采集的最小单位,音频与视频各自独立,可以单独启停。

1.2 约束对象的两种形态

约束字段可以写成布尔值,也可以写成对象。布尔值只是对象形态的简写:

写法等价对象含义
video: true{ video: {} }接受任意视频参数
video: false不采集视频拒绝视频
audio: { echoCancellation: true }指定属性精确控制

关键区别在于:布尔 true 是「随便给」,而对象形态中的每个字段都可以带 ideal/exact/min/max 修饰。

二、约束的高级语义

2.1 ideal、exact、min、max

约束字段的值可以是裸值,也可以是带修饰符的对象,语义如下:

修饰符语义不满足时
ideal优先满足,可退让退而求其次,不报错
exact必须精确匹配抛出 OverconstrainedError
min不低于该值无法满足则报错
max不高于该值无法满足则报错
const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    width: { ideal: 1280 },
    height: { ideal: 720 },
    frameRate: { min: 24, ideal: 30, max: 30 },
    facingMode: { ideal: "user" },     // 移动端优先前置
  },
  audio: {
    echoCancellation: { ideal: true },
    noiseSuppression: { ideal: true },
    autoGainControl: { ideal: true },
    sampleRate: { ideal: 48000 },
  },
});

ideal 是最常用的修饰符,因为它不会因为设备不支持而让整个调用失败。exact 要慎用,它会让不支持的设备直接抛错,通常只用于明确的设备选择。

2.2 优雅降级

面对能力参差不齐的设备,正确做法是先给理想约束,再根据 getSettings 的实际结果做降级:

async function captureVideo() {
  const ladder = [
    { width: 1920, height: 1080, frameRate: 30 },
    { width: 1280, height: 720, frameRate: 30 },
    { width: 640, height: 480, frameRate: 24 },
  ];

  for (const level of ladder) {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: {
          width: { ideal: level.width },
          height: { ideal: level.height },
          frameRate: { ideal: level.frameRate },
        },
      });
      console.log("采集成功:", stream.getVideoTracks()[0].getSettings());
      return stream;
    } catch (err) {
      if (err.name !== "OverconstrainedError") throw err;
      console.warn("该档位不支持,尝试下一档");
    }
  }
  throw new Error("没有可用视频档位");
}

三、设备枚举与热插拔

3.1 enumerateDevices

const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((d) => d.kind === "videoinput");
const mics = devices.filter((d) => d.kind === "audioinput");
const speakers = devices.filter((d) => d.kind === "audiooutput");

for (const cam of cameras) {
  console.log(cam.deviceId, cam.label);
}

一个关键细节:在用户授权之前,label 与 deviceId 可能为空字符串。必须先调用一次 getUserMedia 取得权限,才能拿到完整的设备信息。这是隐私保护的设计,不是 bug。

3.2 监听设备变化

外接摄像头、蓝牙耳机随时可能插拔,界面需要实时更新:

navigator.mediaDevices.addEventListener("devicechange", async () => {
  const devices = await navigator.mediaDevices.enumerateDevices();
  renderDeviceList(devices);
});

3.3 切换设备

切换摄像头不能只改约束,需要重新采集并替换轨道,同时保持 RTCPeerConnection 上的 sender 不变:

async function switchCamera(pc, sender, deviceId) {
  const newStream = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { exact: deviceId } },
  });
  const newTrack = newStream.getVideoTracks()[0];
  await sender.replaceTrack(newTrack); // 无需再协商
  // 若使用 addTrack 则必须再协商
}

replaceTrack 的最大优势是不触发再协商,切换过程平滑,不会中断连接。这是实现「切换摄像头」「切换麦克风」的标准做法。

四、屏幕共享 getDisplayMedia

4.1 基本用法

const screen = await navigator.mediaDevices.getDisplayMedia({
  video: {
    frameRate: { ideal: 15, max: 30 },
    width: { ideal: 1920 },
  },
  audio: false, // 系统音频需浏览器支持
});

const track = screen.getVideoTracks()[0];
pc.addTrack(track, screen);

// 用户点击浏览器原生「停止共享」按钮时触发
track.onended = () => {
  console.log("共享结束");
  pc.removeTrack(pc.getSenders().find((s) => s.track === track));
};

4.2 与摄像头采集的差异

维度getUserMediagetDisplayMedia
选择器可选设备列表系统级选择器,不可绕过
音频麦克风系统音频,支持有限
结束方式主动 stop用户可随时点停止
分辨率受摄像头限制常为高分辨率,需主动降采样
移动端支持iOS Safari 支持有限

屏幕共享最常见的性能问题是分辨率过高。桌面分辨率动辄 2560×1440,若不限制帧率与分辨率,编码器会瞬间打满 CPU。生产环境通常把共享流的帧率压到 5 到 15 fps,并对内容变化做检测,静态画面时进一步降帧。

4.3 屏幕共享的编码优化

屏幕内容以文字与静态区域为主,与摄像头画面特性完全不同。适合的编码策略是:

  • 优先使用 VP9 或 AV1 的屏幕内容编码模式(若可用)。
  • 降低帧率,提高单帧清晰度,避免文字模糊。
  • 开启内容提示 contentHint,让编码器调整码率分配策略。
track.contentHint = "text"; // 或 "detail"、"motion"

五、运行时调参

5.1 applyConstraints

采集开始后仍可动态调整参数,无需重新申请权限:

const track = stream.getVideoTracks()[0];
await track.applyConstraints({
  width: { ideal: 640 },
  frameRate: { max: 15 },
});
console.log(track.getConstraints()); // 当前生效的约束
console.log(track.getSettings());    // 实际生效的参数

getConstraints 返回请求的约束,getSettings 返回实际生效的值,二者可能不同,排障时以后者为准。

5.2 enabled 与 stop 的区别

操作效果是否释放硬件
track.enabled = false发送黑帧或静音否,仍占用摄像头
track.stop()彻底停止轨道是,摄像头指示灯熄灭

「静音按钮」应该用 enabled = false,因为它可逆且响应快;「关闭摄像头」应该用 stop() 并移除 sender,以释放硬件资源。混用这两者会造成摄像头指示灯常亮或无法恢复。

六、权限与安全

  • 必须在安全上下文(HTTPS 或 localhost)中调用,否则 mediaDevices 为 undefined。
  • 权限只能在用户手势(点击等)触发的调用栈中申请,自动播放式调用会被拒绝。
  • 页面需要能响应 navigator.permissions.query({ name: "camera" }) 的状态变化,引导用户重新授权。
  • 采集到的轨道若长期不用应 stop(),避免后台占用隐私设备。
  • 屏幕共享必须在用户可见的交互中发起,浏览器会强制弹出选择器。

七、采集质量的工程权衡

采集质量与系统负载直接相关,需要根据场景取舍:

  • 一对一通话:720p、30 fps 通常是画质与带宽的平衡点。
  • 多人会议:受 SFU 与上行带宽限制,建议 480p 或更低,配合 Simulcast。
  • 屏幕共享:优先清晰度而非流畅度,15 fps 足够演示文档。
  • 移动端:考虑电量与发热,帧率与分辨率都要保守。

音频方面,echoCancellation、noiseSuppression、autoGainControl 三项默认开启能解决大部分回声与噪声问题,但在音乐场景下应关闭,否则会损伤音质。这是一个典型的场景相关权衡。

八、音频处理与 3A 算法

音频采集的质量很大程度上取决于三项内置处理,业界称为 3A:回声消除(AEC)、噪声抑制(ANS)、自动增益控制(AGC)。它们默认开启,但在不同场景下需要区别对待。

处理作用应开启的场景应关闭的场景
echoCancellation消除扬声器回灌到麦克风的回声免提通话、外放戴耳机、音乐直播
noiseSuppression抑制稳态背景噪声办公、嘈杂环境音乐、乐器演奏
autoGainControl自动调节音量人声通话音乐、需要保留动态
const audioOnly = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: { ideal: true },
    noiseSuppression: { ideal: true },
    autoGainControl: { ideal: true },
    channelCount: { ideal: 1 },      // 单声道足以通话
    latency: { ideal: 0.01 },        // 争取低延迟
  },
  video: false,
});

音乐场景(如在线乐器教学)应关闭全部 3A,否则 AGC 会把渐强渐弱压平,ANS 会把乐器的谐波当成噪声削掉。这是一个典型的「默认值不适合所有场景」的例子。

8.1 采样率与声道

Opus 内部以 48 kHz 工作,因此采集端指定 sampleRate: 48000 可以避免重采样损失。声道方面,通话用单声道即可,立体声会翻倍码率却对语音无益。若需要立体声音乐,应显式指定 channelCount: 2 并确认设备支持。

8.2 音量检测

在采集端实时检测音量,可以做「正在说话」的 UI 提示或静音检测:

const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);

const data = new Uint8Array(analyser.frequencyBinCount);

function pollVolume() {
  analyser.getByteFrequencyData(data);
  const avg = data.reduce((a, b) => a + b, 0) / data.length;
  console.log("音量等级:", avg.toFixed(1));
  requestAnimationFrame(pollVolume);
}
pollVolume();

注意 AudioContext 在部分浏览器上需要用户手势后才能启动,否则会处于 suspended 状态。

九、常见坑清单

  • 在 getUserMedia 之前调用 enumerateDevices,拿到空的 label 与 deviceId。
  • 用 exact 约束指定设备,设备拔出后直接抛错,未做降级。
  • 切换设备时用 addTrack 而非 replaceTrack,触发不必要的再协商甚至黑屏。
  • 屏幕共享不限制帧率与分辨率,编码器过载导致整体卡顿。
  • 忘记处理 track.onended,用户停止共享后 UI 状态与实际不符。
  • 用 stop() 实现静音,导致无法恢复且摄像头反复重启。
  • 忽略 contentHint,屏幕共享的文字被编码器当作运动画面处理而模糊。
  • 采集后不 stop(),页面隐藏后仍占用设备,移动端耗电严重。
  • 在非 HTTPS 环境下调试,误以为是代码问题。

小结

采集是实时通信质量的上限。getUserMedia 的约束模型提供了从「随便给」到「精确匹配」的连续控制,正确使用 ideal 修饰符并配合降级阶梯,能适配从高端桌面到低端手机的各类设备。设备切换优先用 replaceTrack 避免再协商,屏幕共享则要主动限制帧率与分辨率。采集到的媒体如何编码、如何适应网络,取决于下一环的 音视频编解码与拥塞控制 。理解 enabled 与 stop 的差异、getSettings 与 getConstraints 的差异,能避开大多数采集类疑难杂症。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播