拓冰建站拓冰建站
首页 / 资讯中心 / 正文

浏览器在线录音导出MP3:getUserMedia到lamejs编码全攻略

简介面向Web开发者及前端学习者的在线录音MP3生成示例利用HTML5 Web Audio API和MediaDevices.getUserMedia实现浏览器端实时采集麦克风音频再通过MP3编码库完成格式转换适合需要快速嵌入录音上传功能的项目参考。资源共6个文件以3个JavaScript脚本为主分别承担录音控制、实时音频处理Web Worker和MP3编码另配HTML页面及服务端上传处理文件ashx/cs可打通前端录制到后端接收的完整流程。压缩包仅58KB体积小巧代码模块划分清晰便于直接阅读和二次改造。目前已有949人学习下载对想深入理解ScriptProcessorNode音频处理、Float32Array二进制操作以及MP3编码过程开发者是一份不错的实战参考。1. js在线录音录制MP3音频导出先解决“能不能录”再谈“导不导出”把 js 在线录音录制 MP3 音频导出做成一个可交付的浏览器功能难点不在“点一下允许麦克风”而在于浏览器对音频封装格式的支持从来没统一过Chrome 的 MediaRecorder 默认给 WebMFirefox 给 Ogg真正输出可直接导出的 MP3 文件得在 JS 层把整条“出声 → 采 PCM → 编码 MP3 → 触发下载”的链路自己搭起来。这类需求最常见于在线表单留言、语音笔记、远程工单附件和课堂反馈等场景。前端拿到麦克风流之后要做的不只是录制还要在浏览器里完成转码最后给用户一个确定可播放、能命名的 .mp3 文件。下文从最小录音代码出发走一遍我在实际项目里验证过的实现路径并列出参数设置和几个反复踩到的坑。适合正准备把录音模块接进现有前端项目的工程师也适合产品想把“浏览器直接录音导出 MP3”从“能录音”推到“能交付”的人。2. 用 getUserMedia 拿下麦克风最小可跑通的录音脚手架2.1 先分清三件套MediaStream、MediaRecorder、AudioContext在浏览器里做在线录音第一步永远是navigator.mediaDevices.getUserMedia({ audio: true })它返回的是一个MediaStream对象里面包含的是音频轨道而不是任何具体格式的文件。到这一步为止你拿到的还是“正在流动的声音”不是能保存的录音。后面往哪走有两条路线MediaRecorder 路线把 MediaStream 直接交给MediaRecorder浏览器自己封装。实现最快但封装格式由浏览器厂商决定最常见的 mimeType 是audio/webm;codecsopus、audio/ogg;codecsopus。Web Audio 路线把 MediaStream 接到AudioContext通过ScriptProcessorNode或AudioWorklet一帧一帧把 PCM 采样点捞出来自己编码成 WAV/MP3。多写不少代码但你能控制采样率、声道数、比特率也能真正得到 MP3。我的建议是如果产品硬性要求导出 MP3采集层可以用 MediaRecorder 快速验证但转码层必须走 Web Audio。原因在第三章展开这里先记住一个结论——不要让MediaRecorder返回的容器格式决定你的导出格式。2.2 最小可跑通的录音脚手架下面这段代码是“能录、能停、能回放”的最小形态适合先把手感和权限链路跑通。它不负责转 MP3但能验证麦克风权限和录音生命周期管理是否正常。button idstart开始录音/button button idstop disabled停止录音/button audio idplayer controls/audio script let mediaRecorder null; const chunks []; const startBtn document.querySelector(#start); const stopBtn document.querySelector(#stop); const player document.querySelector(#player); startBtn.addEventListener(click, async () { const stream await navigator.mediaDevices.getUserMedia({ audio: true }); const mimeType [audio/webm;codecsopus, audio/ogg;codecsopus, audio/mp4] .find(type MediaRecorder.isTypeSupported(type)) || ; mediaRecorder new MediaRecorder(stream, { mimeType }); chunks.length 0; mediaRecorder.ondataavailable event { if (event.data.size 0) chunks.push(event.data); }; mediaRecorder.onstop () { const blob new Blob(chunks, { type: mediaRecorder.mimeType || audio/webm }); player.src URL.createObjectURL(blob); }; mediaRecorder.start(); startBtn.disabled true; stopBtn.disabled false; }); stopBtn.addEventListener(click, () { mediaRecorder.stop(); startBtn.disabled false; stopBtn.disabled true; }); /script这段代码里有两个关键参数需要解释。mimeType的选择是先枚举三个常见类型再用MediaRecorder.isTypeSupported()判断当前浏览器支持哪一个避免new MediaRecorder()直接抛NotSupportedError。ondataavailable里判断event.data.size 0是个容易漏掉但很重要的细节不是每次回调都会给到有效数据空数据块会被后续Blob拼进文件导致播放时末尾出现一小段杂音。2.3 权限策略与 HTTPS 要求本地调试的两个前置条件getUserMedia对安全上下文有硬性要求。localhost 和 127.0.0.1 被当作安全上下文所以本地npm run dev或随便开个静态服务都能调试但如果是局域网 IP、file://打开或者测试环境没有配置 HTTPS 证书Chrome 会直接在getUserMedia上报NotAllowedError连授权弹窗都不会出现。常见做法是在项目里加一段用户引导检测async function getMicrophoneStream() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error(当前浏览器不支持录音请使用最新版 Chrome/Edge 或 Safari); } const stream await navigator.mediaDevices.getUserMedia({ audio: true }); return stream; }这里同时做了两个检查先判断mediaDevices是否存在避免老浏览器直接白屏再发起权限请求。权限请求后面必须跟catch因为用户点“拒绝”时这个 Promise 会以NotAllowedErrorreject不接住就会在控制台留下未处理的异常页面表现像“点开始没反应”。注意生产环境一定要走 HTTPS否则 iOS Safari 和 Android Chrome 的录音功能都会静默失败。如果证书是自签的优先检查浏览器地址栏的安全状态不要一上来就怀疑代码逻辑。3. 从 WAV 到 MP3不赌浏览器选择转码方案3.1 三条路线的选型原生 audio/mp3 / lamejs 纯 JS 编码 / 后端转码现在面对一个现实问题MediaRecorder能不能直接产 MP3MediaRecorder.isTypeSupported(audio/mp3)在某些 Chromium 内核上确实返回true但这属于浏览器内部封装能力编码器实现、容器封装都不可控换一个内核版本就可能退化成audio/webm。更要命的是导出audio/mp3时Chrome 生成的 blob 实际内容常常还是以 MP4/ADTS 形式存在播放器兼容性非常差。把产品可靠性压在这种“某种浏览器的某几个版本恰好支持”上是录音项目第一个大坑。第二条路是用纯 JS 的 MP3 编码器。前端圈子里最常用的是lamejs它把 LAME 编码器编译成 JavaScript在浏览器里直接跑喂给它 Int16 格式的 PCM 采样点吐出来就是 MP3 帧。这条路的好处是不依赖后端网络也不挑封装格式录完的文件就是规规矩矩的 MP3。第三条路是把 PCM 或原始录制的 WebM 整包交给后端用 FFmpeg 转码成 MP3 返给前端。可靠性最高采样率、比特率、ID3 标签都能精细处理适合录音时长长、要求高、设备老的场景但代价是要部署转码服务还多了上传等待。在“纯前端在线录音导出 MP3”这个需求下我一般选第二条路getUserMedia 采原始 PCM然后用 lamejs 编码。下面章节把这条链路拆开写。3.2 用 lamejs 把 AudioBuffer 转成 MP3核心代码与参数说明先捋一下编码前的数据流。getUserMedia拿到的是MediaStream把它接进AudioContext通过脚本节点拿到Float32Array采样点攒满一次录音后把连续的Float32Array拼接成一条长数组再转成Int16Array交给 lamejs。为什么必须是 Int16因为 MP3 编码器内部按 16bit PCM 处理Float32 不能直接喂。核心编码函数长这样function encodePCMToMp3(pcmFloat32, sampleRate, channels, kbps) { // Float32 - Int16同时做一次振幅钳制 const pcmInt16 floatTo16BitPCM(pcmFloat32); const mp3Encoder new lamejs.Mp3Encoder(channels, sampleRate, kbps); const mp3Data []; const blockSize 1152; // MP3 编码器固定帧长单位是采样点 for (let i 0; i pcmInt16.length; i blockSize) { const chunk pcmInt16.subarray(i, i blockSize); const encoded mp3Encoder.encodeBuffer(chunk); if (encoded.length 0) { mp3Data.push(new Int8Array(encoded)); } } const endBytes mp3Encoder.flush(); if (endBytes.length 0) { mp3Data.push(new Int8Array(endBytes)); } return new Blob(mp3Data, { type: audio/mp3 }); } function floatTo16BitPCM(input) { const output new Int16Array(input.length); for (let i 0; i input.length; i) { const s Math.max(-1, Math.min(1, input[i])); output[i] s 0 ? s * 0x8000 : s * 0x7FFF; } return output; }blockSize 1152不是随便填的它对应 MP3 一帧的采样点数lamejs 内部按帧处理一次至少喂一帧。如果传入的 chunk 长度不是 1152 的整数倍编码器也能处理但尽量用subarray保持与源数据共享缓存避免每次循环都复制大段内存。floatTo16BitPCM里最容易出错的两个细节一是编码前必须做Math.max(-1, Math.min(1, ...))钳制否则削波时s * 0x8000会超出 Int16 范围转出来的整段声音会出现爆音二是负半轴乘0x8000、正半轴乘0x7FFF这是对称 PCM 映射的标准写法直接统一乘32767会让波形正负幅值不对称。3.3 比特率、采样率、声道数影响文件大小与音质的 3 个必调参数三个参数的取舍对产品体验的影响很直接列一个我在项目里经常用的配置表场景采样率声道比特率预期文件体积每分钟语音留言、短时录音16 kHz单声道48 kbps约 0.36 MB课堂、会议录音32 kHz单声道96 kbps约 0.72 MB音乐片段、高保真素材44.1 kHz双声道192 kbps约 1.44 MB采样率决定频带宽语音识别和回放的场景 16 kHz 够用但乐器和背景音乐场景低于 32 kHz 会明显发闷。lamejs 在编码时会把采样率写进 MP3 头所以传给Mp3Encoder的sampleRate必须与实际采样率严格一致否则播放器会把时长算错出现“放了 10 秒进度只走 5 秒”的怪异行为。声道数方面浏览器麦克风默认是单声道但某些设备会输出双声道。编码给 MP3 时如果用了channels 2却又只传一个声道的数据编码器会报错或生成异常文件。稳妥做法是在采集阶段就固定成单声道在AudioContext里把channelCount设为 1或者把所有输入声道取平均值后再进入编码。注意比特率越高不代表听感一定越好。对语音64 kbps 和 128 kbps 的差异在普通耳机上几乎听不出来但文件体积差了一倍。音频产品里我通常把比特率做成设置项默认 64给高级用户开放 128/192 的选择。4. 导出 MP3 文件Blob 下载、URL.createObjectURL 与文件名处理4.1 把编码结果包成 Blobtype 参数一个细节也别写错第三章最后一个函数里new Blob(mp3Data, { type: audio/mp3 })这里的type和下载行为直接相关。浏览器识别文件类型一部分看扩展名一部分看 blob 的 MIME。如果type写成application/octet-stream下载下来的文件通常还是能保存为.mp3但某些云盘和手机相册识别时会把文件当未知类型导致无法预览。audio/mp3是国际惯例也兼容audio/mpeg。导出下载时我给文件名固定加.mp3后缀两个一起保证识别。有些浏览器还会看type来判断能不能在audio里直接播放所以录音试听也用同一个 blobtype 更不能乱写。4.2 两种导出交互自动下载按钮和本地试听回放拿到 MP3 的 blob 后最常见的导出方式是动态创建a元素触发下载function downloadBlob(blob, filename) { const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download filename; document.body.appendChild(link); link.click(); link.remove(); // 延迟释放避免立即 revoke 导致下载中断 setTimeout(() URL.revokeObjectURL(url), 3000); }这段代码里有三个值得留意的点。第一link必须挂到document.body上有的浏览器对未挂载节点的click()不响应。第二URL.revokeObjectURL不要立刻调用必须等到下载请求真正发起后再释放否则部分浏览器会下载失败延迟 3 秒是常见做法。第三link.download里的文件名会直接决定用户保存时的默认名最好不要用固定前缀而是带上日期和序号方便用户归档录音文件。我习惯用这种命名方式function defaultRecordingName() { const now new Date(); const stamp now.toISOString().slice(0, 19).replace(/[-:T]/g, ); return recording_${stamp}.mp3; }toISOString()里的T和:不合法去掉后得到recording_20250114_153000.mp3这样的结构排序和检索都方便。试听回放则更简单把同一个 blob 交给audio元素player.src URL.createObjectURL(blob); player.play().catch(() { // 用户还没操作页面就被拦截这里提示点击播放 statusText.textContent 请手动点击播放按钮; });player.play()在现代浏览器里返回 Promise自动播放被拦截时这个 Promise 会 reject。不要在主流程里直接调用play()用户刚点完“停止录音”手势时间窗还没完全失效但异步转码后激活状态可能已经丢失。稳妥做法是让用户再点一次播放按钮或者用catch给提示。4.3 手机端导出 MP3iOS Safari 的下载行为差异移动端 Safari 对download属性的支持一直很别扭。iOS 上的 Safari 遇到a[download]经常不是自动下载而是把 MP3 交给系统播放器或预览器下载体验不如桌面端干净。针对这个差异我通常在移动端增加“分享文件”入口使用 Web Share APIasync function shareMp3(blob, filename) { const file new File([blob], filename, { type: audio/mp3 }); if (navigator.canShare navigator.canShare({ files: [file] })) { await navigator.share({ files: [file], title: 录音文件, text: 这是我刚录制的 MP3 }); } else { downloadBlob(blob, filename); } }navigator.share在 iOS Safari 和 Android Chrome 上都能调用系统分享面板用户可以把文件直接存到“文件”App、发到微信或备忘录绕开下载属性导致的困惑。桌面端浏览器普遍不支持navigator.share的files所以兜底走downloadBlob。这个组合是目前我推荐的跨端导出方案。注意在 Web Share API 中new File()的type也要写成audio/mp3某些安卓系统会基于 File 结构判断文件格式MIME 和扩展名不一致时分享到微信会被识别成“未知文件”。5. js在线录音常见问题与避坑排查几个让录音项目翻车的细节5.1 现象录出来的文件有大小播放却是静音或只有“咔哒”爆音原因大概率出在 Float32 转 Int16 的映射上。很多人图省事直接写input[i] * 32767却没有做钳制如果录制过程中音量超过 0 dB数值会溢出并在波形上下限附近翻转转出来的 PCM 变成锯齿状播放就是一段刺耳的破音或静音。另外把Float32Array错当作Uint8Array逐字节处理也会让 MP3 编码器拿到完全错误的数据。解决统一用第三章floatTo16BitPCM的写法先钳位再映射。调试时先不要急着听 MP3把采样数据落成 WAV 或打印前 100 个采样点确认数值范围在 -32768 到 32767 之间再喂编码器。5.2 现象MP3 播放时长为实际时长两倍或者进度条走着走着跳回开头原因通常是采样率配置与编码器入参不一致。例如AudioContext在设备上实际是 48000 Hz但代码按 44100 Hz 传给Mp3Encoder编码器按错误采样率计算 MP3 帧时间戳播放器再按 48000 解读时长自然对不上。还有更隐蔽的情况在ScriptProcessorNode里取到了e.inputBuffer.getChannelData(0)但e.inputBuffer.sampleRate和audioContext.sampleRate不一致离线渲染时会出现这时如果用了固定值就会出问题。解决不要硬编码采样率统一从audioContext.sampleRate读并把sampleRate作为参数一路传下去。如果要做降采样单独写一个重采样函数不要中途改变数组长度和采样率的关系。5.3 现象点击“停止录音”后onstop不触发或者一直拿不到可用 blob原因有几种MediaRecorder 的stop()在没有真正start()成功时调用会抛出InvalidStateError部分 WebView 对audio/webm支持不全start()已经失败但异常没被捕获另外录音时间极短时ondataavailable可能只产生一个空数据块blob 是 0 字节。解决MediaRecorder.start(timeSlice)里传入时间片比如start(1000)让浏览器每隔 1 秒抛一次数据避免最后一次性数据过大或为空。再用try/catch包裹stop()并给onerror挂上日志。像下面这样兜底mediaRecorder.start(1000); mediaRecorder.onerror event { console.error(MediaRecorder 失败, event.error); };5.4 现象授权弹窗被拒绝后页面没有任何错误提示像“死掉”一样原因getUserMedia的 Promise reject 之后没有接catch也没有做用户可感知的 UI 反馈。很多新手只在async函数里await getUserMedia却没有把 try/catch 写在调用外层异常直接落到 console。解决把权限获取封装成独立函数并给出友好提示async function ensureMicrophone() { try { return await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { if (err.name NotAllowedError) { throw new Error(请先允许麦克风权限再开始录音); } if (err.name NotFoundError) { throw new Error(没有检测到麦克风设备); } throw new Error(录音启动失败 err.message); } }用户拒绝后再点“开始录音”浏览器通常不会立刻重新弹窗需要引导用户去地址栏左侧的权限图标里恢复授权。不用写死文案但给用户一个“去设置打开权限”的指引能少接很多客服反馈。5.5 现象走了 Web Audio 路线但录音数据全是 0原因很经典浏览器把AudioContext初始状态设为suspended尤其 iOS Safari 必须等待用户手势调用resume()后才会开始处理音频。如果代码在页面加载时就创建AudioContext到点击开始时它可能还在挂起状态脚本节点拿到的全是空白。解决每次点击“开始录音”的同一个事件循环里调用await audioContext.resume()等返回running再建立ScriptProcessorNode并连接。可以在start()里加一句强制解锁if (audioContext.state suspended) { await audioContext.resume(); }6. 进阶技巧把录音、转码、导出封装成一个可复用的 Mp3Recorder 工具类6.1 设计一个带事件回调的 Recorder 类把前几章的内容收拢成一个类对外暴露start、stop、download三个方法并把录音进度用onData回调抛出去这样不管接 Vue、React 还是原生页面都能直接利用。class Mp3Recorder { constructor(options {}) { this.targetSampleRate options.sampleRate || 16000; this.kbps options.kbps || 64; this.channels options.channels || 1; this.audioContext null; this.stream null; this.processor null; this.samples []; this.onData options.onData || (() {}); this.running false; } async start() { this.stream await navigator.mediaDevices.getUserMedia({ audio: true }); this.audioContext new AudioContext(); if (this.audioContext.state suspended) { await this.audioContext.resume(); } const source this.audioContext.createMediaStreamSource(this.stream); this.processor this.audioContext.createScriptProcessor(4096, this.channels, this.channels); this.processor.onaudioprocess event { const input event.inputBuffer.getChannelData(0); // 整数倍降采样比如 48000 - 16000每 3 个采样点取 1 个 const ratio this.audioContext.sampleRate / this.targetSampleRate; if (ratio ! 1) { for (let i 0; i input.length; i ratio) { this.samples.push(input[Math.floor(i)]); } } else { for (let i 0; i input.length; i) { this.samples.push(input[i]); } } this.onData(this.estimateProgress()); }; source.connect(this.processor); this.processor.connect(this.audioContext.destination); this.running true; } async stop() { if (!this.running) return null; this.processor.disconnect(); this.stream.getTracks().forEach(track track.stop()); await this.audioContext.close(); const floatArray new Float32Array(this.samples); const pcm floatTo16BitPCM(floatArray); const blob encodePCMToMp3(pcm, this.targetSampleRate, this.channels, this.kbps); this.running false; return blob; } }这段代码把链路真正串起来了。createScriptProcessor(4096, channels, channels)里的 4096 是每次回调的采样点数量数值越大触发频率越稳定但内存占用更大一般不超过 8192。ratio this.audioContext.sampleRate / this.targetSampleRate是简单的整数降采样策略速度快且不会改变时长但注意它没有抗混叠滤波对语音场景够用需要保留完整音质的场合要换成低通滤波后再降采样。6.2 用 AudioWorklet 替代 ScriptProcessor告别主线程卡顿上节的类为了可读性用了ScriptProcessorNode它已经标记为 deprecated原因是它跑在主线程上录音回调频繁触发时复杂页面会出现明显掉帧。如果项目要上生产建议把核心音频处理迁到AudioWorklet。替换方式是增加 worklet 文件在主线程里注册并加载await audioContext.audioWorklet.addModule(/js/pcm-recorder-worklet.js); const workletNode new AudioWorkletNode(audioContext, pcm-recorder, { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });worklet 内部不再占用主线程但从回调里向主线程传数据需要用一个共享缓冲区实现量会翻倍。我现在的习惯是原型阶段用脚本节点快速验证真上生产再切 worklet。这是最平衡的前端音频开发节奏。6.3 验证音频质量的一组自检清单上线前不要只靠“听着还行”来验收我用下面这份清单快速排查大部分问题检查项通过标准采样率用ffprobe或mediainfo查看 MP3 文件头确认采样率和编码参数符合预期时长播放器的总时长与真实录音时长误差不超过 0.5 秒静音段用audio播放时拖动进度条确认没有开头、结尾异常截断文件下载文件名后缀.mp3双击后能被系统默认播放器识别权限拒绝拒绝一次授权后再次录音页面有明确提示而不是白屏做 js 在线录音和 MP3 导出最怕的就是“在 Chrome 里好好的换个浏览器就翻车”。我后来习惯在代码里把 mimeType、sampleRate、channels、kbps 四个值打进日志出问题先看这四个参数比反复试录音快得多。这些坑都是真金白银踩出来的希望帮到你让录音导出在你手上一版跑顺。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门