简介这份源码面向需要在Web端快速集成录音能力的开发者尤其适合前端工程师、在线教育与企业会议系统搭建者。项目以JavaScript为核心结合HTML5标准实现录音的启动、暂停、停止、播放与数据下载并兼顾PC与移动端跨平台接入。压缩包共202个文件、约11.38MB其中90个JavaScript文件承载核心录音逻辑16个HTML文件构建界面37个PNG与4个GIF提供图标与动画资源另有JSON配置、Markdown文档、MP3/WAV/AMR等示例音频以及Java、Swift、Vue、TypeScript相关文件体现前后端分离与多端适配思路。目前已有293人学习。读者可据此获得一套结构清晰、可直接运行的H5录音方案理解录音数据采集、编码与下载的完整链路并在此基础上按在线课堂、会议记录等场景修改扩展减少从零搭建的成本。1. 从一次客服通话质检说起H5 录音到底难在哪去年帮一个做在线客服系统的团队做技术评审他们的核心诉求很朴素用户在手机浏览器里点一下按钮把 30 秒的投诉语音录下来上传到后端做质检。团队一开始觉得这事十分钟能搞定结果在 iOS Safari 和微信内置浏览器上连续翻车三天。这就是 H5 录音功能的真实门槛——它不是「调个 API」那么简单而是浏览器权限、编码格式、移动端兼容性三件事叠在一起的工程问题。基于 JavaScript 的 H5 录音功能设计本质是用浏览器原生的 MediaRecorder 接口配合 getUserMedia 拿到麦克风音频流再通过 Blob 和 FormData 把音频送到服务端。它解决的是「不装 App、不依赖原生插件在网页里直接采集语音」的需求适合在线客服、语音留言、口语测评、会议记录这类场景。源码层面通常包含三块权限申请与设备枚举、录音控制与状态机、音频编码与上传。下面把我踩过的坑和能直接抄的代码按顺序讲清楚。2. 把录音链路拆开看从 getUserMedia 到 Blob 的完整数据流2.1 浏览器录音的三个核心对象H5 录音的底层链路其实只有三个对象在传递数据。第一个是navigator.mediaDevices.getUserMedia({audio:true})它返回一个MediaStream代表麦克风的实时音频轨道。第二个是MediaRecorder它把 MediaStream 按时间切片编码成二进制数据块。第三个是Blob把收集到的数据块拼成完整的音频文件。理解这条链路的关键在于MediaRecorder 不是「录完再给你」而是边录边通过ondataavailable事件往外吐数据块。如果你不在事件里收集数据就丢了。很多新手写完代码发现录出来是 0 字节八成是忘了在ondataavailable里 push 数据。// 最小可用的录音链路 let mediaRecorder null; const chunks []; async function startRecord() { // 1. 申请麦克风权限拿到音频流 const stream await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, // 回声消除客服场景必开 noiseSuppression: true, // 降噪嘈杂环境有用 sampleRate: 16000 // 16k 采样语音质检够用且省带宽 } }); // 2. 创建录制器指定 MIME 类型 mediaRecorder new MediaRecorder(stream, { mimeType: audio/webm;codecsopus }); // 3. 每 1 秒切一个数据块避免内存暴涨 mediaRecorder.start(1000); // 4. 收集数据块 mediaRecorder.ondataavailable (e) { if (e.data e.data.size 0) chunks.push(e.data); }; }参数说明sampleRate设 16000 是语音场景的通用值音乐场景才需要 44100。echoCancellation和noiseSuppression在客服、会议场景建议开启但做声纹识别时要关掉否则会破坏音色特征。start(1000)里的 1000 是切片间隔毫秒数设太小会增加事件频率设太大会让内存里堆积大块数据。2.2 MIME 类型选型为什么 opus 是首选MediaRecorder 支持的格式因浏览器而异这是 H5 录音最容易被忽视的兼容性雷区。Chrome 和 Edge 支持audio/webm;codecsopusSafari 从 14.1 开始支持audio/mp4Firefox 支持audio/ogg;codecsopus。如果你写死一个格式在另一个浏览器上会直接抛NotSupportedError。正确做法是用MediaRecorder.isTypeSupported()做能力探测按优先级降级function pickMimeType() { const candidates [ audio/webm;codecsopus, // Chrome/Edge/Firefox 首选 audio/mp4, // Safari 14.1 audio/ogg;codecsopus, // Firefox 备选 audio/webm // 兜底 ]; for (const type of candidates) { if (MediaRecorder.isTypeSupported(type)) return type; } return ; // 空字符串让浏览器自己选 }opus 编码在 16kbps 码率下就能达到不错的语音清晰度比 PCM 省 10 倍带宽这是它成为首选的根本原因。但要注意Safari 录出的 mp4 文件在部分安卓机上无法直接播放如果后端要做统一转码建议在服务端用 ffmpeg 统一转成 wav 或 mp3。2.3 录音状态机别让用户连点按钮录音按钮被连点导致状态错乱是上线后最常见的 bug。用户点「开始」两次第二次会抛InvalidStateError因为 MediaRecorder 已经在 recording 状态。解决办法是维护一个明确的状态机把按钮的可用性跟状态绑定。const STATE { IDLE: idle, RECORDING: recording, PAUSED: paused }; let currentState STATE.IDLE; function updateUI() { btnStart.disabled currentState ! STATE.IDLE; btnStop.disabled currentState ! STATE.RECORDING; btnPause.disabled currentState ! STATE.RECORDING; } async function handleStart() { if (currentState ! STATE.IDLE) return; await startRecord(); currentState STATE.RECORDING; updateUI(); } function handleStop() { if (currentState ! STATE.RECORDING) return; mediaRecorder.stop(); mediaRecorder.onstop () { const blob new Blob(chunks, { type: mediaRecorder.mimeType }); currentState STATE.IDLE; updateUI(); uploadAudio(blob); }; }状态机的价值在于把「能不能点」的判断收敛到一个地方。我见过有团队在四个按钮的回调里各写一遍 if 判断后来加了个「暂停」功能就全乱了。用状态驱动 UI加功能时只改状态定义和 updateUI 一处。2.4 上传环节Blob 转 FormData 的两个细节录完的 Blob 不能直接 POST要包进 FormData。这里有两个细节容易翻车一是文件名要带正确扩展名否则后端按扩展名判断格式会失败二是要处理大文件的分片上传。async function uploadAudio(blob) { const formData new FormData(); // 扩展名必须跟 MIME 对应否则后端解析失败 const ext blob.type.includes(mp4) ? mp4 : webm; formData.append(audio, blob, record_${Date.now()}.${ext}); formData.append(duration, recordDuration); const res await fetch(/api/upload, { method: POST, body: formData // 注意不要手动设 Content-Type浏览器会自动加 boundary }); return res.json(); }手动设置Content-Type: multipart/form-data是经典错误会导致 boundary 丢失后端收不到文件。让浏览器自己设它会在 header 里带上正确的 boundary 参数。另外如果录音超过 60 秒建议在ondataavailable里就分片上传而不是等录完再传否则用户等待时间过长中途退出就全丢了。3. 移动端兼容性实战iOS、微信、安卓的差异处理3.1 iOS Safari 的三个硬限制iOS 上的 H5 录音有三条铁律违反任何一条都直接失败。第一getUserMedia必须在用户手势click、touch的同步调用栈里触发不能在 setTimeout 或 Promise.then 里延迟调用否则会被静默拒绝。第二iOS 只允许 HTTPS 页面访问麦克风HTTP 页面连权限弹窗都不出。第三Safari 录出的音频采样率会被系统强制重采样到 44100你设的 16000 不生效需要后端做重采样。// iOS 正确姿势click 回调里直接调不要 await 别的再调 btnStart.addEventListener(click, async () { // 这里第一行就调 getUserMedia中间不要插入其他 await const stream await navigator.mediaDevices.getUserMedia({ audio: true }); // ...后续逻辑 });如果业务逻辑需要在录音前先请求别的接口正确做法是先调 getUserMedia 拿到流再去请求接口而不是反过来。这个顺序问题在安卓上不明显在 iOS 上是致命的。3.2 微信内置浏览器的权限坑微信内置浏览器X5 内核对录音权限的处理跟系统浏览器不同。首次进入页面时微信不会主动弹权限框而是在你调用 getUserMedia 时才弹。但如果用户之前拒绝过微信会记住这个域名的拒绝状态后续调用直接失败且不弹框。这时候需要引导用户去「设置-隐私-授权管理」里手动开启代码层面只能通过 catch 错误后给出提示。try { const stream await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { if (err.name NotAllowedError) { // 区分是首次拒绝还是永久拒绝 showTip(请在微信设置中开启麦克风权限后重试); } else if (err.name NotFoundError) { showTip(未检测到麦克风设备); } }错误名要区分处理NotAllowedError是权限问题NotFoundError是设备问题NotReadableError是设备被占用比如正在打电话。给用户的提示要具体笼统的「录音失败」会让客服收到大量无效工单。3.3 安卓碎片化机型适配清单安卓的坑主要在机型差异。华为部分机型在锁屏后会自动切断麦克风小米需要额外申请RECORD_AUDIO运行时权限如果是混合 App 内嵌 H5OPPO 和 vivo 在低电量模式下会限制后台录音。这些没有统一的代码解法只能靠真机测试覆盖。机型/系统典型问题应对方式华为 EMUI锁屏切断麦克风提示用户保持屏幕常亮小米 MIUI后台录音被限制引导加入白名单OPPO ColorOS低电量限制录音检测电量并提示三星 OneUI采样率异常后端做重采样兜底真机测试建议至少覆盖iOS Safari、iOS 微信、安卓 Chrome、安卓微信、安卓系统浏览器五个环境。有条件的话把主流机型各测一台这个投入比上线后救火划算得多。4. 避坑与排查录音功能上线前必须过的五道关4.1 录出来是 0 字节现象录音停止后生成的 Blob size 为 0上传后后端收到空文件。原因通常是ondataavailable事件没触发或者 chunks 数组在 stop 之前被清空了。解决确保mediaRecorder.start()传了时间切片参数并且在onstop回调里再拼 Blob不要在 stop 之前就拼。4.2 iOS 上点了没反应现象安卓正常iOS 点开始按钮无任何反应控制台无报错。原因是 getUserMedia 不在用户手势的同步调用栈里。解决把 getUserMedia 放在 click 回调的第一行去掉中间的所有 await。4.3 录音时长不准现象录了 10 秒但生成的音频只有 3 秒。原因是 MediaRecorder 的切片间隔和实际编码延迟不一致start(1000)不代表精确每秒切一次。解决用Date.now()在 start 和 stop 时各记一次时间戳把真实时长传给后端不要依赖音频文件的元数据。4.4 微信里权限弹窗不出现现象微信内置浏览器调用 getUserMedia 后既不弹权限框也不报错一直 pending。原因是微信的权限管理跟系统分离且可能记住了之前的拒绝状态。解决catch 超时错误引导用户手动去微信设置里开启同时提供一个「检测权限」的按钮让用户自查。4.5 上传大文件超时现象录音超过 30 秒后上传失败报 413 或超时。原因是单次请求体过大或者服务端有 body size 限制。解决在ondataavailable里做分片上传每 10 秒一个分片服务端按分片序号拼接。同时前端要显示上传进度让用户知道还在传。5. 进阶技巧用 AudioContext 做实时音量可视化与静音检测录音功能做完基础版之后有两个进阶需求几乎一定会来一是录音时的音量波形显示让用户知道麦克风在工作二是静音检测用户长时间不说话自动停止录音省流量。这两个都能用 AudioContext 的 AnalyserNode 实现。// 在 getUserMedia 拿到 stream 后接入 AudioContext const audioCtx new AudioContext(); const source audioCtx.createMediaStreamSource(stream); const analyser audioCtx.createAnalyser(); analyser.fftSize 256; // 频率窗口大小越小越省 CPU source.connect(analyser); const dataArray new Uint8Array(analyser.frequencyBinCount); function checkVolume() { analyser.getByteFrequencyData(dataArray); // 计算平均音量 const avg dataArray.reduce((a, b) a b, 0) / dataArray.length; // avg 范围 0-255低于 10 视为静音 if (avg 10) { silentFrames; if (silentFrames 50) { // 连续 50 帧静音约 1 秒 handleStop(); // 自动停止录音 } } else { silentFrames 0; } // 可视化把 avg 映射到波形高度 waveBar.style.height ${avg / 255 * 100}%; requestAnimationFrame(checkVolume); }参数说明fftSize设 256 是语音场景的平衡值设 2048 会更精细但 CPU 占用翻倍。静音阈值 10 是经验值嘈杂环境要调高到 20 左右安静办公室可以降到 5。silentFrames 50对应约 1 秒的静音容忍太短会误切正常停顿太长则失去省流量的意义。这里有个血泪经验AudioContext 在 iOS 上必须由用户手势创建否则会处于 suspended 状态analyser 读出来全是 0。解决办法是在 click 回调里同时创建 AudioContext 和 getUserMedia两者都在手势栈里。另一个技巧是录音结束后主动关闭 AudioContext 和 MediaStream 的轨道否则麦克风指示灯会一直亮着用户会以为被偷听。stream.getTracks().forEach(t t.stop())和audioCtx.close()这两句一定要在 stop 回调里执行。我自己的习惯是任何涉及硬件权限的 H5 功能上线前必须用真机跑一遍「拒绝权限→重新授权→录音→上传→播放」的完整闭环模拟器永远测不出 iOS 的手势限制和微信的权限缓存。这套录音方案从代码量看不到 200 行但兼容性细节能吃掉你三天时间提前把上面这些坑过一遍能省下大量返工。希望帮到你。本文还有配套的精品资源点击获取
