PC端网页录音实战:麦克风采集、实时波形与分片上传
简介:一套围绕 H5 音频能力的前端实战示例,面向需要实现浏览器端麦克风采集、实时音频流处理与录音上传的开发者,尤其适合 PC 端 Web 项目。内容借助 Web Audio API 与 getUserMedia 取得实时音频流,再用 MediaRecorder 分片录制并合成音频数据,最终通过接口上传到后台,配套页面与后台接收处理逻辑可形成前后端闭环参考。压缩包共 9 个文件、约 189KB,其中 3 个 js 脚本负责采集、录制与上传逻辑,2 个 html 页面用于演示实际调用,另附 ashx 与 .cs 文件对应服务端接收处理,mp3 与 png 分别用于音频样例和界面示意,体量轻便,容易导入现有工程调试。目前已有 7409 人学习下载。读者可据此梳理音频处理链搭建、录音分片与 Blob 合并、上传参数组织以及浏览器兼容性判断等关键环节,也可作为语音识别、在线通话等场景的最小可用原型。
1. PC 端网页点一下按钮,音频就得进得来、看得见、传得走
PC 端后台管理系统里加一个「录音」按钮,看起来是件小事:点一下,浏览器弹麦克风授权,声音进来之后页面上画条实时波形,停止时把整段录成文件自动传到后台。真动手做才发现,h5 音频采集这条链路上,getUserMedia、AudioContext、MediaRecorder三件套只是入场券,实时音频的分析节点怎么挂、录音上传的 Blob 怎么拼、PC 端音频设备切换后流怎么释放,每一步都有能让人原地卡半天的细节。
这份资源做的是一个完整可跑的 PC 端音频采集 Demo:麦克风授权、实时音量与波形展示、录音编码、停止后组文件、FormData 上传后台,链路闭环。适合两类人:一是接到「网页录音」需求、想找个能直接跑通的参照实现的前端;二是已经在写但卡在静音、时长归零、上传丢包这些玄学问题上的开发者。下面按工程结构、采集、录音、上传、排查、改造的顺序拆开讲。
2. 技术选型与工程结构:三条 API 各管一段,别混着用
浏览器音频这块最容易犯的错,是把「拿到麦克风」「分析声音」「录成文件」当成一件事。实际上它们是三条独立的能力,各自有生命周期,混在一起写就会出现「波形还在动但文件是空的」这种问题。先把分工理清楚,后面的代码才不会互相打架。
2.1 getUserMedia、AudioContext、MediaRecorder 的职责边界
navigator.mediaDevices.getUserMedia({ audio: true })只负责一件事:向用户申请麦克风权限,返回一个MediaStream。这个流是「活」的,只要不调track.stop(),麦克风就一直开着,页面标签上的红点也一直亮着。
AudioContext负责处理:把MediaStream包成MediaStreamAudioSourceNode,再挂上AnalyserNode做频域/时域分析,或者挂AudioWorkletNode拿原始 PCM。它不产生文件,只产生数据。
MediaRecorder负责编码落盘:吃一个MediaStream,按指定的mimeType编码,通过ondataavailable吐Blob分片。它不理解音量,也不做可视化。
常见做法是把三者串成一条线:getUserMedia拿流 → 同一份流分两路,一路进AudioContext做实时展示,一路进MediaRecorder做录制。注意是「同一份流分两路」,不是申请两次麦克风——重复申请在部分设备上会直接抛NotReadableError。
| 能力 | API | 输出 | 生命周期控制 |
|---|---|---|---|
| 采集 | getUserMedia | MediaStream | track.stop() |
| 分析 | AudioContext + AnalyserNode | 频域/时域数组 | context.close() |
| 录制 | MediaRecorder | Blob 分片 | recorder.stop() |
2.2 一条音频从麦克风到后台的完整数据流
把链路摊开看是这样:用户点击 → 权限检查 →getUserMedia返回流 → 流分叉 → 左侧AudioContext每秒 60 帧读AnalyserNode画波形和音量条 → 右侧MediaRecorder.start(timeslice)按固定间隔吐分片 → 用户点停止 →recorder.stop()触发最后一次ondataavailable→ 把所有分片new Blob(chunks, { type: mimeType })组装成完整文件 → 构造FormData带上文件、时长、采样率等元信息 →fetch上传到后台 → 后台落盘并返回文件 id。
这里面有两个容易漏的点。一是timeslice参数:start()不传参,ondataavailable只在停止时触发一次,长录音中途崩了就全丢;传1000表示每秒吐一个分片,可以边录边传。二是停止顺序:必须先recorder.stop(),等onstop回调里再关AudioContext和停 track,反过来的话最后一段音频会丢。
2.3 工程目录与模块职责划分
这种 Demo 我不建议全塞一个index.html里,改两次就找不到东西了。按职责拆成几个模块,后面接后台、换编码格式都只动一个文件:
audio-demo/ ├── index.html # 页面结构:按钮、波形 canvas、音量条 ├── src/ │ ├── main.js # 入口:绑定按钮事件、串联各模块 │ ├── mic.js # 麦克风:权限申请、设备枚举、流释放 │ ├── analyser.js # 实时分析:AudioContext、音量计算、波形绘制 │ ├── recorder.js # 录制:MediaRecorder 封装、分片收集、Blob 组装 │ ├── uploader.js # 上传:FormData、分片上传、失败重试 │ └── utils/ │ ├── wav-encoder.js # 兜底:PCM 转 WAV │ └── duration-fix.js # 修复 webm 时长元数据 └── server/ └── upload.js # 后台接收示例:字段校验、文件落盘mic.js只关心流,analyser.js只关心读数据画图,recorder.js只关心 Blob,uploader.js只关心传输。四个模块之间通过main.js传递MediaStream和Blob,不互相 import。这样后面要把录音换成 WAV 格式,只改recorder.js和wav-encoder.js,其余不动。
提示:目录里
duration-fix.js不是可选项。Chrome 下MediaRecorder产出的 webm 文件,<audio>标签读出来的duration经常是Infinity,后台拿到的时长字段要么是 0 要么是 null,这个是后面排查章节要重点讲的坑。
3. 麦克风采集与实时音频流:约束参数写不对,后面全是白工
采集这步看着最简单,实际决定了后面所有环节的质量。约束参数写得太随意,采样率可能是 48k 也可能是 44.1k,回声消除开着还会把某些频段削掉,录音听着「发闷」多半是这里的问题。
3.1 getUserMedia 的 audio 约束:别只写 true
{ audio: true }能用,但拿到的流参数完全由浏览器和系统决定,不同机器不一样。要做上传和后续处理,最好显式声明期望值,让浏览器在能力范围内尽量满足:
// mic.js export async function requestMic(constraints = {}) { // 先探测权限状态,避免用户已拒绝时反复弹窗 const permission = await navigator.permissions .query({ name: 'microphone' }) .catch(() => null); if (permission && permission.state === 'denied') { throw new Error('MIC_PERMISSION_DENIED'); } const stream = await navigator.mediaDevices.getUserMedia({ audio: { // 采样率:语音场景 16000 足够,音乐场景用 48000 sampleRate: constraints.sampleRate || 48000, // 声道:语音单声道即可,体积减半 channelCount: constraints.channelCount || 1, // 回声消除:纯录制场景建议关掉,开着会削频 echoCancellation: constraints.echoCancellation ?? false, // 降噪与自动增益:会议场景开,录音存档建议关 noiseSuppression: constraints.noiseSuppression ?? false, autoGainControl: constraints.autoGainControl ?? false, }, video: false, }); return stream; }参数说明几件事。sampleRate和channelCount是「期望值」不是「保证值」,设备不支持时浏览器会静默降级,实际值要读stream.getAudioTracks()[0].getSettings()才知道,这个值必须跟着文件一起传给后台,否则后台转码时会按错误参数处理。echoCancellation、noiseSuppression、autoGainControl这三个开关在 PC 上尤其要显式写,Chrome 默认全开,做录音存档时会把音频处理得「不像本人」。
3.2 AudioContext 接管道:AnalyserNode 算音量与画波形
实时展示的核心是AnalyserNode,它不改动音频数据,只是旁路读取。挂法是把MediaStream转成源节点,再连到 analyser,注意 analyser 不需要连到destination,连了会把自己的声音播出来形成啸叫:
// analyser.js export function createAnalyser(stream) { const context = new AudioContext(); const source = context.createMediaStreamSource(stream); const analyser = context.createAnalyser(); // fftSize 决定频率分辨率,2048 对应 1024 个频点 analyser.fftSize = 2048; // 平滑系数,0 最灵敏,1 最迟钝,画波形用 0.8 比较稳 analyser.smoothingTimeConstant = 0.8; // 只连 source -> analyser,不连 destination,避免回放 source.connect(analyser); const timeData = new Uint8Array(analyser.fftSize); const freqData = new Uint8Array(analyser.frequencyBinCount); // 计算 RMS 音量,返回值 0~1,用来驱动音量条和静音检测 function getVolume() { analyser.getByteTimeDomainData(timeData); let sum = 0; for (let i = 0; i < timeData.length; i++) { // 时域数据是 0~255,中心值 128 const v = (timeData[i] - 128) / 128; sum += v * v; } return Math.sqrt(sum / timeData.length); } return { context, analyser, timeData, freqData, getVolume }; }getVolume()用的是 RMS(均方根),比直接取最大值稳定,不会因为一个尖峰就跳到满格。这个值后面有两个用途:驱动 UI 音量条;做静音检测——连续 N 帧低于 0.01 就认为用户没说话,可以给个「未检测到声音」的提示,避免传上去一个全静音的文件。
波形绘制用requestAnimationFrame循环,每帧调一次getByteTimeDomainData然后把 2048 个点按 canvas 宽度降采样画折线。频率条同理,用getByteFrequencyData。注意循环里不要做new Uint8Array,数组在初始化时就创建好复用,否则每秒 60 次分配会明显拖慢 GC。
3.3 设备枚举、切换与流释放
PC 端用户经常插着耳机又开着外接麦,需要让用户选设备。enumerateDevices()有个坑:没授权之前,返回的label是空字符串,拿不到设备名。所以顺序必须是先getUserMedia拿权限,再枚举:
// mic.js export async function listDevices() { // 前提:已经调用过 requestMic 拿到授权 const devices = await navigator.mediaDevices.enumerateDevices(); return devices .filter((d) => d.kind === 'audioinput') .map((d) => ({ deviceId: d.deviceId, label: d.label || '未命名设备' })); } // 切换设备:先停旧流,再用 deviceId 重新申请 export async function switchMic(oldStream, deviceId) { oldStream?.getTracks().forEach((t) => t.stop()); return requestMic({ deviceId }); }切换设备一定要先stop()旧流,再申请新流。反过来在部分声卡上会抛NotReadableError: Could not start audio source,因为设备还被上一个流占着。页面卸载时同样要释放,window.addEventListener('beforeunload', ...)里把所有 track 停掉、audioContext.close(),否则用户切走页面后麦克风指示灯还亮着,体验很差。
4. 录音编码与上传链路:Blob 拼错一次,后台就多一个坏文件
录制和上传是这条链路上代码量最大、也最容易出问题的一段。MediaRecorder的 API 很简单,但它吐出来的东西是分片,拼装顺序、mimeType 一致性、停止时机,任何一处错了文件就是坏的。
4.1 mimeType 协商与码率设置
不同浏览器支持的编码格式不一样,Chrome 系偏audio/webm;codecs=opus,Safari 偏audio/mp4。硬写一个格式在别的浏览器上直接抛NotSupportedError。规范做法是用isTypeSupported按优先级探测:
// recorder.js const CANDIDATES = [ 'audio/webm;codecs=opus', // 体积小、兼容好,首选 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4', // Safari 兜底 ]; export function pickMimeType() { for (const type of CANDIDATES) { if (MediaRecorder.isTypeSupported(type)) return type; } return ''; // 返回空串表示交给浏览器自己决定 }码率用audioBitsPerSecond控制。语音存档 64000 就够,音乐场景给到 128000。码率不是越高越好,opus 在 64k 以上对语音的收益已经很小,文件体积倒是线性涨。选定之后要把实际使用的mimeType记下来,组装 Blob 时type必须和它一致,否则后台按扩展名解析会失败。
4.2 分片收集、时长统计与 Blob 组装
MediaRecorder.start(timeslice)传了时间片之后,ondataavailable会周期性触发。用一个数组把所有分片按顺序存起来,onstop时一次性组装:
// recorder.js export function createRecorder(stream, { timeslice = 1000, bitsPerSecond = 64000 } = {}) { const mimeType = pickMimeType(); const recorder = new MediaRecorder(stream, { mimeType: mimeType || undefined, audioBitsPerSecond: bitsPerSecond, }); const chunks = []; let startTime = 0; recorder.ondataavailable = (e) => { // size 为 0 的分片是浏览器在停止时的空事件,直接丢弃 if (e.data && e.data.size > 0) chunks.push(e.data); }; return { start() { chunks.length = 0; startTime = performance.now(); // 传 timeslice,才能周期性拿到分片 recorder.start(timeslice); }, stop() { return new Promise((resolve) => { recorder.onstop = () => { // 关键:type 必须和录制时的 mimeType 一致 const blob = new Blob(chunks, { type: mimeType || 'audio/webm' }); const duration = Math.round((performance.now() - startTime) / 1000); resolve({ blob, duration, mimeType }); }; recorder.stop(); }); }, get state() { return recorder.state; }, }; }这里有三个细节值得单独说。第一,duration用performance.now()算的是「墙上时间」,包含暂停和卡顿,和音频实际时长可能有几百毫秒偏差,后台如果对时长敏感,应该以解码后的真实时长为准。第二,chunks.length = 0在start()里清空,保证同一个 recorder 实例可以重复录第二段、第三段,不用每次重新创建。第三,e.data.size > 0这个判断不能省,Chrome 在stop()时会额外触发一次 size 为 0 的事件,混进数组里不影响 Blob,但会干扰后面算分片数的逻辑。
如果后台只接受 WAV,或者需要精确的 PCM 数据,那就得走AudioWorklet拿原始采样自己编码。这条路代码量翻倍,但时长、采样率、声道全部可控,不存在 webm 时长元数据缺失的问题:
// utils/wav-encoder.js // 把 Float32Array 的 PCM 数据编码成 16bit WAV export function encodeWav(samples, sampleRate = 48000, channels = 1) { const bytesPerSample = 2; const blockAlign = channels * bytesPerSample; const dataSize = samples.length * bytesPerSample; const buffer = new ArrayBuffer(44 + dataSize); const view = new DataView(buffer); const writeString = (offset, str) => { for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i)); }; writeString(0, 'RIFF'); view.setUint32(4, 36 + dataSize, true); writeString(8, 'WAVE'); writeString(12, 'fmt '); view.setUint32(16, 16, true); // fmt 块长度 view.setUint16(20, 1, true); // PCM 格式 view.setUint16(22, channels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * blockAlign, true); // 字节率 view.setUint16(32, blockAlign, true); view.setUint16(34, 16, true); // 位深 writeString(36, 'data'); view.setUint32(40, dataSize, true); // Float32 [-1,1] 转 Int16 let offset = 44; for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i])); view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); offset += 2; } return new Blob([view], { type: 'audio/wav' }); }WAV 的头部是 44 字节固定结构,RIFF、WAVE、fmt、data四个标识块写对了,播放器才认。缺点是体积大,48000 采样率单声道 16bit,一分钟大概 5.5MB,长录音不适合走这条路,只在需要精确数据时用。
4.3 FormData 上传、分片与失败重试
单文件上传的最小实现用FormData直接塞 Blob,配一个语义化的文件名。后台拿到的file字段就是完整音频:
// uploader.js export async function uploadAudio({ blob, duration, mimeType, sampleRate, token }) { const form = new FormData(); // 文件名带扩展名,后台按扩展名判断格式更省事 const ext = mimeType.includes('wav') ? 'wav' : mimeType.includes('mp4') ? 'm4a' : 'webm'; form.append('file', blob, `record-${Date.now()}.${ext}`); form.append('duration', String(duration)); form.append('sampleRate', String(sampleRate)); form.append('mimeType', mimeType); const res = await fetch('/api/audio/upload', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, // 不要手动设 Content-Type body: form, }); if (!res.ok) throw new Error(`UPLOAD_FAILED_${res.status}`); return res.json(); // { fileId, url } }FormData上传有一个高频翻车点:手动设了Content-Type: multipart/form-data,但没带boundary,后台直接解析失败。正确做法是完全不设,让浏览器自动生成带 boundary 的头。
长录音(比如超过 5 分钟)建议走分片上传,把 Blob 按 1MB 切开,每片单独传,最后调合并接口。这样中途断网只需要重传失败的那一片:
// uploader.js 分片上传 export async function uploadByChunks(blob, { chunkSize = 1024 * 1024, onProgress } = {}) { const total = Math.ceil(blob.size / chunkSize); const uploadId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; for (let i = 0; i < total; i++) { const chunk = blob.slice(i * chunkSize, (i + 1) * chunkSize); const form = new FormData(); form.append('uploadId', uploadId); form.append('index', String(i)); form.append('total', String(total)); form.append('chunk', chunk, 'chunk'); // 单分片失败重试 3 次,指数退避 await retry(() => fetch('/api/audio/chunk', { method: 'POST', body: form }), 3); onProgress?.((i + 1) / total); } const res = await fetch('/api/audio/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, total }), }); return res.json(); } async function retry(fn, times) { for (let i = 0; i < times; i++) { try { const r = await fn(); if (r.ok) return r; } catch (e) { if (i === times - 1) throw e; await new Promise((r) => setTimeout(r, 500 * 2 ** i)); } } }后台接收侧要做三件事:校验mimeType白名单,防止传上来可执行文件;限制单文件大小,Content-Length超限直接拒;把duration、sampleRate这些元信息和文件一起入库,后面转码、检索都要用。合并接口里按index排序拼接,拼完删掉临时分片,别留垃圾。
5. 常见问题排查:权限、静音、时长与内存的四类翻车
这套链路的坑基本集中在四个方向,下面每一条都是「现象 → 原因 → 解决」的实录,遇到问题按顺序对号入座。
5.1 授权类:NotAllowedError 与设备被占用
现象:调getUserMedia直接 reject,报NotAllowedError或PermissionDeniedError,页面上什么都没发生。
原因:三种可能——用户点了拒绝;页面不在安全上下文(非 HTTPS 且非 localhost);浏览器记住了上次的拒绝状态,不再弹窗。
解决:先判断location.protocol,非https:且 hostname 不是localhost/127.0.0.1就给出明确提示,这是硬约束,代码层面绕不过去。再用navigator.permissions.query({ name: 'microphone' })读状态,denied时引导用户去地址栏左侧的站点设置里手动改,prompt时再调getUserMedia。不要在denied状态下反复调用,Chrome 会直接静默拒绝不弹窗。
现象:报NotReadableError: Could not start audio source。
原因:麦克风被其他应用独占,常见于会议软件、录屏工具开着的时候。
解决:给用户明确提示「请关闭其他正在使用麦克风的程序」,并在代码里加一个重试按钮。切换设备时先stop()旧 track 再申请新流,也能规避一部分占用问题。
5.2 音频数据类:录出来是静音、杂音或者不像本人
现象:波形在动,录出来的文件播放全是静音。
原因:AudioContext挂在流上但MediaRecorder用的是另一份流;或者recorder.start()之后立刻stop(),第一段分片还没生成。
解决:确认AudioContext和MediaRecorder用的是同一个MediaStream实例。stop()至少等 500ms 再调,或者在onstart事件里标记状态,确保state === 'recording'才允许停止。另外检查audioBitsPerSecond是不是设得太低,8k 以下的 opus 语音会严重失真。
现象:录音听着「发闷」,高频缺失,或者声音忽大忽小。
原因:echoCancellation、noiseSuppression、autoGainControl三个开关默认开着,浏览器把音频当通话处理了。
解决:在约束参数里显式关掉这三个,尤其是做内容存档、语音质检这类场景。开会话场景反而要开着,按用途选。
现象:后台拿到的采样率和预期不一致,48000 变成了 44100。
原因:约束参数只是「期望值」,设备不支持就静默降级。
解决:录完之后读stream.getAudioTracks()[0].getSettings()拿真实值,和文件一起上传,后台按真实值处理,别用前端写死的常量。
5.3 时长与内存类:duration 是 Infinity、长录音把标签页干崩
现象:上传的 webm 文件,<audio>标签读duration是Infinity,后台 ffprobe 读出来时长是 0 或N/A。
原因:webm 容器的时长元数据写在文件尾部(Duration 元素),MediaRecorder是流式输出,停止时没有回写这个字段,所以文件本身是「时长未知」的。这是格式特性,不是 bug。
解决:三条路选一条——上传时把前端算的duration作为独立字段传给后台,后台不用容器时长;用fix-webm-duration这类工具在停止后重写一遍文件头;或者干脆改用 WAV/PCM 方案,时长由采样数算出来,精确且不会缺。我一般选第一条加第二条组合,成本最低。
现象:录了十几分钟,页面越来越卡,最后标签页崩了(Aw, Snap)。
原因:所有分片都堆在内存数组里,Blob组装时又要复制一遍,十几分钟的高码率音频能把内存吃满。
解决:start(timeslice)传时间片,在ondataavailable里直接调分片上传接口把数据发走,本地只保留uploadId和分片索引,不攒 Blob。录完调合并接口。这样内存占用和录音时长解耦,录一小时也不会爆。
注意:
AudioContext在部分浏览器里有数量上限(一般是 6 个左右),每次录音都new AudioContext()而不close(),录几次之后就会抛The AudioContext was not allowed to start。用完必须context.close()。
6. 二次开发:把 Demo 抽成录音组件,状态机是关键
Demo 能跑通只是起点,真正落到业务里,录音这块要以组件形式被多个页面复用,还得处理「录到一半切页面」「用户重复点击」「上传失败重录」这些边界。我的做法是先立一个显式状态机,把所有非法操作挡在状态判断里。
状态就五个:idle(未授权)→ready(已授权待录)→recording(录制中)→paused(暂停)→uploading(上传中)。每个状态只允许特定的动作,比如recording下点开始按钮直接忽略,uploading下不允许重新录制。用一张表把迁移关系锁死,组件内部只暴露start()、stop()、reset()三个方法,外部不需要知道MediaRecorder的存在:
// 状态迁移表:key 是当前状态,value 是允许的动作 -> 目标状态 const TRANSITIONS = { idle: { grant: 'ready' }, ready: { start: 'recording' }, recording: { pause: 'paused', stop: 'uploading' }, paused: { resume: 'recording', stop: 'uploading' }, uploading: { done: 'ready', fail: 'ready' }, }; function canTransit(from, action) { return Boolean(TRANSITIONS[from]?.[action]); }这套表的好处是可测。写单测时不用碰真实麦克风,直接对canTransit断言就行,覆盖所有非法组合。血泪经验是:一开始没做状态机,用户快速双击录制按钮,MediaRecorder被start()两次直接抛异常,整个页面白屏,排查了半天才发现是并发调用。
第二个改造点是编码后端的可替换。把recorder.js抽象成一个接口,MediaRecorderRecorder和WorkletRecorder两个实现都满足start() / stop() / onData三个约定,业务层通过配置选。这样默认走 webm 省带宽,需要精确 PCM 的场景切 WAV,切换成本是改一行配置,不用动业务代码。
第三个点是验证方法。录音这种链路,光靠肉眼看波形不可靠,我一般固定跑一份清单:录 3 秒、30 秒、5 分钟各一次,检查文件能播放、时长误差小于 1 秒、后台入库字段完整;切一次输入设备再录一遍;录制过程中断网再恢复,看分片重试是否生效;连续录 10 次,看AudioContext数量有没有泄漏。这套跑完,基本能挡住 90% 的上线问题。
从那以后我每次接网页录音的需求,都强制先把「同一份流分两路」和「停止顺序」两个点写进注释里,再动手写业务逻辑,不然大概率要在「波形有、文件空」这个坑里再躺一次。希望帮到你。
本文还有配套的精品资源,点击获取