浏览器音频采集实践:麦克风权限、降噪、回声消除与 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 通常已经足够。

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

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

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

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

相关推荐
mardelan17 小时前
2026年iOS短视频总结工具测评强识别提效率 整理清晰更省心
ios·音视频
山顶夕景18 小时前
【VQA】VideoChat3长视频理解模型
音视频·多模态·视频理解·长视频
微硬创新18 小时前
耐达讯自动化16路0-20mA转PROFINET协议转换模块技术说明
人工智能·网络协议·自动化·信息与通信
kyriewen19 小时前
我排查了一个React内存泄漏——罪魁祸首是这3个被忽略的清理函数
前端·javascript·面试
IT_陈寒19 小时前
我又被JavaScript的隐式类型转换坑了
前端·人工智能·后端
其美杰布-富贵-李19 小时前
03 ref、reactive 与 computed 响应式数据
前端·javascript·vue.js
OpenTiny社区19 小时前
GenUI SDK v1.3.0 发布|多框架兼容,一键换物料,渲染器 & 演练场全面增强!
前端·ai编程
用户9385156350719 小时前
useRef + Web Worker 实战:React 如何优雅地拥抱多线程
前端·javascript·react.js
嘟嘟071719 小时前
useRef + Web Worker:React 中的耗时计算不卡页面
javascript·vue.js
mONESY20 小时前
React Router v7 完全实战指南:从入门到鉴权,一套代码全搞定
javascript