🎙️

浏览器录音实战:getUserMedia 采集 PCM 并编码 WAV

在高级群发的语音功能里,我需要同时支持上传音频和浏览器录音:录音可以开始、暂停、继续、结束、试听和重新录制,最长 60 秒,上传文件最大 10MB。
这篇文章记录一次前端录音实现:使用 getUserMedia 获取麦克风,通过 Web Audio API 采集 PCM,再手动编码为单声道、16 bit 的 WAV 文件。重点是解决录音计时与播放时长不一致、暂停恢复、原生播放器试听和资源释放的问题。

浏览器录音方案:为什么从 MediaRecorder 转向 PCM + WAV

项目调试中曾出现“录音界面显示 5 秒,播放只有 3 秒”和播放器显示 0:00 的情况。排查时涉及容器格式、元数据、分片处理和计时来源。最终在这个项目中选择自行采集 PCM、编码 WAV,使输出格式和文件时长的计算方式更明确。
这并不意味着 MediaRecorder 在所有浏览器中都不可靠。尤其不能把 WebM 文件改名为 .wav 就当作 WAV:扩展名和 MIME 标签不会改变文件内部编码。使用 MediaRecorder 时应检查实际 MIME 类型,并完整收集停止时的最后一段数据。
适用范围说明:本文保留项目使用的 ScriptProcessor 实现,便于理解和维护已有代码。ScriptProcessorNode 已弃用,新项目应优先使用 AudioWorklet 处理自定义 PCM 采集。 MDN:ScriptProcessorNode
getUserMedia 获取麦克风 → AudioContext 创建音频上下文 → ScriptProcessor 采集 PCM → Float32Array 缓存采样数据 → 结束时编码标准 WAV → 原生 audio 播放与试听
下面的代码按 Vue Options API 风格展示关键方法,不是可以直接粘贴运行的完整组件;响应式状态、按钮绑定、消息组件和上传接口需要接入所在项目。

获取麦克风权限:HTTPS、localhost 与浏览器兼容

录音需要安全上下文,例如 HTTPS 或本机 localhost。用户拒绝麦克风权限后,前端不能强制重新弹出授权框,需要引导用户检查站点权限。
下面保留历史 API 的兼容写法,但兼容前缀不能绕过安全上下文与权限限制。
getUserMedia() { if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { return options => navigator.mediaDevices.getUserMedia(options) } const legacy = navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.getUserMedia return legacy ? options => new Promise((resolve, reject) => { legacy.call(navigator, options, resolve, reject) }) : null } getAudioContext() { return window.AudioContext || window.webkitAudioContext }

开始录音:用 AudioContext 采集 Float32Array PCM

创建单声道处理节点,将输入样本复制到新的 Float32Array,避免直接持有输入缓冲区。连接静音 Gain 节点以避免把麦克风声音回放到扬声器。
async startRecording() { const getUserMedia = this.getUserMedia() const AudioContextClass = this.getAudioContext() if (!window.isSecureContext || !getUserMedia || !AudioContextClass) { this.micError = true this.$message.error('请在 HTTPS 或 localhost 环境下使用麦克风') return } try { const stream = await getUserMedia({ audio: true }) this.audioContext = new AudioContextClass() if (this.audioContext.state === 'suspended') { await this.audioContext.resume() } this.recordingStream = stream this.audioSource = this.audioContext.createMediaStreamSource(stream) const createProcessor = this.audioContext.createScriptProcessor || this.audioContext.createJavaScriptNode this.audioProcessor = createProcessor.call( this.audioContext, 4096, 1, 1 ) this.audioData = { buffer: [], size: 0, sampleRate: this.audioContext.sampleRate } this.audioProcessor.onaudioprocess = event => { if (this.recordingState !== 'recording') return const input = event.inputBuffer.getChannelData(0) this.audioData.buffer.push(new Float32Array(input)) this.audioData.size += input.length } this.audioSource.connect(this.audioProcessor) // 连接静音 Gain,保证采集节点持续触发,同时避免麦克风回声。 const silentGain = this.audioContext.createGain() silentGain.gain.value = 0 this.audioProcessor.connect(silentGain) silentGain.connect(this.audioContext.destination) this.recording = true this.recordingState = 'recording' this.recordingStartedAt = Date.now() this.recordedElapsedMs = 0 this.startElapsedTimer() } catch (error) { this.cleanupAudioRecorder() this.micError = true } }
实现时应在拿到 stream 后立即保存引用,再创建 AudioContext,确保后续初始化失败也能停止麦克风。按钮还应有“请求权限中”的互斥状态,防止重复点击创建多套音频节点;用户关闭弹窗后才返回的 stream 也要及时停止。

暂停与继续:只记录有效录音片段

ready 准备录音 recording 正在录音 paused 已暂停 complete 录音完成
暂停时不销毁 AudioContext,只停止写入 PCM 数据;恢复后继续追加。暂停期间麦克风流仍然存在,直到结束或取消时释放。
togglePause() { if (this.recordingState === 'recording') { this.recordedElapsedMs += Date.now() - this.recordingStartedAt this.recordingStartedAt = 0 this.clearElapsedTimer() this.recordingState = 'paused' } else if (this.recordingState === 'paused') { this.recordingStartedAt = Date.now() this.recordingState = 'recording' this.startElapsedTimer() } }
音频采集回调通过状态判断跳过暂停片段:
if (this.recordingState !== 'recording') return

60 秒录音限制:区分界面计时与真实音频时长

界面使用时间戳计算经过时间,比每次定时器回调简单累加一秒更合理。以下是原实现的展示计时器。
startElapsedTimer() { this.clearElapsedTimer() this.recordingTimer = setInterval(() => { const elapsedMs = this.recordedElapsedMs + (this.recordingStartedAt ? Date.now() - this.recordingStartedAt : 0) this.elapsedSeconds = Math.floor(elapsedMs / 1000) if (this.elapsedSeconds >= 60) { this.finishRecording() } }, 1000) } clearElapsedTimer() { if (this.recordingTimer) clearInterval(this.recordingTimer) this.recordingTimer = null }
但 setInterval 可能延迟触发,时间戳也不能保证实际采集到同等时长的样本。最终 WAV 时长应以样本数量为准:
音频时长(秒)= PCM 样本总数 / 采样率
若要保证生成文件严格不超过 60 秒,需要在采集回调中限制样本数量,而不能只依赖上面的定时器。可将原来的 onaudioprocess 回调替换为:
this.audioProcessor.onaudioprocess = event => { if (this.recordingState !== 'recording' || !this.audioData) return const input = event.inputBuffer.getChannelData(0) const maxSamples = Math.floor(this.audioData.sampleRate * 60) const remaining = maxSamples - this.audioData.size const count = Math.min(input.length, Math.max(0, remaining)) if (count > 0) { this.audioData.buffer.push(new Float32Array(input.subarray(0, count))) this.audioData.size += count } if (this.audioData.size >= maxSamples) this.finishRecording() }
上面的时间戳仍可用于 UI 提示;更希望显示值贴近最终文件时,也可以直接按已采集样本数刷新。需要单调时钟时可将整套 Date.now() 计时统一替换为 performance.now()。

结束录音:生成 WAV 文件并计算实际时长

finishRecording() { if (this.recordingStartedAt) { this.recordedElapsedMs += Date.now() - this.recordingStartedAt this.recordingStartedAt = 0 } this.clearElapsedTimer() const audioBlob = this.encodeWav() if (audioBlob) { const duration = this.audioData.size / this.audioData.sampleRate const file = new File( [audioBlob], 'voice-' + Date.now() + '.wav', { type: 'audio/wav' } ) this.setAudioFile(file) this.recordedDuration = duration this.elapsedSeconds = Math.floor(duration) this.recordingState = 'complete' } this.cleanupAudioRecorder() this.recording = false }
finishRecording 应增加 recording / paused 状态检查,防止定时器、采集回调与按钮重复结束。编码和设置文件建议用 try/finally 包裹,确保异常时也释放资源。没有采集到有效样本时,应恢复 ready 并提示重新录制,避免留下“正在录音”的界面状态。

JavaScript 编码 WAV:写入 RIFF 文件头与 16 bit PCM

采集到的样本是 Float32Array。先合并,再裁剪到 -1 至 1,转换为有符号 16 bit 整数,写入标准 PCM WAV。下面编码器固定为单声道,不适用于直接编码双声道交错样本。
encodeWav() { if (!this.audioData || !this.audioData.size) return null const samples = new Float32Array(this.audioData.size) let offset = 0 this.audioData.buffer.forEach(chunk => { samples.set(chunk, offset) offset += chunk.length }) const dataLength = samples.length * 2 const buffer = new ArrayBuffer(44 + dataLength) const view = new DataView(buffer) const writeString = (position, value) => {
下面继续完成 WAV 编码器:
const writeString = (position, value) => { for (let i = 0; i < value.length; i++) { view.setUint8(position + i, value.charCodeAt(i)) } } writeString(0, 'RIFF') view.setUint32(4, 36 + dataLength, true) writeString(8, 'WAVE') writeString(12, 'fmt ') view.setUint32(16, 16, true) view.setUint16(20, 1, true) view.setUint16(22, 1, true) view.setUint32(24, this.audioData.sampleRate, true) view.setUint32(28, this.audioData.sampleRate * 2, true) view.setUint16(32, 2, true) view.setUint16(34, 16, true) writeString(36, 'data') view.setUint32(40, dataLength, true) for (let i = 0; i < samples.length; i++) { const sample = Math.max(-1, Math.min(1, samples[i])) const value = sample < 0 ? sample * 0x8000 : sample * 0x7fff view.setInt16(44 + i * 2, value, true) } return new Blob([view], { type: 'audio/wav' }) }
单声道、16 bit、48kHz、60 秒的 WAV 大小为:
44 + 60 × 48000 × 1 × 2 = 5,760,044 字节 约 5.76 MB,或 5.49 MiB
该配置低于 10MB,但浏览器 AudioContext 的实际采样率可能不同。例如 96kHz 下同样时长会超过 10MB,因此生成 File 后仍应按 file.size 校验。只修改 WAV 头里的采样率不是重采样,会改变播放速度与时长。

使用原生 audio controls 试听录音

WAV 文件头包含采样率和数据长度,播放器可以据此解析时长。界面使用浏览器原生播放控件处理播放、暂停、拖动进度和音量,具体控件以浏览器为准。
<audio v-if="audioUrl" :src="audioUrl" controls preload="metadata" />
原文的 setAudioFile 需要维护对象 URL。可按以下方式补齐,并在替换文件、重新录制和组件卸载时释放旧 URL:
setAudioFile(file) { const maxBytes = 10 * 1024 * 1024 // 示例采用 10 MiB,需与后端统一口径 if (file.size > maxBytes) throw new Error('音频文件超过大小限制') if (this.audioUrl) URL.revokeObjectURL(this.audioUrl) this.audioFile = file this.audioUrl = URL.createObjectURL(file) }
重新录制时先停止当前采集、清空计时器,再撤销旧 audioUrl、清空 audioFile 和时长,恢复 ready 状态。本文不假定上传音频也限制 60 秒:原需求只明确录音最长 60 秒、上传最大 10MB,上传文件是否限制时长需与产品约定。

释放麦克风和 AudioContext,避免资源泄漏

cleanupAudioRecorder() { if (this.audioProcessor) this.audioProcessor.disconnect() if (this.audioSource) this.audioSource.disconnect() if (this.recordingStream) { this.recordingStream.getTracks().forEach(track => track.stop()) } if (this.audioContext && this.audioContext.state !== 'closed') { this.audioContext.close() } this.audioProcessor = null this.audioSource = null this.recordingStream = null this.audioContext = null this.audioData = null }
结束录音、取消、初始化失败和组件卸载都应执行清理。除上述节点外,建议把 silentGain 保存为实例字段并断开;把 onaudioprocess 设为 null,清除计时器,并处理 audioContext.close() 返回的 Promise。否则可能出现麦克风指示灯持续亮起、重复采集或多套 AudioContext 占用资源。

浏览器录音常见问题与排查顺序

为什么播放器显示 0:00,或录音 5 秒只播放 3 秒?

先确认文件真实容器与扩展名、MIME 一致,再检查最后一个分片是否被收集、暂停恢复逻辑是否正确,最后核对 UI 计时与文件时长来源。0:00 也可能是元数据尚未加载或文件解析失败,不能只凭显示值判断根因。

MediaRecorder.start(1000) 能保证每秒一段吗?

不能。timeslice 不是精确计时器,回调可能因浏览器调度而延迟。不要用“分片数 × 1 秒”作为录音实际时长,也不能把所有截断问题都归因于 start(1000)。MDN:dataavailable 事件

暂停时间会算进最终录音吗?

本文方案在 paused 状态不写入 PCM,因此最终文件只包含已记录的样本。暂停点的精度受处理缓冲区边界影响;暂停时仍需同步处理 UI 计时。

新项目还应该使用 ScriptProcessor 吗?

新项目应优先评估 AudioWorklet;维护已有 ScriptProcessor 代码时,要清楚它的弃用状态和主线程处理的局限。本文 WAV 编码、样本计时及资源生命周期的思路仍可复用。MDN:AudioWorklet 使用指南

上传音频与后端群发接口如何对接

前端生成的是 audio/wav,后端对接前需要明确以下协议:
  1. 上传接口是否支持 WAV;需求里的 WAM 是否为 WAV 的笔误。
  1. 是否需要后端转码,以及最终允许的容器、编码、采样率与声道数。
  1. 上传接口地址、请求方式、文件字段名,以及 10MB 的具体字节口径。
  1. 上传成功返回的是 fileId、URL、文件类型还是时长。
  1. 服务端是否再次校验真实格式、文件大小和录音时长。
  1. 语音附件 attachmentType 是否使用 5;该值是项目约定,并非通用标准。
  1. 群发接口最终接收 fileId、URL 还是其他字段。
本地待提交附件暂时使用以下结构:
{ key: 'voice-' + Date.now(), attachmentType: 5, fileName: 'voice-xxx', fileType: 'wav', file: audioFile }
浏览器 File 对象不能当作普通 JSON 文件内容直接发给后端。接口确认后,应先通过约定的上传方式提交文件,再使用后端返回的文件标识创建群发任务。前端大小与类型检查用于即时反馈,后端仍需校验实际文件内容。

完整录音流程回顾

点击开始录音 → 请求麦克风权限 → 获取 MediaStream → 创建 AudioContext / MediaStreamSource / ScriptProcessor → 持续采集 Float32Array PCM,并限制最大样本数 → 暂停时停止写入,继续时恢复写入 → 结束时合并 PCM、编码标准 WAV → 根据样本数计算真实时长 → 生成 File 并校验大小 → 原生 audio 试听 → 上传后端 → 用文件标识提交群发任务
这次实现的关键,是让文件格式、样本数量、播放时长和上传协议彼此对应。UI 计时服务于交互,PCM 样本数决定生成文件的真实音频时长;结束与重新录制则必须把麦克风、音频节点、计时器和对象 URL 一起纳入生命周期管理。

这里暂时还不能留言,想交流的话可以给我写信。