3、浏览器录音实战:MediaRecorder API 详解、录音格式选择(WAV vs WebM)、录音状态管理
好,咱们进入实战环节了。前面两章讲了音频基础知识和浏览器兼容性,这一章咱们直接上手——用浏览器把麦克风的声音录下来。
你可能会想:「录音不就是点一下开始、再点一下停止吗?」嗯,表面上看确实是这样。但实际工程里,格式怎么选、状态怎么切、数据怎么拿,每一步都有坑。我最早做语音识别项目时,就因为在录音格式上选错了,导致后端解析失败,排查了一整天。
今天咱们把 MediaRecorder API 彻底讲透。你学完这一章,能自己写一个健壮的录音模块,直接用到生产环境里。
3.1 MediaRecorder API 核心概念
MediaRecorder 是浏览器原生提供的录音接口。它接收一个 MediaStream(就是咱们 getUserMedia 拿到的流),然后把音频数据编码成指定格式输出。
我个人习惯把它的工作流程拆成三步:
- 创建实例:new MediaRecorder(stream, options)
- 监听数据:ondataavailable 事件拿到音频块
- 控制启停:start() / stop() / pause() / resume()
说白了,它就是浏览器帮我们封装好的录音机。你给它麦克风流,它给你吐二进制数据。
核心要点:MediaRecorder 不是实时流式处理,它是按「时间片」输出数据。你调用 start() 时可以传一个 timeslice 参数,比如 1000 毫秒,这样每 1 秒就会触发一次 ondataavailable。
3.1.1 浏览器兼容性
先泼盆冷水——MediaRecorder 在 Safari 上支持得不太好。尤其是 iOS Safari,直到 14.5 才支持,而且只支持 WebM 格式。但 Safari 又不支持 WebM 播放……你说气不气人?
我建议你这样做:
- 桌面端:Chrome、Firefox、Edge 都支持得很好
- 移动端:Android Chrome 没问题,iOS Safari 需要降级方案
- 降级方案:检测不支持时,用 Web Audio API + Worker 自己编码
注意:不要在生产环境里直接假设所有浏览器都支持 MediaRecorder。一定要做特性检测:typeof MediaRecorder !== 'undefined'。
3.2 录音格式选择:WAV vs WebM
这是个大坑。我见过不少项目,前端录完音后端解析不了,就是因为格式没对齐。
MediaRecorder 支持的格式取决于浏览器和操作系统。常用的就两种:
| 特性 | WAV | WebM (Opus) |
|---|---|---|
| 编码方式 | PCM 无压缩 | Opus 有损压缩 |
| 文件大小 | 大(1分钟约 5MB) | 小(1分钟约 200KB) |
| 浏览器支持 | Chrome、Firefox 部分支持 | Chrome、Firefox、Edge 全支持 |
| 后端解析 | 简单,几乎所有语言都支持 | 需要 Opus 解码库 |
| 实时性 | 高,无需解码 | 低,需要解码后才能处理 |
| 适用场景 | 本地录音、语音识别 | 网络传输、存储 |
你可能会问:「那我该用哪个?」
我的建议是:
- 做语音识别:优先用 WAV。因为大多数语音识别引擎(包括咱们后面要讲的 Web Speech API 和第三方 SDK)都直接吃 PCM 数据。省去解码这一步,延迟更低。
- 做网络传输:用 WebM。文件小,传输快。后端用 ffmpeg 或者 opus.js 解码就行。
- 兼容性优先:检测浏览器支持的 MIME 类型,动态选择。
小技巧:检测浏览器支持哪些格式,可以这样写:MediaRecorder.isTypeSupported('audio/webm;codecs=opus')。返回 true 就能用。
3.2.1 如何录制 WAV 格式
这里有个坑——MediaRecorder 默认不直接支持 WAV。Chrome 支持 audio/wav,但 Firefox 不支持。所以如果你想稳定拿到 WAV,得自己动手。
我常用的方案是:
- 用 MediaRecorder 录成 WebM
- 拿到音频数据后,用
AudioContext.decodeAudioData()解码 - 再用
encodeWAV()函数转成 WAV 格式
代码大概长这样:
// 1. 录制 WebM
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
});
const chunks = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
// 2. 解码
const arrayBuffer = await blob.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// 3. 转 WAV
const wavBlob = encodeWAV(audioBuffer);
// 现在 wavBlob 就是 WAV 格式了
};
嗯,encodeWAV() 函数网上有很多现成的实现,我一般用这个:Recorderjs 里的 exportWAV 方法。你也可以自己写,核心就是把 Float32Array 转成 16-bit 整数,加上 WAV 头。
性能注意:解码再编码这个过程挺耗 CPU 的。如果录音时间很长(比如超过 5 分钟),建议分段处理,或者直接用 WebM 格式。
3.3 录音状态管理
录音不是简单的「开始/停止」两个状态。实际工程里,状态切换很频繁。我总结了一个状态机:
看到这个状态机了吧?实际开发中,我建议你用 useReducer(React)或者一个简单的状态对象来管理。千万别用多个布尔变量——「isRecording」「isPaused」「isError」这种,组合起来能把你绕晕。
3.3.1 状态管理实战代码
这是我常用的一个状态管理方案:
const RECORDER_STATE = {
IDLE: 'idle',
RECORDING: 'recording',
PAUSED: 'paused',
ERROR: 'error'
};
class RecorderManager {
constructor(stream, options = {}) {
this.stream = stream;
this.state = RECORDER_STATE.IDLE;
this.mediaRecorder = null;
this.chunks = [];
this.options = options;
}
async start() {
if (this.state !== RECORDER_STATE.IDLE) {
throw new Error('当前状态不允许开始录制');
}
try {
this.mediaRecorder = new MediaRecorder(this.stream, this.options);
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.mediaRecorder.onerror = () => {
this.state = RECORDER_STATE.ERROR;
this.options.onError?.('录制出错');
};
this.mediaRecorder.start(1000); // 每秒触发一次 ondataavailable
this.state = RECORDER_STATE.RECORDING;
this.options.onStateChange?.(this.state);
} catch (err) {
this.state = RECORDER_STATE.ERROR;
this.options.onError?.(err.message);
}
}
pause() {
if (this.state !== RECORDER_STATE.RECORDING) return;
this.mediaRecorder.pause();
this.state = RECORDER_STATE.PAUSED;
this.options.onStateChange?.(this.state);
}
resume() {
if (this.state !== RECORDER_STATE.PAUSED) return;
this.mediaRecorder.resume();
this.state = RECORDER_STATE.RECORDING;
this.options.onStateChange?.(this.state);
}
stop() {
return new Promise((resolve) => {
if (this.state === RECORDER_STATE.IDLE) {
resolve(null);
return;
}
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.options.mimeType || 'audio/webm' });
this.chunks = [];
this.state = RECORDER_STATE.IDLE;
this.options.onStateChange?.(this.state);
resolve(blob);
};
this.mediaRecorder.stop();
});
}
reset() {
this.chunks = [];
this.state = RECORDER_STATE.IDLE;
this.mediaRecorder = null;
this.options.onStateChange?.(this.state);
}
}
这段代码有几个关键点:
- 状态守卫:每个方法开头都检查当前状态,非法操作直接抛错或忽略
- 回调通知:状态变化时通过
onStateChange通知外部,方便 UI 更新 - 资源清理:stop() 里重置 chunks,避免内存泄漏
经验之谈:我曾经在项目里忘了重置 chunks,结果用户录了 10 次音,每次停止后 chunks 越积越多,最后浏览器直接崩了。所以每次 stop() 或者 reset() 一定要清空数组。
3.4 录音实战:完整示例
好了,理论讲完了,咱们写一个完整的录音组件。这个组件会包含:
- 获取麦克风权限
- 创建 MediaRecorder
- 管理录音状态
- 输出录音文件
async function startRecording() {
try {
// 1. 获取麦克风流
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 2. 检测支持的格式
let mimeType = 'audio/webm;codecs=opus';
if (MediaRecorder.isTypeSupported('audio/wav')) {
mimeType = 'audio/wav';
} else if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'audio/webm';
}
// 3. 创建管理器
const recorder = new RecorderManager(stream, {
mimeType,
onStateChange: (state) => {
console.log('录音状态:', state);
updateUI(state); // 更新按钮状态、显示录音时长等
},
onError: (msg) => {
console.error('录音错误:', msg);
alert('录音出错:' + msg);
}
});
// 4. 开始录制
await recorder.start();
// 5. 5秒后停止(实际项目中由用户点击触发)
setTimeout(async () => {
const blob = await recorder.stop();
// 现在 blob 就是录音文件,可以上传或本地播放
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play();
}, 5000);
} catch (err) {
console.error('获取麦克风失败:', err);
alert('请允许麦克风权限');
}
}
这段代码可以直接跑。你把它放到浏览器的控制台里试试,点一下就能录音 5 秒,然后自动播放。
安全限制:getUserMedia 必须在用户手势(点击、触摸)中调用。你不能在页面加载时自动请求麦克风权限。这是浏览器的安全策略,为了防恶意录音。
3.5 避坑指南
最后,我把自己踩过的坑总结一下:
- 坑一:MIME 类型不匹配——你指定的格式浏览器不支持,MediaRecorder 会直接抛错。记得用
isTypeSupported检测。 - 坑二:录音时长不准——MediaRecorder 的
start(timeslice)只是触发频率,不是精确的录音时长。要自己用Date.now()计时。 - 坑三:内存泄漏——每次录音结束后,记得释放 MediaStream 的轨道:
stream.getTracks().forEach(track => track.stop())。 - 坑四:iOS Safari 不支持 WebM 播放——如果你录了 WebM,在 iOS 上没法用
<audio>直接播放。要么转格式,要么用AudioContext解码。 - 坑五:静音检测——用户可能开了麦克风但不说话。我建议在录音时加一个音量检测,如果连续 3 秒音量低于阈值,自动停止并提示用户。