
简介这是一套面向前端开发者的H5录音功能完整源码基于JavaScript与HTML5标准实现可跨PC端与移动端无缝接入适用于在线教育、会议记录、语音备忘等需要网页录音的场景。资源包共202个文件约11.38MB其中90个JavaScript文件构成录音启动、暂停、停止、播放与数据下载的核心逻辑16个HTML文件搭建界面布局37个PNG图片与4个GIF动画提供图标及交互素材另含JSON配置、Markdown文档、MP3与AMR等示例音频以及Java、Gradle、Swift、Vue、TypeScript相关文件体现前后端分离与多端适配的技术栈。目前已有293人学习下载。读者可据此快速掌握浏览器录音的完整实现路径直接复用或按需扩展代码省去从零搭建的重复工作将精力投入产品功能创新。1. 浏览器里把麦克风变成数据流H5 录音到底难在哪很多人第一次接到「网页录音」需求时直觉是找个现成的 JavaScript 库调一个start()就完事。真上手才发现浏览器不给文件只给一条实时音频流你得自己把它切成块、拼成文件、算时长、画波形还要处理用户中途拔耳机、切后台、iOS 上采样率对不上这些破事。基于 JavaScript 的 H5 录音功能设计源码讲的不是某个神秘库而是把getUserMedia、MediaRecorder、AudioContext这几块拼成一条能落地的链路。它适合要做语音留言、在线口播、客服录音回传的前端和全栈同学。读完你能自己写出一份不依赖第三方 SDK 的录音源码知道每个参数为什么这么设也知道哪些坑我替你踩过了。2. 录音链路拆解从 getUserMedia 到 Blob 的四个环节2.1 权限、设备与约束对象录音的第一步不是写代码是让浏览器愿意把麦克风交出来。navigator.mediaDevices.getUserMedia返回一个 Promiseresolve 出来的是MediaStream。这里最容易翻车的是约束对象写得太随意导致在某些安卓机上拿到的是通话麦克风而不是主麦克风录出来声音又闷又小。// 请求麦克风约束对象决定采样率、声道和回声消除策略 async function getMicStream() { const constraints { audio: { sampleRate: 44100, // 常见做法是 44100和多数音频设备对齐 channelCount: 1, // 语音场景单声道足够体积减半 echoCancellation: true, // 开回声消除避免外放时录进自己的声音 noiseSuppression: true, // 开降噪键盘声、风扇声会小很多 autoGainControl: true // 自动增益说话忽大忽小时有用 }, video: false }; return await navigator.mediaDevices.getUserMedia(constraints); }逻辑上constraints是给浏览器的「期望值」而不是「强制值」浏览器可能返回一个接近但不完全一致的流。参数说明sampleRate设 44100 是兼容性最好的选择设 48000 在部分设备上会被静默改成 44100channelCount语音场景一定设 1设 2 会让文件体积翻倍且没有实际收益三个布尔开关在纯录音场景建议全开只有在做音乐录制时才关掉echoCancellation和autoGainControl否则会吃掉高频细节。拿到流之后务必在页面卸载或停止录音时调用stream.getTracks().forEach(t t.stop())。我见过太多页面录完不释放麦克风指示灯一直亮着用户以为被偷听投诉就来了。2.2 MediaRecorder 的编码选型与分片MediaRecorder是把MediaStream转成可存储数据块的核心。它的构造函数第二个参数是mimeType这个值直接决定你拿到的是 webm 还是 mp4也决定 iOS 上能不能播。// 根据浏览器支持情况挑一个能用的编码格式 function pickMimeType() { const candidates [ audio/webm;codecsopus, // Chrome、Edge、Firefox 首选压缩率高 audio/mp4, // Safari 较新版本支持iOS 上更稳 audio/webm, audio/ogg;codecsopus ]; for (const type of candidates) { if (MediaRecorder.isTypeSupported(type)) return type; } return ; // 交给浏览器自己决定兼容性兜底 }参数说明audio/webm;codecsopus是桌面端最优解opus 在低码率下语音清晰度明显好于 mp3audio/mp4是给 Safari 和 iOS 准备的老版本 iOS 只认这个。isTypeSupported一定要做不要硬编码否则在 Safari 上直接抛异常。分片用start(timeslice)单位毫秒。设 1000 表示每秒吐一个Blob到ondataavailable。设太小比如 100会频繁触发回调主线程压力大设太大比如 10000则实时波形和上传都会延迟。语音留言场景我一般设 1000直播字幕场景设 200。const recorder new MediaRecorder(stream, { mimeType: pickMimeType() }); const chunks []; recorder.ondataavailable (e) { if (e.data e.data.size 0) chunks.push(e.data); }; recorder.onstop () { const blob new Blob(chunks, { type: recorder.mimeType }); // blob 就是最终音频文件可上传、可播放、可下载 }; recorder.start(1000);2.3 用 AudioContext 做实时波形与音量检测只录不显示波形用户会怀疑到底有没有在录。AudioContext配合AnalyserNode可以拿到实时频域和时域数据用来画波形条或者做「说话才录」的静音检测。// 建立分析节点每帧取一次时域数据算音量 const audioCtx new AudioContext(); const source audioCtx.createMediaStreamSource(stream); const analyser audioCtx.createAnalyser(); analyser.fftSize 2048; // 时域数据长度 fftSize2048 足够画波形 source.connect(analyser); const buffer new Uint8Array(analyser.fftSize); function readVolume() { analyser.getByteTimeDomainData(buffer); let sum 0; for (let i 0; i buffer.length; i) { const v (buffer[i] - 128) / 128; // 归一化到 -1 ~ 1 sum v * v; } const rms Math.sqrt(sum / buffer.length); // 均方根反映能量 return rms; // 一般大于 0.02 认为有人在说话 }参数说明fftSize决定频率分辨率2048 对应约 21Hz 一档画波形够用getByteTimeDomainData拿的是时域波形getByteFrequencyData拿的是频谱做音量检测用时域更直接。RMS 阈值 0.02 是经验值安静办公室可以降到 0.01嘈杂环境要提到 0.05否则会一直触发。注意AudioContext在部分浏览器要求用户手势后才能创建所以初始化要放在点击事件里不要在页面加载时就 new。2.4 把 Blob 变成可上传、可播放、可下载的成品录完拿到Blob只是半成品还要解决三件事本地预览、上传、时长计算。预览用URL.createObjectURL上传用FormData时长则要靠Audio元素的loadedmetadata事件读duration。// 本地预览 读取时长 const url URL.createObjectURL(blob); const audio new Audio(url); audio.addEventListener(loadedmetadata, () { console.log(时长(秒):, audio.duration); }); // 上传字段名要和后端约定好 const form new FormData(); form.append(audio, blob, record_${Date.now()}.webm); form.append(duration, String(audio.duration)); fetch(/api/upload, { method: POST, body: form });参数说明createObjectURL生成的地址要在不用时revokeObjectURL释放否则内存泄漏FormData第三个参数是文件名后端靠它判断扩展名时长一定要在loadedmetadata之后读直接读audio.duration大概率是NaN。webm 在部分安卓浏览器上传后后端识别不了稳妥做法是文件名带扩展名并在后端按mimeType兜底。3. 一份能直接跑的录音源码模块划分与关键实现3.1 目录结构与职责边界我不建议把录音逻辑全塞进一个文件。按职责拆成三个模块后面加波形、加静音检测、加多段录制都不会乱。文件职责关键导出recorder.js封装 getUserMedia 与 MediaRecorderstart、stop、onDataanalyser.js音量检测与波形数据getVolume、getWaveformuploader.js分片上传与重试upload、abortrecorder.js只关心「拿到流、开始录、停止录、吐出 Blob」不碰 UIanalyser.js只读数据不改流uploader.js只负责把 Blob 送出去。这样拆分的好处是换 UI 框架时录音核心不用动做 uniapp 或企业微信内嵌 H5 时也能直接复用。3.2 录音器类的完整实现下面这份代码是我在多个项目里沉淀下来的版本去掉了业务耦合可以直接抄。// recorder.js export class H5Recorder { constructor() { this.stream null; this.recorder null; this.chunks []; this.mimeType ; } async start(timeslice 1000) { this.stream await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 44100, channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); this.mimeType this.pickMimeType(); this.recorder new MediaRecorder(this.stream, { mimeType: this.mimeType }); this.chunks []; this.recorder.ondataavailable (e) { if (e.data e.data.size 0) this.chunks.push(e.data); }; this.recorder.start(timeslice); } pickMimeType() { const list [audio/webm;codecsopus, audio/mp4, audio/webm]; return list.find((t) MediaRecorder.isTypeSupported(t)) || ; } stop() { return new Promise((resolve) { if (!this.recorder || this.recorder.state inactive) { resolve(null); return; } this.recorder.onstop () { const blob new Blob(this.chunks, { type: this.mimeType }); this.release(); resolve(blob); }; this.recorder.stop(); }); } release() { if (this.stream) { this.stream.getTracks().forEach((t) t.stop()); this.stream null; } } }逻辑说明start里先拿流再建 recorder顺序不能反stop返回 Promise 是为了让调用方在拿到 Blob 后再做上传避免时序错乱release单独抽出来既在 stop 里调也方便页面卸载时手动调。参数说明timeslice默认 1000做实时上传可以传 500做整段录制可以传 0表示停止时才吐一次数据。3.3 静音检测与自动断句语音留言场景经常需要「说完自动停」。用前面analyser.js的 RMS 值做状态机连续 N 帧低于阈值就认为说完了。// 静音超过 1.5 秒自动停止 let silentFrames 0; const SILENT_LIMIT 30; // 按 20ms 一帧算30 帧约 0.6 秒可调 function watchSilence(getVolume, onSilence) { const timer setInterval(() { const v getVolume(); if (v 0.02) { silentFrames; if (silentFrames SILENT_LIMIT) { clearInterval(timer); onSilence(); } } else { silentFrames 0; // 一有声音就清零避免说话间隙误判 } }, 20); return () clearInterval(timer); }参数说明采样间隔 20ms 对应约 50 帧每秒SILENT_LIMIT设 30 约等于 0.6 秒静音实际项目里我一般设到 751.5 秒给用户留换气时间。阈值 0.02 要按环境调会议室可以到 0.03安静卧室 0.01 就够。注意这个定时器要在停止录音时清掉否则会一直跑。3.4 分片上传与失败重试长录音一次性上传容易超时按timeslice分片上传更稳。核心是给每个分片带序号后端按序号拼接。// uploader.js export async function uploadChunk(blob, index, total, sessionId) { const form new FormData(); form.append(chunk, blob); form.append(index, String(index)); form.append(total, String(total)); form.append(sessionId, sessionId); const res await fetch(/api/audio/chunk, { method: POST, body: form }); if (!res.ok) throw new Error(chunk ${index} failed); return res.json(); } // 带重试的上传 export async function uploadWithRetry(blob, index, total, sessionId, retry 3) { for (let i 0; i retry; i) { try { return await uploadChunk(blob, index, total, sessionId); } catch (e) { if (i retry - 1) throw e; await new Promise((r) setTimeout(r, 500 * (i 1))); // 退避重试 } } }参数说明sessionId由前端生成时间戳加随机数即可后端用它把分片归到同一次录音retry设 3 次退避间隔 500ms 递增避免网络抖动时疯狂重试打爆后端。注意分片上传要保证顺序或者后端支持乱序拼接否则拼出来的音频会错位。4. 避坑与排查录音功能最常见的五个翻车现场4.1 iOS 上录出来没声音或时长是 Infinity现象在 iPhone 的 Safari 或微信内嵌页录完播放没声音audio.duration显示Infinity。原因iOS 对 webm 支持很差MediaRecorder可能返回一个 Safari 认不出的容器另外 iOS 的AudioContext默认是suspended状态不 resume 就不工作。解决pickMimeType里把audio/mp4排在前面给 iOS 用创建AudioContext后立刻调audioCtx.resume()并且这个调用要放在用户点击事件里。时长读不到就退而求其次用录音开始和结束的时间戳相减作为时长上报。4.2 切到后台录音自动断现象用户录到一半切到微信回消息回来发现录音停了。原因移动端浏览器在页面进入后台时会挂起MediaRecorder这是系统省电策略不是 bug。解决监听visibilitychange在页面隐藏时主动stop()并把已录分片存到IndexedDB回到前台提示用户「刚才的录音已保存是否继续」。不要指望后台能一直录这是平台限制绕不过去。4.3 权限被拒后没有二次引导现象用户第一次点了「拒绝」之后再点录音按钮毫无反应。原因getUserMedia被拒后同一域名下浏览器不会再弹权限框Promise 直接 reject。解决捕获NotAllowedError给出明确文案「请在浏览器地址栏左侧的锁图标里重新允许麦克风权限」并提供一个「重新检测」按钮。检测用navigator.permissions.query({ name: microphone })拿到denied就展示引导拿到granted再调getUserMedia。4.4 录出来的文件体积大得离谱现象录了 30 秒文件 5MB 以上。原因mimeType没指定浏览器用了默认的 PCM 或高码率编码或者channelCount设成了 2。解决强制指定audio/webm;codecsopusopus 默认码率约 64kbps30 秒约 240KB。如果后端只收 wav那体积大是必然的要在产品层面接受或者前端转码成本高不推荐。4.5 企业微信或 App 内嵌 H5 里录音失败现象在普通浏览器正常嵌到企业微信或自家 App 的 WebView 里就报错。原因WebView 默认可能没开mediaPlaybackRequiresUserAction或没授予录音权限安卓端还可能是WebChromeClient.onPermissionRequest没处理。解决前端侧先做能力检测navigator.mediaDevices不存在就直接降级提示同时推动客户端同学在 WebView 配置里放开录音权限安卓端重写onPermissionRequest并grant掉RESOURCE_AUDIO_CAPTURE。这块前端单方面解决不了要拉上客户端一起排查。5. 进阶技巧把录音质量再提一档的三个手段5.1 用 OfflineAudioContext 做前端降噪如果对音质有要求可以在录完后用OfflineAudioContext跑一遍高通滤波把 80Hz 以下的低频噪声空调、桌面震动切掉。// 录完后对 Blob 做一次高通滤波 async function denoise(blob) { const arrayBuffer await blob.arrayBuffer(); const ctx new OfflineAudioContext(1, 44100 * 60, 44100); const audioBuffer await ctx.decodeAudioData(arrayBuffer); const source ctx.createBufferSource(); source.buffer audioBuffer; const filter ctx.createBiquadFilter(); filter.type highpass; filter.frequency.value 80; // 切掉 80Hz 以下 source.connect(filter).connect(ctx.destination); source.start(); const rendered await ctx.startRendering(); // rendered 是处理后的 AudioBuffer可再编码回 Blob return rendered; }参数说明frequency设 80 是语音场景的常用值设太高比如 200会让人声变薄OfflineAudioContext的采样率要和源文件一致否则会变调。这个处理是离线的不占实时资源适合录完再跑。5.2 用 Web Worker 做编码避免主线程卡顿MediaRecorder本身在独立线程但如果你要自己做 PCM 编码或做实时上传压缩放到主线程会卡 UI。把编码逻辑挪进 Worker。// worker.js self.onmessage async (e) { const { pcmData, sampleRate } e.data; // 这里做重采样或编码具体实现按需 const encoded encodePCM(pcmData, sampleRate); self.postMessage(encoded, [encoded.buffer]); // 转移所有权零拷贝 };参数说明postMessage第二个参数是 Transferable把ArrayBuffer转移过去而不是拷贝大文件时能省下明显的内存和时间。注意转移后主线程那边的 buffer 会变成不可用别再访问。5.3 用 MediaStreamTrack 的 getSettings 做设备自检上线前做一次设备自检把实际拿到的采样率、声道数打出来能提前发现「约束没生效」的问题。const track stream.getAudioTracks()[0]; const settings track.getSettings(); console.log(实际采样率:, settings.sampleRate); console.log(实际声道:, settings.channelCount); console.log(设备ID:, settings.deviceId);如果sampleRate和你请求的不一致说明浏览器做了降级这时候要么接受要么在constraints里加exact强制但强制可能导致OverconstrainedError要 try/catch。我一般只在自检日志里记录不强制因为强制失败的代价比降级大。最后说个我自己的习惯每次接录音需求先写一个最小页面只做「拿流、录 5 秒、播放」跑通三个平台桌面 Chrome、iOS Safari、安卓微信再往上加功能。这个最小验证能省掉后面 80% 的玄学排查。希望帮到你。本文还有配套的精品资源点击获取