☰
浏览器原生录音全链路:getUserMedia与MediaRecorder实战
2026/9/26 22:57:53 网站建设 项目流程

简介:这份资源面向前端开发者,尤其是需要在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;codecs=opus', // Chrome/Edge/Firefox 首选,压缩率高 'audio/webm', 'audio/ogg;codecs=opus', // 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/webm,Safari 对 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 探测 → 分片大小确认 → 后台限制核对」这五步,少一步就可能在某个浏览器上翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询