浏览器原生录音全链路:getUserMedia与MediaRecorder实战

发布时间:2026/9/26 22:58:00
浏览器原生录音全链路:getUserMedia与MediaRecorder实战
简介这份资源面向前端开发者尤其是需要在PC端实现H5音频采集与上传的工程师解决浏览器调用麦克风获取实时音频流、录制音频并上传后台的完整实现问题。包内共9个文件以3个js脚本和2个html页面为核心辅以1个mp3示例音频、1个cs后台处理文件、1个ashx上传接口、1个png示意图压缩包约189KB覆盖前端采集、录音编码与后台接收的闭环流程。内容围绕Web Audio API与MediaRecorder展开包含实时音频流获取、音频节点处理、分块录音、Blob合并及上传等关键环节并附有可运行的示例页面与后台处理代码便于读者直接对照调试。目前已有7407人学习下载适合希望快速掌握H5录音上传方案、排查浏览器兼容与上传异常的前端开发者参考。1. 浏览器里那支看不见的录音笔从 getUserMedia 到后台落盘很多人第一次接到「网页上录一段话传到后台」的需求时直觉是去找个录音插件或者干脆让用户自己用手机录好再上传文件。真做过一遍就知道浏览器原生能力早就够用了核心就是navigator.mediaDevices.getUserMedia拿到麦克风权限再用MediaRecorder把实时音频流切片成 Blob最后走FormData上传。整套链路不依赖任何第三方库PC 端 Chrome、Edge、Firefox 都能跑。这份资源要解决的就是这条完整链路怎么申请麦克风、怎么拿到实时音频流、怎么边录边处理、怎么把录好的音频传到后台。适合正在做 H5 音频采集的前端也适合需要理解前端到底传了什么的 Java 或 Node 后端。下面按「能跑起来 → 能传上去 → 不翻车」的顺序拆开讲。2. 拿到实时音频流getUserMedia 的参数、约束与权限时机2.1 为什么是 getUserMedia 而不是别的方案浏览器里能碰麦克风的入口只有navigator.mediaDevices.getUserMedia老 APInavigator.getUserMedia已废弃别再用。它返回一个MediaStream对象这个流里可以只有音频轨也可以音视频都有。我们要的是音频所以约束里写audio: true或者更细的音频参数。选它的理由很直接它是 W3C 标准浏览器原生支持不需要引入 recorder.js、js-audio-recorder 这类封装库。封装库在简单场景下省事但一旦你要控制采样率、声道数、码率或者要做实时音频分析比如画波形、算音量底层还是得回到MediaStream和AudioContext。与其被库的抽象挡一层不如一开始就用原生 API边界清楚。一个容易忽略的点getUserMedia必须在安全上下文里调用。https://或者localhost才行http://的普通域名下navigator.mediaDevices直接是undefined。这就是热搜里那个「当前页面非 https 安全上下文无法访问摄像头/麦克风」的根因不是代码写错了是环境不对。2.2 最小可运行的采集代码// 申请麦克风并拿到音频流 async function startCapture() { try { // audio 传对象可以精细控制传 true 则用浏览器默认 const stream await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 44100, // 采样率CD 音质常用 44100 channelCount: 1, // 单声道语音场景足够且体积小 echoCancellation: true, // 回声消除通话/录音都建议开 noiseSuppression: true, // 降噪环境嘈杂时有用 autoGainControl: true // 自动增益避免声音忽大忽小 }, video: false }); return stream; } catch (err) { // 权限被拒、没有设备、被占用都会走到这里 console.error(获取麦克风失败:, err.name, err.message); throw err; } }逻辑说明getUserMedia是异步的返回 Promise必须await或.then。audio传对象时浏览器会尽量满足这些约束但不保证完全满足——比如设备不支持 44100它会给你一个最接近的值。所以拿到流之后如果你对参数有硬要求要用stream.getAudioTracks()[0].getSettings()回读实际生效的参数而不是假设你写的就是生效的。参数说明echoCancellation、noiseSuppression、autoGainControl这三个是语音场景的常开项但如果你要做音频质量分析或者音乐录制它们会「污染」原始信号这时候要全部关掉。channelCount: 1对语音足够立体声会让文件体积翻倍。2.3 权限时机与设备枚举的坑权限弹窗的触发时机很关键。浏览器要求getUserMedia必须在用户手势点击、触摸的回调里调用页面一加载就自动调Chrome 会直接拒绝或者静默失败。常见做法是放一个「开始录音」按钮点击后再申请权限。设备枚举是另一个坑。navigator.mediaDevices.enumerateDevices()在授权之前返回的设备列表里label是空的你拿不到「麦克风 1」「麦克风 2」这种名字。只有用户授权过一次之后label 才会填充。所以如果你的 UI 要显示设备下拉框得先申请一次权限哪怕马上停掉再枚举。// 先拿到权限再枚举设备label 才有值 async function listMics() { await startCapture(); // 触发授权 const devices await navigator.mediaDevices.enumerateDevices(); return devices.filter(d d.kind audioinput); }提示授权后如果用户手动在浏览器设置里撤销了权限正在使用的流不会立刻断但下次getUserMedia会失败。监听track.onended可以感知设备被拔掉或流被中断。3. MediaRecorder 切片录音mimeType 选择、分片策略与实时流处理3.1 MediaRecorder 的工作模型拿到MediaStream之后MediaRecorder负责把它编码成可存储的格式。它的模型是「分片」你调start(timeslice)它每隔timeslice毫秒触发一次ondataavailable给你一小块Blob。不传timeslice或者传 0就只在stop()时给一整块。这个分片机制是实时处理的关键。如果你要做「边录边传」就靠ondataavailable一块块往后台发如果只是录完再传可以不分片等stop拿完整 Blob。分片的好处是内存占用低、可以实时上传坏处是分片边界可能切断音频帧后台拼接时要小心。3.2 选择 mimeType 与构造 recorder// 选择浏览器支持的音频编码格式 function pickMimeType() { const candidates [ audio/webm;codecsopus, // Chrome/Edge/Firefox 首选压缩率高 audio/webm, audio/ogg;codecsopus, // Firefox 常用 audio/mp4 // Safari 走这个 ]; for (const type of candidates) { if (MediaRecorder.isTypeSupported(type)) return type; } return ; // 返回空字符串让浏览器自己选 } function createRecorder(stream) { const mimeType pickMimeType(); const recorder new MediaRecorder(stream, { mimeType, audioBitsPerSecond: 128000 // 128kbps语音够用再高收益不大 }); const chunks []; recorder.ondataavailable (e) { if (e.data e.data.size 0) chunks.push(e.data); }; return { recorder, chunks }; }逻辑说明MediaRecorder.isTypeSupported是静态方法用来探测浏览器支持哪些格式。不要硬编码audio/webmSafari 对 webm 支持很差硬写会导致new MediaRecorder直接抛错。用候选列表逐个探测是稳妥做法。参数说明audioBitsPerSecond控制码率。语音场景 64kbps 到 128kbps 足够再往上对语音清晰度提升有限只是白白增大文件。如果是音乐或需要高保真的场景可以提到 192000 甚至 256000但要确认设备采集端支持。3.3 分片录音与实时上传// 边录边传每 3 秒切一片立刻上传 function startStreamingUpload(stream, uploadUrl) { const { recorder } createRecorder(stream); const sessionId Date.now().toString(); // 一次录音会话的标识 recorder.ondataavailable async (e) { if (!e.data || e.data.size 0) return; const form new FormData(); form.append(audio, e.data, chunk-${Date.now()}.webm); form.append(sessionId, sessionId); // 用 keepalive 保证页面关闭时请求也能发出去 await fetch(uploadUrl, { method: POST, body: form }); }; recorder.start(3000); // 每 3000ms 触发一次 ondataavailable return { recorder, sessionId }; }逻辑说明recorder.start(3000)里的 3000 是分片间隔。每片通过FormData上传带上sessionId让后台知道这些片属于同一次录音。后台按sessionId归组、按到达顺序拼接就能还原完整音频。参数说明分片间隔太小比如 500ms会导致请求过于频繁后台压力大太大比如 30s则失去实时性且单次失败丢的音频多。3 到 5 秒是常见折中。fetch的keepalive: true在页面卸载时能保证请求发出但注意 keepalive 请求体有 64KB 上限分片别太大。注意分片上传的顺序不保证和录制顺序一致网络抖动会让后发的片先到。后台要么按片序号排序要么在每片里带上时间戳。别假设「先发先到」。3.4 实时音频流还能做什么拿到MediaStream后除了录音还能接AudioContext做实时分析。比如画波形、算实时音量、做静音检测VAD。常见做法是createMediaStreamSource(stream)接一个AnalyserNode用getByteTimeDomainData拿时域数据画波形用getByteFrequencyData拿频域数据做频谱。// 实时音量检测用于判断用户是否在说话 function createVolumeMeter(stream) { const ctx new AudioContext(); const source ctx.createMediaStreamSource(stream); const analyser ctx.createAnalyser(); analyser.fftSize 512; source.connect(analyser); const data new Uint8Array(analyser.fftSize); return () { analyser.getByteTimeDomainData(data); let sum 0; for (let i 0; i data.length; i) { const v (data[i] - 128) / 128; // 归一化到 -1~1 sum v * v; } return Math.sqrt(sum / data.length); // RMS 音量 }; }这段不参与上传但常和录音配合音量低于阈值持续一段时间就自动停止录音省得录一堆静音。4. 上传到后台FormData、Blob 处理与后端接收要点4.1 前端上传的两种形态上传分两种整段上传和分片上传。整段上传简单stop之后把所有 chunk 合成一个 Blob一次FormData发走。分片上传适合长录音或弱网前面已经演示过。// 整段上传录完合成一个文件再发 function stopAndUpload(recorder, chunks, uploadUrl) { return new Promise((resolve) { recorder.onstop async () { // 把所有分片合成一个 Blob类型要和录制时一致 const blob new Blob(chunks, { type: recorder.mimeType }); const form new FormData(); form.append(audio, blob, record-${Date.now()}.webm); const res await fetch(uploadUrl, { method: POST, body: form }); resolve(res.json()); }; recorder.stop(); }); }逻辑说明new Blob(chunks, { type })的type必须和录制时的 mimeType 一致否则后台拿到的文件头可能对不上播放器解不开。FormData.append的第三个参数是文件名后台靠它判断扩展名。参数说明文件名建议带时间戳或 UUID避免并发上传时重名覆盖。扩展名要和实际编码匹配——webm 就写.webm别写成.mp3否则后台按扩展名转码会出错。4.2 后端接收的常见形态后端不管什么语言接收的都是multipart/form-data。以 Node Express 为例用multer接// Node/Express 接收音频上传 const multer require(multer); const upload multer({ dest: uploads/ }); app.post(/api/audio, upload.single(audio), (req, res) { // req.file 是文件信息req.body.sessionId 是附带字段 console.log(收到文件:, req.file.path, req.file.size); res.json({ ok: true, path: req.file.path }); });Java Spring Boot 侧则是RequestParam(audio) MultipartFile file配置里要放开spring.servlet.multipart.max-file-size默认 1MB 对音频来说太小长录音很容易超。4.3 分片后台拼接的思路分片上传的后台要维护一个「会话 → 分片列表」的映射。每收到一片按sessionId存起来记录片序号或时间戳。等收到结束信号或者超时无新片按序拼接成完整文件。拼接时注意webm 分片不是简单字节拼接就能还原的每个分片可能带独立的容器头。稳妥做法是让前端在分片时用MediaRecorder的连续输出后台用支持流式拼接的工具如 ffmpeg合并而不是Buffer.concat硬拼。提示如果后台只是要转文字或做语音识别很多云服务的接口支持流式传入可以边录边喂不必等录完。但这就涉及具体服务商的 SDK不在本文范围内。5. 避坑与排查权限、格式、上传失败的那些血泪经验5.1 现象navigator.mediaDevices是 undefined原因页面不在安全上下文。http://的普通域名、或者file://直接打开 HTML都会导致mediaDevices不存在。这是浏览器强制的安全策略不是 bug。解决本地开发用localhost浏览器把 localhost 当安全上下文线上必须上https。如果只是临时调试可以用chrome://flags里的「unsafely treat insecure origin as secure」把某个域名加白名单但仅限调试别带到生产。5.2 现象getUserMedia报NotAllowedError原因用户拒绝了权限或者页面不是用户手势触发的调用。Chrome 对「非手势自动调用」会直接拒绝。解决把申请权限放进按钮点击回调。如果用户之前拒绝过浏览器会记住再次调用不会弹窗而是直接拒绝。这时候要引导用户去浏览器地址栏的权限图标里手动改回「允许」代码层面无法强制重新弹窗。5.3 现象录出来的文件播放器打不开原因Blob 的type和实际编码不匹配或者分片拼接时容器头损坏。常见于硬编码audio/webm但浏览器实际用了别的编码。解决始终用recorder.mimeType回读实际使用的类型构造 Blob 时用它。分片上传的后台不要用字节拼接用 ffmpeg 之类的工具合并。5.4 现象上传大文件时请求失败或超时原因后端max-file-size限制、网关超时、或者一次性传太大内存爆掉。解决长录音走分片上传每片控制在几百 KB 到 1MB。后端调大 multipart 限制。前端加失败重试重试时带上片序号后台做幂等避免重复片。5.5 现象Safari 上new MediaRecorder抛错原因Safari 对 mimeType 支持有限早期版本不支持 webm只支持 mp4。解决用isTypeSupported探测候选列表里把audio/mp4放进去。Safari 较新版本已支持更多格式但探测这一步不能省。6. 进阶把实时音频流接进 Web Audio 做可视化与静音裁剪录音上传只是起点。真正让这个资源用起来顺手的是把实时流接进AudioContext做二次处理。我一般会做两件事一是画个实时波形让用户知道「确实在录」二是做静音检测自动裁掉头尾的空白。波形可视化的核心是AnalyserNode。前面给过音量检测的代码画波形就是把getByteTimeDomainData拿到的数据映射到 canvas 的 y 轴。fftSize决定采样点数512 或 1024 对波形显示足够太大反而卡。每帧用requestAnimationFrame重绘注意在录音停止时取消动画否则 canvas 会一直空转。静音裁剪稍微复杂。思路是录音过程中持续算 RMS 音量记录第一个超过阈值的时间点和最后一个超过阈值的时间点。录完后用AudioContext.decodeAudioData把 Blob 解码成AudioBuffer按记录的时间点slice出有效段再重新编码上传。这样能去掉用户点「开始」后愣神的那几秒和录完忘点「停止」的空白。// 用 AudioBuffer 裁剪静音段简化示意 async function trimSilence(blob, startSec, endSec) { const ctx new AudioContext(); const arrayBuffer await blob.arrayBuffer(); const audioBuffer await ctx.decodeAudioData(arrayBuffer); const sampleRate audioBuffer.sampleRate; const startSample Math.floor(startSec * sampleRate); const endSample Math.floor(endSec * sampleRate); const length endSample - startSample; const trimmed ctx.createBuffer( audioBuffer.numberOfChannels, length, sampleRate ); for (let ch 0; ch audioBuffer.numberOfChannels; ch) { const src audioBuffer.getChannelData(ch); trimmed.getChannelData(ch).set(src.subarray(startSample, endSample)); } // trimmed 再编码上传这里省略编码步骤 return trimmed; }参数说明startSec和endSec是有效音频的起止秒数来自录音时的音量检测。decodeAudioData是异步的且会完整解码到内存长录音要注意内存占用。裁剪后的AudioBuffer要重新编码成可上传格式可以用OfflineAudioContext配合MediaRecorder或者用wav编码器手动打包成 PCM WAV。一个我踩过的坑decodeAudioData对 webm/opus 的支持在各浏览器不一致Safari 上可能解不了 webm。如果要做裁剪录制时优先选浏览器能解码的格式或者干脆在后台用 ffmpeg 裁前端只负责传原始文件加时间戳。从那以后我每次做录音功能都强制走一遍「安全上下文检查 → 权限手势触发 → mimeType 探测 → 分片大小确认 → 后台限制核对」这五步少一步就可能在某个浏览器上翻车。希望帮到你。本文还有配套的精品资源点击获取