浏览器音频采集实践:麦克风权限、降噪、回声消除与 PCM 转换

在线会议、语音输入、实时字幕、语音识别和 AI 翻译等 Web 应用,都绕不开一个基础问题:

复制代码
如何在浏览器中稳定地采集音频?

表面上看,只需要调用一次 getUserMedia()

复制代码
const stream = await navigator.mediaDevices.getUserMedia({
  audio: true
});

但真正进入开发阶段后,通常会遇到更多问题:

复制代码
浏览器没有弹出麦克风权限
耳机正常,外放时却出现回声
录到的声音忽大忽小
采样率与服务端要求不一致
MediaRecorder 输出格式无法直接使用
音频分片太大,实时识别延迟明显
切换麦克风后音频中断
页面进入后台后处理不稳定

实时音频采集并不是简单地"获得一条音频流",而是一条包含设备权限、音频处理、编码转换、传输和异常恢复的完整链路。


一、浏览器采集音频的基本流程

一个常见的实时音频处理链路如下:

复制代码
申请麦克风权限
       ↓
获取 MediaStream
       ↓
创建 AudioContext
       ↓
读取音频帧
       ↓
声道处理与重采样
       ↓
Float32 转 PCM Int16
       ↓
通过 WebSocket 发送
       ↓
服务端进行 ASR 或其他处理

如果业务只是录音并上传,可以直接使用 MediaRecorder

如果业务需要实时语音识别、实时字幕或语音翻译,通常需要读取更底层的音频帧,再按照服务端协议持续发送。


二、使用 getUserMedia 获取麦克风

基础写法如下:

复制代码
async function openMicrophone() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: false
    });

    return stream;
  } catch (error) {
    console.error('获取麦克风失败:', error);
    throw error;
  }
}

获取成功后,返回的是一个 MediaStream

可以通过以下方式查看音轨:

复制代码
const audioTracks = stream.getAudioTracks();

console.log(audioTracks);

每一个 MediaStreamTrack 对应一个音频输入轨道。


三、为什么有时不会弹出麦克风权限?

常见原因包括:

复制代码
页面不是 HTTPS 环境
用户之前永久拒绝了权限
浏览器或操作系统禁止了麦克风
页面位于受限制的 iframe 中
设备正被其他应用独占
没有检测到可用输入设备

开发环境通常可以使用:

复制代码
localhost

但线上页面一般应当运行在安全上下文中。

权限被拒绝时,浏览器可能抛出:

复制代码
NotAllowedError

没有找到麦克风时,可能抛出:

复制代码
NotFoundError

设备正在被使用或无法打开时,可能出现:

复制代码
NotReadableError

因此,不要只显示统一的"录音失败"。

可以根据异常类型给用户更明确的提示:

复制代码
function getMicrophoneErrorMessage(error) {
  switch (error.name) {
    case 'NotAllowedError':
      return '麦克风权限未开启,请在浏览器设置中允许访问。';

    case 'NotFoundError':
      return '未检测到可用的麦克风设备。';

    case 'NotReadableError':
      return '麦克风暂时无法使用,可能正被其他程序占用。';

    default:
      return '无法访问麦克风,请检查设备和浏览器设置。';
  }
}

四、回声消除、降噪和自动增益是什么?

浏览器通常支持三类基础音频处理能力:

复制代码
echoCancellation
noiseSuppression
autoGainControl

可以在获取音频时声明:

复制代码
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
});

1. echoCancellation

回声消除主要用于解决扬声器声音再次被麦克风录入的问题。

例如在线会议中,对方的声音从电脑扬声器播放,又被本机麦克风采集并传回会议,便会形成回声。

使用耳机时,回声问题通常会明显减轻。

2. noiseSuppression

降噪用于减少持续性的环境噪声,例如:

复制代码
空调声
风扇声
键盘声
部分背景底噪

它并不意味着所有噪声都能被彻底移除。

如果旁边有人说话,浏览器未必能准确判断哪一个才是目标声音。

3. autoGainControl

自动增益会尝试将音量调整到相对合适的范围。

用户离麦克风较远时,它可能提高增益;声音过大时,则可能降低增益。

但对于需要保留原始音量特征的场景,自动增益可能反而造成干扰。


五、如何查看浏览器实际采用的音频参数?

传入的音频约束不一定都会被浏览器严格采用。

可以通过 getSettings() 查看最终设置:

复制代码
const track = stream.getAudioTracks()[0];
const settings = track.getSettings();

console.log(settings);

常见输出可能包括:

复制代码
{
  "autoGainControl": true,
  "channelCount": 1,
  "echoCancellation": true,
  "noiseSuppression": true,
  "sampleRate": 48000,
  "sampleSize": 16
}

开发时要区分:

复制代码
constraints:希望浏览器采用什么参数
settings:浏览器实际采用了什么参数

不要默认认为设置了 sampleRate: 16000,最终拿到的就一定是 16 kHz 音频。


六、MediaRecorder 适合什么场景?

如果目标只是录制一段音频并在结束后上传,MediaRecorder 是比较简单的方案。

复制代码
const chunks = [];

const recorder = new MediaRecorder(stream);

recorder.ondataavailable = event => {
  if (event.data.size > 0) {
    chunks.push(event.data);
  }
};

recorder.onstop = () => {
  const blob = new Blob(chunks, {
    type: recorder.mimeType
  });

  console.log(blob);
};

recorder.start();

也可以设置分片间隔:

复制代码
recorder.start(1000);

这样浏览器大约每秒触发一次 dataavailable

但 MediaRecorder 更偏向录音文件生成。

它输出的可能是:

复制代码
WebM
Opus
Ogg
MP4

如果服务端要求持续接收原始 PCM 数据,MediaRecorder 并不是最方便的方案。


七、实时语音识别为什么更常使用 AudioWorklet?

实时语音系统通常需要不断读取较小的音频帧。

过去经常使用 ScriptProcessorNode,但现在更推荐使用 AudioWorklet

基本结构如下:

复制代码
麦克风 MediaStream
        ↓
MediaStreamAudioSourceNode
        ↓
AudioWorkletNode
        ↓
按帧读取 Float32 音频数据

主线程代码示例:

复制代码
const audioContext = new AudioContext();

await audioContext.audioWorklet.addModule(
  '/audio-processor.js'
);

const source = audioContext.createMediaStreamSource(stream);

const workletNode = new AudioWorkletNode(
  audioContext,
  'audio-processor'
);

workletNode.port.onmessage = event => {
  const audioFrame = event.data;
  console.log(audioFrame);
};

source.connect(workletNode);

audio-processor.js

复制代码
class AudioProcessor extends AudioWorkletProcessor {
  process(inputs) {
    const input = inputs[0];

    if (input && input[0]) {
      const channelData = input[0];
      this.port.postMessage(
        new Float32Array(channelData)
      );
    }

    return true;
  }
}

registerProcessor(
  'audio-processor',
  AudioProcessor
);

AudioWorklet 的音频处理运行在独立的音频渲染线程中,比依赖主线程回调更适合持续音频处理。


八、Float32 音频数据是什么?

Web Audio API 中常见的音频样本类型是 Float32Array

每个采样点通常位于:

复制代码
-1.0 ~ 1.0

例如:

复制代码
Float32Array([
  0.001,
  0.015,
  -0.021,
  0.008
]);

但很多语音识别服务要求的输入格式是:

复制代码
PCM 16-bit little-endian

这意味着需要将 Float32 转成 Int16


九、Float32 转 PCM Int16

转换代码如下:

复制代码
function float32ToInt16(float32Array) {
  const int16Array = new Int16Array(
    float32Array.length
  );

  for (let i = 0; i < float32Array.length; i++) {
    const sample = Math.max(
      -1,
      Math.min(1, float32Array[i])
    );

    int16Array[i] = sample < 0
      ? sample * 0x8000
      : sample * 0x7fff;
  }

  return int16Array;
}

这里先将数值限制在 -11 之间,避免发生溢出。

发送时可以直接发送二进制数据:

复制代码
const pcmData = float32ToInt16(audioFrame);

webSocket.send(pcmData.buffer);

相比把音频转换成 Base64,直接发送二进制通常更节省带宽和编码开销。


十、单声道和双声道怎么处理?

麦克风可能输出单声道,也可能输出双声道。

实时语音识别通常只需要单声道。

如果输入是双声道,可以将左右声道合并:

复制代码
function mergeToMono(left, right) {
  const length = Math.min(
    left.length,
    right.length
  );

  const mono = new Float32Array(length);

  for (let i = 0; i < length; i++) {
    mono[i] = (left[i] + right[i]) / 2;
  }

  return mono;
}

如果业务只关心语音内容,没有空间声场需求,单声道可以降低:

复制代码
网络带宽
服务端处理量
音频存储空间

十一、为什么需要重采样?

浏览器 AudioContext 的采样率可能是:

复制代码
44100 Hz
48000 Hz

而语音识别服务可能要求:

复制代码
16000 Hz

如果直接把 48 kHz 数据当作 16 kHz 处理,播放速度、时长和识别结果都会异常。

简单来说:

复制代码
采样率代表每秒包含多少个音频采样点

48 kHz 表示每秒 48000 个采样点。

16 kHz 表示每秒 16000 个采样点。

从 48 kHz 转到 16 kHz,需要减少采样点数量。


十二、一个简单的降采样实现

下面是一个简化的平均降采样示例:

复制代码
function downsampleBuffer(
  buffer,
  inputSampleRate,
  outputSampleRate
) {
  if (outputSampleRate > inputSampleRate) {
    throw new Error(
      '输出采样率不能高于输入采样率'
    );
  }

  if (outputSampleRate === inputSampleRate) {
    return buffer;
  }

  const ratio =
    inputSampleRate / outputSampleRate;

  const outputLength = Math.round(
    buffer.length / ratio
  );

  const result = new Float32Array(outputLength);

  let inputOffset = 0;

  for (
    let outputOffset = 0;
    outputOffset < outputLength;
    outputOffset++
  ) {
    const nextInputOffset = Math.round(
      (outputOffset + 1) * ratio
    );

    let sum = 0;
    let count = 0;

    for (
      let i = inputOffset;
      i < nextInputOffset &&
      i < buffer.length;
      i++
    ) {
      sum += buffer[i];
      count++;
    }

    result[outputOffset] =
      count > 0 ? sum / count : 0;

    inputOffset = nextInputOffset;
  }

  return result;
}

调用示例:

复制代码
const audio16k = downsampleBuffer(
  audioFrame,
  audioContext.sampleRate,
  16000
);

这种实现适合演示基础思路。

对音质要求更高时,应使用更完善的重采样算法和抗混叠滤波。


十三、音频分片应该多大?

实时系统需要在延迟和传输开销之间做平衡。

分片太大:

复制代码
等待时间变长
首字延迟增加
字幕刷新不及时

分片太小:

复制代码
WebSocket 消息数量过多
协议开销增加
服务端调度频繁
更容易产生抖动

常见策略是按几十毫秒组织音频包,例如:

复制代码
20ms
40ms
100ms

假设音频格式为:

复制代码
16000 Hz
单声道
16-bit PCM

100ms 音频包含:

复制代码
16000 × 0.1 = 1600 个采样点

每个采样点占 2 字节,因此数据大小约为:

复制代码
1600 × 2 = 3200 字节

实际项目中,分片大小需要结合:

复制代码
ASR 服务协议
网络环境
字幕延迟要求
服务端吞吐能力

共同确定。


十四、不要每拿到一帧就立即发送

AudioWorklet 每次返回的数据帧通常比较小。

如果每帧都发送一条 WebSocket 消息,会产生大量消息。

更合理的方式是先在客户端缓冲,再按目标大小发送。

复制代码
let pendingSamples = [];

function appendAudioFrame(frame) {
  pendingSamples.push(...frame);

  const targetSize = 1600;

  while (pendingSamples.length >= targetSize) {
    const chunk = pendingSamples.splice(
      0,
      targetSize
    );

    const floatData = new Float32Array(chunk);
    const pcmData = float32ToInt16(floatData);

    sendAudioChunk(pcmData);
  }
}

不过频繁使用数组 splice() 会产生额外内存开销。

生产环境中可以使用循环缓冲区减少复制。


十五、如何列出和切换麦克风?

可以通过:

复制代码
navigator.mediaDevices.enumerateDevices()

获取设备列表:

复制代码
async function listMicrophones() {
  const devices =
    await navigator.mediaDevices.enumerateDevices();

  return devices.filter(
    device => device.kind === 'audioinput'
  );
}

输出可能包含:

复制代码
[
  {
    "deviceId": "default",
    "kind": "audioinput",
    "label": "MacBook Microphone"
  },
  {
    "deviceId": "device-002",
    "kind": "audioinput",
    "label": "USB Microphone"
  }
]

指定设备:

复制代码
const stream =
  await navigator.mediaDevices.getUserMedia({
    audio: {
      deviceId: {
        exact: selectedDeviceId
      }
    }
  });

切换设备前,应停止旧音轨:

复制代码
oldStream.getTracks().forEach(track => {
  track.stop();
});

否则旧麦克风可能继续占用硬件资源。


十六、如何采集系统音频?

麦克风音频和系统播放音频不是同一类输入。

麦克风通常使用:

复制代码
getUserMedia()

屏幕或系统音频通常通过:

复制代码
getDisplayMedia()

示例:

复制代码
const displayStream =
  await navigator.mediaDevices.getDisplayMedia({
    video: true,
    audio: true
  });

但系统音频采集能力会受到:

复制代码
操作系统
浏览器
用户选择的共享目标
浏览器安全策略

等因素影响。

因此,不能假设所有浏览器和设备都能使用完全相同的方式采集系统声音。

在在线会议、直播字幕或实时翻译等场景中,系统音频采集往往比麦克风采集更容易遇到兼容性问题。


十七、如何同时处理麦克风和系统声音?

如果需要同时采集用户讲话和电脑播放声音,可以分别获得两条流:

复制代码
麦克风 MediaStream
系统音频 MediaStream

然后通过 Web Audio API 合并:

复制代码
const audioContext = new AudioContext();

const destination =
  audioContext.createMediaStreamDestination();

const micSource =
  audioContext.createMediaStreamSource(
    microphoneStream
  );

const systemSource =
  audioContext.createMediaStreamSource(
    displayStream
  );

micSource.connect(destination);
systemSource.connect(destination);

const mixedStream = destination.stream;

但直接混合可能产生:

复制代码
音量不平衡
重复回声
声音削波
系统音频过大
麦克风声音过小

通常还需要分别接入 GainNode 调整音量。

复制代码
const micGain = audioContext.createGain();
const systemGain = audioContext.createGain();

micGain.gain.value = 1.0;
systemGain.gain.value = 0.7;

micSource
  .connect(micGain)
  .connect(destination);

systemSource
  .connect(systemGain)
  .connect(destination);

十八、实时翻译场景还要注意什么?

实时翻译系统通常包含:

复制代码
音频采集
流式传输
语音识别
源语言判断
机器翻译
字幕输出
可选语音播报

例如同言翻译(Transync AI)这类用于在线会议的实时翻译工具,就需要持续处理麦克风或会议音频,并输出双语字幕。

从工程实现角度看,这类场景需要重点关注:

复制代码
音频分片是否连续
WebSocket 是否稳定
麦克风和系统音频是否选对
音频采样率是否一致
断线后是否重新建立会话
重复片段是否去重
字幕顺序是否正确

模型能力只是整个系统的一部分。

如果音频采集链路不稳定,即使识别模型本身准确率很高,最终体验仍然可能表现为:

复制代码
漏字
重复
延迟
突然停止识别
声音忽大忽小

十九、页面进入后台后会发生什么?

当浏览器标签页进入后台时,浏览器可能降低:

复制代码
定时器执行频率
页面渲染频率
部分 JavaScript 任务优先级

因此,不要依赖高频 setInterval() 进行核心音频采集。

AudioWorklet 比主线程定时读取更适合持续音频处理。

同时可以监听页面状态:

复制代码
document.addEventListener(
  'visibilitychange',
  () => {
    if (
      document.visibilityState === 'visible'
    ) {
      checkAudioState();
      checkWebSocketState();
    }
  }
);

页面重新进入前台后,可以检查:

复制代码
AudioContext 是否仍在运行
麦克风音轨是否结束
WebSocket 是否断开
服务端会话是否仍然有效

二十、常见性能问题

1. 频繁创建 AudioContext

AudioContext 应尽量复用。

每次开始识别都创建新的实例,但不关闭旧实例,可能造成资源泄漏。

结束时应调用:

复制代码
await audioContext.close();

2. 音频数组不断增长

如果缓存音频但没有及时消费,内存会持续上涨。

必须设置最大缓存范围。

3. 在主线程做大量数据转换

高频 Float32 转换、重采样和 Base64 编码可能影响页面交互。

可以考虑:

复制代码
AudioWorklet
Web Worker
二进制传输
批量处理

4. WebSocket 断开后继续缓存

如果网络已经断开,但客户端仍无限缓存音频,会导致恢复后瞬间发送大量过期数据。

实时音频通常具有时效性。

连接断开时间过长时,应丢弃旧分片,而不是无限补发。


二十一、音频采集检查清单

上线前可以逐项检查:

复制代码
1. 页面是否运行在安全上下文?
2. 是否区分不同麦克风权限错误?
3. 是否支持选择输入设备?
4. 切换设备时是否停止旧音轨?
5. 是否启用合适的回声消除和降噪?
6. 是否读取实际 sampleRate?
7. 服务端要求的音频格式是什么?
8. 是否需要 Float32 转 PCM Int16?
9. 是否需要单声道处理?
10. 是否需要重采样?
11. 音频分片大小是否合理?
12. 是否避免逐帧发送 WebSocket?
13. 是否限制断线期间的音频缓存?
14. 页面从后台恢复后是否检查状态?
15. AudioContext 和音轨是否正确释放?
16. 是否监控首包延迟和音频丢帧?

总结

浏览器音频采集的基础 API 并不复杂,但要用于生产环境,还需要处理大量细节。

一条稳定的实时音频链路通常包括:

复制代码
权限管理
设备选择
回声消除与降噪
AudioWorklet 读取
声道转换
重采样
PCM 编码
分片缓存
WebSocket 传输
断线恢复
资源释放

如果只是录制文件,MediaRecorder 通常已经足够。

如果要实现实时语音识别、在线字幕或实时翻译,则需要更细粒度地处理音频帧。

真正影响实时语音体验的,也不只是识别模型。

从用户开始讲话,到字幕出现在屏幕上,中间每一个环节都可能增加延迟或丢失数据。

只有把采集、处理、传输和服务端识别放在同一条链路里分析,才能真正定位实时音频系统中的问题。

相关推荐
huangdong_1 小时前
京东整店商品图片视频批量下载技术:从商品列表到自动分类
开发语言·python·音视频
Dontla1 小时前
HTML实体转义(HTML Entity Escaping)介绍
前端·html
咸鱼翻身小阿橙1 小时前
高斯模糊降噪/磨皮算法降噪图像
前端·opencv·算法·webpack·c#
ct9781 小时前
ES6 新特性
前端·vue.js·性能优化
持敬chijing1 小时前
Web渗透之SQL注入-宽字节注入
sql·安全·web安全·网络安全·网络攻击模型·安全威胁分析·web
SPC的存折1 小时前
Redis完整学习手册(赵老师视频精华版)
redis·学习·音视频
KaMeidebaby1 小时前
卡梅德生物技术快报|抗原如何自己检测?FAdV-4 重组抗原制备与 ELISA 体系技术调试指南
前端·人工智能·物联网·算法·百度
一拳不是超人1 小时前
AI 辅助研发时代,如何用“规范 Skill”缩短测试周期
前端·人工智能·代码规范
夜郎king3 小时前
湖南高考天气查询:基于 HTML5 与百度天气 API 实现页面展示
前端·html5·百度天气实践·天气信息可视化