实时通信的第一公里是采集。无论后面的编解码、拥塞控制做得多好,如果采到的画面是模糊的、声音是带回声的,用户体验就无从谈起。浏览器把采集能力收敛到 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 与摄像头采集的差异
| 维度 | getUserMedia | getDisplayMedia |
|---|---|---|
| 选择器 | 可选设备列表 | 系统级选择器,不可绕过 |
| 音频 | 麦克风 | 系统音频,支持有限 |
| 结束方式 | 主动 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 的差异,能避开大多数采集类疑难杂症。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。