2、音视频采集实战:getUserMedia详解、摄像头/麦克风权限管理、分辨率与帧率设置、设备枚举与切换

音视频采集是WebRTC通话的起点。说白了,如果这一步没做好,后面所有的编码、传输、渲染都是白搭。我在做第一个WebRTC项目时,就踩过采集端的坑——画面卡顿、麦克风没声音、权限弹窗被浏览器拦截……嗯,今天咱们就把这些基础但关键的问题一次性理清楚。

2.1 getUserMedia:采集的入口

getUserMedia 是浏览器提供的媒体采集API。它返回一个 MediaStream 对象,里面包含了音频轨和视频轨。我个人习惯把它叫做“媒体流的源头”。

核心概念: getUserMedia 不是一次性调用就完事的。每次调用都会触发浏览器的权限请求,并且返回的流对象需要妥善管理生命周期。

2.1.1 基本调用方式

// 最简单的调用——同时采集音视频
async function startCapture() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    // 将流绑定到 video 元素
    const videoElement = document.getElementById('localVideo');
    videoElement.srcObject = stream;
  } catch (err) {
    console.error('采集失败:', err);
  }
}

这段代码看起来简单,但实际项目中我遇到过不少坑。比如 video: true 会让浏览器使用默认摄像头,但默认的不一定是你想要的。还有,audio: true 会采集所有音频输入,包括麦克风、系统声音(如果浏览器支持),这可能会引发回声问题。

我的习惯: 在调用 getUserMedia 之前,先检查 navigator.mediaDevices 是否存在。有些旧浏览器或非安全上下文(HTTP)下,这个API是未定义的。

2.1.2 约束(Constraints)详解

约束是 getUserMedia 的精髓。你可以通过约束指定分辨率、帧率、音频采样率等参数。我曾经在项目中因为约束设置不当,导致采集到的视频分辨率与预期不符,画面被拉伸变形。

约束属性 类型 说明 我的建议
width ConstrainULong 视频宽度(像素) 优先使用 ideal 而非 exact
height ConstrainULong 视频高度(像素) 保持宽高比,避免拉伸
frameRate ConstrainDouble 帧率(fps) 通话场景推荐 15-30fps
deviceId ConstrainDOMString 指定设备ID 用于设备切换时必填
echoCancellation boolean 是否开启回声消除 通话场景建议开启
noiseSuppression boolean 是否开启降噪 根据场景决定,音乐场景慎用
// 更精细的约束示例
const constraints = {
  video: {
    width: { ideal: 1280 },
    height: { ideal: 720 },
    frameRate: { ideal: 30, max: 30 },
    facingMode: 'user'  // 前置摄像头
  },
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    sampleRate: { ideal: 48000 }
  }
};

const stream = await navigator.mediaDevices.getUserMedia(constraints);

注意: 使用 exact 约束(如 width: { exact: 1920 })可能导致采集失败,因为设备可能不支持该精确值。优先使用 ideal 或 min/max 组合。

2.2 摄像头/麦克风权限管理

权限问题是WebRTC开发中最常见的“隐形杀手”。用户点了“允许”不代表权限就一定能用。我遇到过好几次,用户误点了“阻止”,然后代码怎么调都不出画面。

2.2.1 权限状态检测

浏览器提供了 permissions API,可以查询当前权限状态。这在调试时非常有用。

// 查询摄像头权限状态
async function checkCameraPermission() {
  try {
    const result = await navigator.permissions.query({ name: 'camera' });
    console.log('摄像头权限状态:', result.state);
    // 'granted' | 'denied' | 'prompt'
    
    result.onchange = () => {
      console.log('权限状态变化:', result.state);
    };
  } catch (err) {
    console.warn('权限查询API不支持:', err);
  }
}

为什么会有 prompt 状态?因为浏览器还没弹出询问框。这时候调用 getUserMedia 就会触发弹窗。如果状态是 denied,调用 getUserMedia 会直接抛 NotAllowedError。

避坑指南: 我曾经在移动端Safari上遇到一个问题——权限状态是 granted,但 getUserMedia 仍然失败。原因是用户之前授权了,但浏览器重启后权限被重置。解决方案是:每次采集前都重新调用 getUserMedia,不要缓存权限状态。

2.2.2 权限被拒绝后的处理

当用户拒绝权限时,我们应该给出友好的提示,而不是让页面卡死。

async function startCaptureWithFallback() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    return stream;
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // 权限被拒绝
      showToast('请允许使用摄像头和麦克风');
      // 可以引导用户去浏览器设置中开启
    } else if (err.name === 'NotFoundError') {
      // 没有找到设备
      showToast('未检测到摄像头或麦克风');
    } else if (err.name === 'NotReadableError') {
      // 设备被其他应用占用
      showToast('摄像头或麦克风被其他应用占用');
    } else {
      showToast('采集失败: ' + err.message);
    }
    return null;
  }
}

2.3 分辨率与帧率设置

分辨率和帧率直接影响通话质量和带宽消耗。不是越高越好,要找到平衡点。我做过一个测试:720p@30fps 和 1080p@15fps,在相同码率下,720p的画面流畅度明显更好。

2.3.1 常见分辨率选择

分辨率 宽高比 推荐场景 带宽消耗(H.264)
640×480 (VGA) 4:3 低带宽通话 ~500kbps
1280×720 (HD) 16:9 标准通话 ~1.5Mbps
1920×1080 (Full HD) 16:9 高清会议 ~3Mbps
3840×2160 (4K) 16:9 专业录制 ~10Mbps

2.3.2 动态调整分辨率

实际项目中,我经常需要根据网络状况动态调整分辨率。WebRTC本身有带宽估计机制,但采集端的分辨率调整可以更主动。

// 动态切换分辨率
async function switchResolution(stream, targetWidth, targetHeight) {
  const videoTrack = stream.getVideoTracks()[0];
  if (!videoTrack) return;
  
  const constraints = {
    width: { exact: targetWidth },
    height: { exact: targetHeight }
  };
  
  try {
    await videoTrack.applyConstraints(constraints);
    console.log(`分辨率已切换至 ${targetWidth}x${targetHeight}`);
  } catch (err) {
    console.warn('分辨率切换失败,使用当前分辨率:', err);
  }
}

// 使用示例:从720p切换到480p
// switchResolution(stream, 640, 480);

注意: applyConstraints 不是所有浏览器都支持完美切换。有些浏览器在切换后需要重新绑定 srcObject 才能生效。我建议在切换后检查 videoTrack.getSettings() 确认实际生效的分辨率。

2.4 设备枚举与切换

用户可能有多台摄像头、多个麦克风。让用户自由切换设备是良好体验的基础。设备枚举API是 enumerateDevices,它返回所有可用的媒体设备列表。

2.4.1 枚举设备

// 获取所有媒体设备
async function getDevices() {
  // 先请求权限,否则枚举结果可能不完整
  await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
    .then(stream => {
      // 用完释放
      stream.getTracks().forEach(track => track.stop());
    })
    .catch(() => {});
  
  const devices = await navigator.mediaDevices.enumerateDevices();
  
  const videoInputs = devices.filter(d => d.kind === 'videoinput');
  const audioInputs = devices.filter(d => d.kind === 'audioinput');
  const audioOutputs = devices.filter(d => d.kind === 'audiooutput');
  
  return { videoInputs, audioInputs, audioOutputs };
}

我的经验: 枚举设备前先调用一次 getUserMedia 获取权限,否则 enumerateDevices 返回的设备列表可能不完整(设备ID为空字符串)。这个坑我踩过两次才记住。

2.4.2 设备切换

切换设备的核心思路:停止旧轨道,用新设备ID创建新轨道,替换到流中。

// 切换摄像头
async function switchCamera(stream, newDeviceId) {
  const videoTrack = stream.getVideoTracks()[0];
  if (videoTrack) {
    videoTrack.stop();  // 停止旧轨道
    stream.removeTrack(videoTrack);
  }
  
  const newStream = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { exact: newDeviceId } },
    audio: false  // 只切换视频
  });
  
  const newVideoTrack = newStream.getVideoTracks()[0];
  stream.addTrack(newVideoTrack);
  
  // 更新 video 元素的 srcObject
  const videoElement = document.getElementById('localVideo');
  videoElement.srcObject = stream;
  videoElement.play();
  
  return newVideoTrack;
}

注意: 切换设备时,旧轨道必须调用 stop() 释放资源,否则摄像头指示灯可能一直亮着。另外,切换后要重新绑定 srcObject,有些浏览器不会自动更新。

2.5 本章知识体系

下面这张图总结了音视频采集的核心流程和关键节点。我建议你把它打印出来贴在工位上。

音视频采集知识体系图 音视频采集核心流程 getUserMedia 权限管理 (Permissions API) 通过 约束设置 (Constraints) MediaStream 对象 视频轨道 (VideoTrack) 音频轨道 (AudioTrack) 设备枚举/切换 绑定到 video/audio 元素 关键:权限 → 约束 → 流 → 轨道管理 → 设备切换,每一步都可能出问题

2.6 常见问题与调试技巧

最后分享几个我在项目中实际遇到的坑,希望能帮你少走弯路。

问题1: 调用 getUserMedia 后,摄像头指示灯亮了,但画面黑屏。

原因: 最常见的是 video 元素没有设置 autoplay 属性,或者没有调用 play() 方法。

解决: 确保 <video autoplay playsinline></video>,并且在移动端加上 playsinline 属性。

问题2: 切换设备后,画面卡在最后一帧。

原因: 旧轨道没有完全停止,或者 srcObject 没有重新赋值。

解决: 切换时先 track.stop(),再 stream.removeTrack(),最后重新设置 srcObject。

问题3: 在iOS Safari上,getUserMedia 返回的音频轨道没有声音。

原因: iOS Safari 要求用户交互后才能启动音频采集,且需要 playsinline 属性。

解决: 在点击事件中调用 getUserMedia,并且音频上下文也要在用户交互后创建。

嗯,音视频采集这块内容就这些。记住一个原则:先检查权限,再设置约束,最后管理轨道。每一步都做好异常处理,你的采集模块就稳了。