
在线会议、语音输入、实时字幕、语音识别和 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;
}
这里先将数值限制在 -1 到 1 之间,避免发生溢出。
发送时可以直接发送二进制数据:
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 通常已经足够。
如果要实现实时语音识别、在线字幕或实时翻译,则需要更细粒度地处理音频帧。
真正影响实时语音体验的,也不只是识别模型。
从用户开始讲话,到字幕出现在屏幕上,中间每一个环节都可能增加延迟或丢失数据。
只有把采集、处理、传输和服务端识别放在同一条链路里分析,才能真正定位实时音频系统中的问题。
