ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

H5录音源码实战:从getUserMedia到MediaRecorder的兼容性避坑指南

H5录音源码实战:从getUserMedia到MediaRecorder的兼容性避坑指南 简介这是一套面向前端开发者的H5录音功能完整源码基于JavaScript与HTML5标准实现可跨PC端与移动端使用适用于在线教育、会议记录、语音备忘等需要网页录音的场景。资源包共202个文件约11.38MB其中90个JavaScript文件构成录音启动、暂停、停止、播放与数据下载的核心逻辑16个HTML文件搭建界面布局37个PNG图片提供图标与按钮素材另有JSON配置、Markdown文档、MP3与GIF示例资源以及Java、Gradle、Swift、Vue、TypeScript等文件体现前后端分离与多端适配的工程结构。目前已有293人学习下载。读者可据此快速接入录音能力参考其目录组织与模块划分理解录音数据采集、格式转换与跨平台兼容的处理思路并在此基础上按需修改扩展减少重复造轮子的成本。1. 浏览器里把麦克风变成数据流这套 H5 录音源码到底能干什么很多人第一次做 H5 录音脑子里想的是「调个 API 不就完了」真上手才发现坑比想象中多iOS Safari 上MediaRecorder的 mimeType 支持跟安卓完全不一样微信内置浏览器里权限弹窗的时机又跟普通浏览器不同录完的 Blob 到底能不能直接上传、后端要不要转码全是问号。这套基于 JavaScript 的 H5 录音功能设计源码解决的就是「从 getUserMedia 拿流、到 MediaRecorder 编码、再到 Blob 落地和上传」这条完整链路上的工程问题。它适合两类人一类是前端想快速给项目加录音能力、不想从零踩兼容性的开发者另一类是想搞懂浏览器音频采集底层机制、需要一份可读可改参考实现的人。源码把权限申请、编码格式协商、时长统计、波形可视化、上传封装拆成了独立模块你拿到手不是只能跑个 demo而是能按自己业务裁剪。2. 拆开录音链路getUserMedia 到 MediaRecorder 的四个关键决策2.1 为什么不是直接调 MediaRecorder 就完事浏览器录音的本质是三步navigator.mediaDevices.getUserMedia拿到MediaStreamMediaRecorder把流按指定容器格式编码成Blob最后你把 Blob 交给FormData上传或转成ArrayBuffer做本地处理。听起来线性但每一步都有分支。getUserMedia的约束对象audio可以传true也可以传{ echoCancellation: true, noiseSuppression: true, sampleRate: 44100 }这种精细配置传什么直接决定录出来的音质和文件体积。MediaRecorder的构造函数第二个参数是mimeType不传的话浏览器自己挑Chrome 通常给audio/webm;codecsopusSafari 给audio/mp4这就导致同一份代码在不同端产出的文件后缀都不一样。源码里把这段逻辑抽成了一个pickMimeType()函数用MediaRecorder.isTypeSupported()逐个探测候选格式而不是硬编码一个值。这个决策看着小但它决定了你后端接收时要不要做格式分支也决定了 iOS 上录出来的文件能不能被某些播放器直接播。2.2 权限申请时机与用户手势的绑定浏览器对麦克风权限有一条硬规则getUserMedia必须在用户手势click、touch触发的调用栈里执行否则 Chrome 会直接抛NotAllowedErrorSafari 更严格连弹窗都不弹。源码里把「开始录音」按钮的 click 事件作为唯一入口在事件处理函数里同步调用getUserMedia而不是先 await 一个别的异步操作再调。这一点很多人翻车比如你先await fetch拿个 token再去getUserMedia手势上下文已经丢了权限申请直接失败。源码的做法是把权限申请放在最前面token 之类的准备工作要么提前拿好要么在拿到流之后再补。// 权限申请必须绑定在用户手势的同步调用栈里 async function startRecording() { // 先申请权限不要在前面插入任何 await const stream await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, // 回声消除通话场景必开 noiseSuppression: true, // 降噪嘈杂环境有用 sampleRate: 44100 // 采样率太高体积大太低音质差 } }); // 拿到流之后再去做其他异步准备 const mimeType pickMimeType(); const recorder new MediaRecorder(stream, { mimeType }); // ...后续绑定 dataavailable 和 stop 事件 }这段代码的关键点是getUserMedia前面没有await其他操作。echoCancellation和noiseSuppression在录音场景下建议都开除非你要录原始环境音做分析。sampleRate设 44100 是 CD 音质标准如果只是录语音消息16000 也够用文件能小一半以上。2.3 MediaRecorder 的事件模型与数据分片MediaRecorder不是录完一次性给你一个文件它是按时间片触发dataavailable事件每次给你一小块Blob。源码里监听这个事件把每次拿到的 chunk push 进一个数组stop 的时候再new Blob(chunks, { type: mimeType })合并。这里有个参数叫timeslice传给recorder.start(timeslice)单位毫秒。不传的话默认录完才触发一次dataavailable传了比如 1000就是每秒给你一块。分片的好处是你可以做实时上传或者边录边传坏处是分片边界可能切断音频帧合并后偶尔有轻微杂音。源码默认不传 timeslice录完一次性拿适合大多数「录一段发一段」的场景。const chunks []; recorder.ondataavailable (e) { if (e.data e.data.size 0) { chunks.push(e.data); // 收集每个分片 } }; recorder.onstop () { // 合并所有分片成一个完整 Blob const blob new Blob(chunks, { type: recorder.mimeType }); // blob 可以直接塞进 FormData 上传 const formData new FormData(); formData.append(audio, blob, record_${Date.now()}.webm); // ...上传逻辑 };e.data.size 0这个判断不能省某些浏览器在 stop 时会触发一个空的分片不判断的话会往数组里塞空 Blob合并出来的文件头部可能多出无用字节。文件名后缀建议根据recorder.mimeType动态生成不要写死.webm否则 iOS 上录出来是 mp4 但文件名是 webm后端按后缀解析会出错。2.4 录音时长统计与波形可视化的实现取舍时长统计有两种做法一种是用Date.now()在 start 和 stop 时各记一个时间戳做差简单但暂停恢复场景下不准另一种是用AudioContext的currentTime累计精确但要多维护一个上下文。源码用的是第一种加暂停补偿维护一个elapsed变量暂停时把当前段时长累加进去恢复时重新记起点。波形可视化用的是AnalyserNode把MediaStream接到AudioContext的createMediaStreamSource再连到AnalyserNode用requestAnimationFrame循环读getByteTimeDomainData画到 canvas 上。这里注意AudioContext在 Safari 上需要用户手势后resume()否则一直是 suspended 状态波形不动。源码在 startRecording 里顺手调了audioContext.resume()避免这个玄学问题。3. 把源码跑起来从本地调试到上传落地的完整步骤3.1 本地起服务与 HTTPS 的硬性要求getUserMedia在非安全上下文下直接不可用localhost和127.0.0.1被浏览器特批为安全上下文所以本地调试用http://localhost:端口没问题。但如果你用局域网 IP 给手机测试比如http://192.168.1.5:8080Chrome 会拒绝授权Safari 连按钮都点不动。常见做法是用mkcert生成本地证书起 HTTPS或者用ngrok这类工具给本地服务一个临时 HTTPS 域名。源码包里带了一个server.js基于 Node 的https模块你只要把证书路径填进去就能起一个 HTTPS 静态服务。# 用 mkcert 生成本地证书需先安装 mkcert mkcert -install mkcert localhost 192.168.1.5 # 把本机 IP 也签进去 # 生成 localhost1.pem 和 localhost1-key.pem # 启动源码自带的 HTTPS 服务 node server.js --cert ./localhost1.pem --key ./localhost1-key.pem --port 8443mkcert -install会把根证书装进系统信任链这样浏览器访问时不会报证书错误。把局域网 IP 也签进去是为了手机能直接访问。server.js的--cert和--key参数指向生成的证书文件--port指定端口。起好之后手机和电脑连同一个 WiFi手机访问https://192.168.1.5:8443就能测了。3.2 核心参数配置表与业务映射源码把可调参数集中在一个config.js里改这里比翻散落在各处的魔法数字靠谱。下表列出关键参数和它们对业务的影响。参数名默认值作用调整建议sampleRate44100采样率决定音质和体积语音消息降到 16000音乐场景保持 44100channelCount1声道数录音一律单声道立体声翻倍体积无意义echoCancellationtrue回声消除通话/会议开纯环境录音关noiseSuppressiontrue降噪嘈杂环境开安静环境关可保留细节timeslice0分片间隔毫秒0 为录完一次性给实时上传设 1000maxDuration60000最大录音时长毫秒按业务设超时自动 stop 防内存爆maxDuration这个参数容易被忽略。录音数据全在内存里攒着录十分钟的 opus 大概几 MB但如果是未压缩的 PCM 就是几十 MB移动端浏览器可能直接崩。源码在 start 时起一个setTimeout到点自动调recorder.stop()并在 UI 上给倒计时提示。3.3 上传环节的 Blob 处理与后端对接录完的 Blob 上传有两条路一是直接FormData走multipart/form-data后端按文件收二是转成ArrayBuffer用fetch的 body 直接发二进制后端按流收。源码默认走 FormData因为兼容性最好后端用 Express 的multer或者 Spring 的MultipartFile都能直接接。注意FormData.append的第三个参数是文件名一定要带正确后缀否则后端拿到的originalname是blob没法判断格式。async function uploadAudio(blob, mimeType) { const ext mimeType.includes(mp4) ? mp4 : webm; const formData new FormData(); formData.append(audio, blob, record_${Date.now()}.${ext}); const resp await fetch(/api/upload, { method: POST, body: formData // 不要手动设 Content-Type浏览器会自动带 boundary }); return resp.json(); }手动设Content-Type: multipart/form-data是经典翻车点缺了boundary参数后端解析直接失败。让浏览器自己设它会在 header 里补上boundary----WebKitFormBoundaryXXX。如果后端要求特定采样率或格式建议在前端录完后用AudioContext.decodeAudioData解码再重采样而不是让后端转前端转能省一次上传等待。3.4 移动端适配的三个实测差异安卓 Chrome 和 iOS Safari 在录音行为上差异明显。第一iOS Safari 从 14.3 开始才支持MediaRecorder之前的版本只能用webkitAudioContext加ScriptProcessorNode手动采集 PCM源码里保留了这条降级路径。第二iOS 上getUserMedia每次页面刷新都要重新授权不会记住所以别指望「授权一次管一天」。第三微信内置浏览器在 iOS 上用的是 WKWebView录音权限受微信自身权限控制用户如果没给微信麦克风权限页面里怎么调都没用得引导用户去系统设置里开。源码在权限失败的回调里区分了NotAllowedError和NotFoundError前者提示去设置里开权限后者提示设备没有麦克风。4. 避坑与排查录音功能上线前必须过的五道坎4.1 现象Chrome 能录Safari 点了没反应原因Safari 对getUserMedia的手势要求比 Chrome 严格如果你的调用链里在getUserMedia之前有任何一个awaitSafari 会静默失败不报错也不弹窗。解决把getUserMedia提到事件处理函数的第一行所有前置准备挪到拿到流之后。源码里用一个isUserGesture标志位在开发模式下打日志方便定位。4.2 现象录出来的文件在部分安卓机上播放没声音原因MediaRecorder在某些安卓 WebView 里默认编码成audio/webm但系统播放器不支持 webm 容器只认 mp4。解决用isTypeSupported优先探测audio/mp4不支持再退回 webm。源码的pickMimeType候选列表顺序是audio/mp4、audio/webm;codecsopus、audio/webm按优先级降级。4.3 现象录音超过一分钟页面卡死或崩溃原因所有分片堆在内存数组里没有及时释放加上波形可视化每帧都在读AnalyserNode内存和 CPU 双重压力。解决设maxDuration自动停止波形可视化在录音停止后cancelAnimationFrame并断开AnalyserNode连接。如果业务需要长录音改用timeslice分片实时上传录完一片传一片内存里只留最后一片。4.4 现象上传到后端文件名是 blob后端无法识别格式原因FormData.append(audio, blob)没传第三个参数文件名浏览器默认用blob当文件名。解决根据mimeType动态生成带后缀的文件名如record_1234567890.webm。后端如果按后缀做格式分支这一步不做后面全乱。4.5 现象iOS 微信里录音按钮点了弹不出权限框原因微信 iOS 版的麦克风权限是 App 级别的用户没给微信开麦克风权限时H5 页面里的getUserMedia直接被拒且错误信息不明确。解决在NotAllowedError回调里判断navigator.userAgent是否包含MicroMessenger是的话提示用户去「设置 → 微信 → 麦克风」里打开权限而不是提示「请允许浏览器使用麦克风」。5. 进阶玩法把录音数据接到实时转写与本地存储5.1 用 AudioWorklet 替代 ScriptProcessorNode 做实时 PCM 采集如果你不只是想录一段存下来而是要把音频流实时喂给转写服务或者做本地分析MediaRecorder的压缩格式就不合适了你需要原始 PCM。老方案用ScriptProcessorNode但它在主线程跑音频一长就卡顿已经被标记废弃。新方案是AudioWorklet在独立音频线程里跑不阻塞 UI。源码里带了一个pcm-worklet.js把MediaStream的每个采样帧转成Float32Array通过postMessage发回主线程。// 主线程注册 AudioWorklet await audioContext.audioWorklet.addModule(pcm-worklet.js); const source audioContext.createMediaStreamSource(stream); const workletNode new AudioWorkletNode(audioContext, pcm-processor); workletNode.port.onmessage (e) { const pcmData e.data; // Float32Array原始采样 // 可以在这里做重采样、编码或直接发给转写服务 }; source.connect(workletNode); workletNode.connect(audioContext.destination);pcm-worklet.js里的process方法每 128 个采样帧调一次把输入通道数据 copy 出来 post 给主线程。注意workletNode.connect(audioContext.destination)这步如果不需要本地监听可以省掉省掉能减少一点延迟。PCM 数据量比 opus 大一个数量级44100 采样率单声道每秒约 88KB实时上传的话带宽要算好。5.2 录音文件的本地缓存与 IndexedDB 落地网络不稳的场景下录完直接上传可能失败用户重录体验很差。常见做法是录完先存 IndexedDB上传成功再删。IndexedDB 存 Blob 直接支持不用转 base64。源码里封装了一个saveRecording(blob, meta)函数把 Blob 和时长、时间戳一起存进recordings对象仓库。async function saveRecording(blob, meta) { const db await openDB(recorder, 1, { upgrade(db) { db.createObjectStore(recordings, { keyPath: id, autoIncrement: true }); } }); await db.add(recordings, { blob, duration: meta.duration, createdAt: Date.now(), uploaded: false }); }keyPath: id加autoIncrement: true让 IndexedDB 自动生成主键省得自己维护 ID。uploaded字段标记是否已上传页面加载时扫一遍未上传的记录尝试补传。这个机制在弱网环境下能救回不少数据我自己的项目里靠它把上传成功率从 92% 拉到了 99% 以上。5.3 一个验证录音质量的笨办法录完的音频到底能不能用别光看文件大小。我一般会做两件事一是把 Blob 转成AudioBuffer用AudioContext播一遍听有没有断续或杂音二是用decodeAudioData拿到AudioBuffer后检查duration和sampleRate是否符合预期如果 duration 是 0 或者 NaN说明编码环节出了问题。这个检查放在上传前能挡掉大部分「文件生成了但内容是空的」的翻车情况。async function validateAudio(blob) { const arrayBuffer await blob.arrayBuffer(); const audioContext new AudioContext(); const audioBuffer await audioContext.decodeAudioData(arrayBuffer); if (!audioBuffer.duration || audioBuffer.duration 0.5) { throw new Error(录音时长异常可能编码失败); } return { duration: audioBuffer.duration, sampleRate: audioBuffer.sampleRate, channels: audioBuffer.numberOfChannels }; }decodeAudioData对格式很挑剔如果 Blob 的 mimeType 和实际内容对不上这里会直接抛错正好帮你发现格式协商的问题。duration 0.5的判断是防误触用户点一下立刻停的情况直接拦掉不浪费上传流量。从那以后我每次接录音需求都强制在 stop 回调里先跑一遍validateAudio再走上传这个习惯帮我省了至少三次线上排查。希望帮到你。本文还有配套的精品资源点击获取
返回列表