JS在线录音导出MP3:Web Audio API与lamejs纯前端实现方案
简介一套基于HTML5 Web Audio API与JavaScript的网页实时录音方案面向需要在前端实现麦克风采集、PCM数据处理与MP3编码导出的Web开发者尤其适合构建在线录音工具、语音留言或音频上传功能。压缩包共6个文件包含3个JavaScript脚本、1个HTML入口页面以及服务端ashx与cs处理文件整体大小仅58KB结构紧凑便于直接部署和二次修改。代码通过getUserMedia获取麦克风流利用ScriptProcessorNode监听音频数据并使用lamejs在Web Worker线程中将PCM样本编码为MP3同时整合了a标签下载与ashx上传接口完整覆盖从采集、编码到传输的链路。已有950人学习参考适合希望快速掌握浏览器端录音编码、线程处理及前后端交互技巧的初中级开发者。1. 为什么「js在线录音录制MP3音频导出代码」最难的不是录音而是导出 MP3如果说「js在线录音录制MP3音频导出代码」真正让人卡住的地方在哪里多半不是“录音”而是最后三个字“MP3”。很多入门文章给的方案是 MediaRecorder写起来确实只有几行但录完导出的文件通常是 WebM/Opus 封装移动端浏览器甚至会给你一个 AAC/MP4而不是产品要的 MP3。常见场景是语音打卡、在线面试、客服质检后端接口写死了 audio/mpeg前端拿不出这个格式只能临时找后端转码一来一回浪费开发时间。这篇文章要解决的是纯前端方案用 Web Audio API 拿麦克风 PCM 数据再通过 lamejs 在前端编码成真正的 MP3 文件直接下载或上传。适合想把录音链路做在前端、不想额外维护转码服务的开发者。2. 录音导出 MP3 的两条技术路线为什么 MediaRecorder 靠不住2.1 MediaRecorder 能录音但导出不了通用 MP3MediaRecorder 这个 API 给初学者的印象是只要指定 mimeType 为 audio/mpeg就能录出 MP3。实际上大多数浏览器的编码器只内置了 Opus、Vorbis 或 AAC对 MP3 编码的支持基本为零。你可以调用MediaRecorder.isTypeSupported(audio/mpeg)去看返回值绝大多数环境返回 false少数返回 true 也只代表封装可以不代表编码质量稳定。我早期在做某跨平台系统的语音备注功能时也想走捷径先用MediaRecorder录等拿到 Blob 后再尝试转格式。结果发现浏览器产出的文件封装修饰得五花八门桌面端大量输出.webm移动端输出.mp4。要让这些文件变成 MP3还得把音频解码出来再重新编码等于把转码逻辑搬到了前端比直接在录音阶段拿 PCM 再编码更绕。MediaRecorder 适合的场景是产品不限定最终编码格式后端也愿意按 webm/opus 处理或者录完直接给同源播放器用。它最大的价值是简单、稳定、系统级封装你不用处理采样率、声道、帧大小这些细节。但代价是你失去对编码格式的控制权这刚好卡死了“导出 MP3”这个硬性需求。2.2 Web Audio API lamejs自己采集 PCM 再编码 MP3既然浏览器不原生提供 MP3 编码器那常见做法就是自己补上最后一段链路用getUserMedia把麦克风流转进 AudioContext通过 AudioWorklet 拿原始 PCM 采样录音结束后把 Float32 采样数组转成 Int16再用 lamejs 的Mp3Encoder按 MP3 帧格式编码最后把编码结果拼成 Blob 导出。这套方案把“录音”和“编码”拆开了。录音阶段只关心采样率、声道数和缓冲分块编码阶段只关心 MP3 的帧大小和比特率。lamejs 是 LAME 编码器的 JavaScript 移植是个纯前端库跑在浏览器里不依赖后端也不依赖操作系统自带编码器。使用 Web Audio 和 lamejs 的完整数据流是这样的麦克风 → MediaStream → AudioContext → AudioWorklet → Float32 PCM chunk → Int16Array → lamejs.Mp3Encoder → MP3 frame 数组 → Blob。每一步都可控你可以决定存成单声道还是双声道决定用 128kbps 还是 96kbps甚至可以在录音过程中实时分析音量。这个灵活性是 MediaRecorder 给不了的。2.3 路线对比哪种情况用哪套方案为了让你快速判断我把两条路线的关键差异整理成一张表对比项MediaRecorderWeb Audio lamejs默认导出格式WebM/Opus、MP4/AAC依赖浏览器MP3格式可控编码质量参数只能选 mimeType无法精确控制比特率可指定采样率、比特率、单双声道实现复杂度低几行代码可用中等需要处理 PCM、帧对齐、编码实时音量监测需要额外分析器可在 AudioWorklet 里直接算内存开销较低浏览器内部封装录音期间需缓存采样数据长录音要优化适合场景对格式不敏感、录完即播产品明确要求 MP3 文件上传或下载如果你的产品只是“录音后播放”或者后端能接受 opus/webm我一般会告诉你直接用 MediaRecorder没必要为了 MP3 增加复杂度。但如果你和标题里写的一样明确要“MP3 音频导出”那第二条路线才是稳定答案。接下来我给出的代码就是第二条路线的完整实现从点击按钮开始录音到停止录音导出 MP3 文件中间每一步都写清楚。3. 用 Web Audio lamejs 实现 MP3 在线录音的最小可运行模块3.1 第一步申请麦克风权限并创建 AudioContext录音链路的起点是麦克风权限。注意一点移动端浏览器对用户手势要求很严格如果不在 click 事件里创建并启动 AudioContext音频上下文会一直处于 suspended 状态录音结果会是空的。所以我习惯把启动逻辑都放在按钮点击的回调里。下面是一段最小可运行的主线程代码负责权限申请、上下文创建和 AudioWorklet 装载let audioContext null; let stream null; let workletNode null; let pcmChunks []; let isRecording false; async function startRecording() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error(当前环境不支持麦克风采集); } audioContext new (window.AudioContext || window.webkitAudioContext)(); // 必须在用户手势里调用 resume()否则移动端可能一直休眠 if (audioContext.state suspended) { await audioContext.resume(); } stream await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: false } }); // 注册独立线程里运行的处理器 await audioContext.audioWorklet.addModule(./recorder-processor.js); const source audioContext.createMediaStreamSource(stream); workletNode new AudioWorkletNode(audioContext, pcm-recorder, { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1, channelCountMode: explicit }); workletNode.port.onmessage (event) { if (event.data event.data.float32Array) { pcmChunks.push(event.data.float32Array); } }; source.connect(workletNode); isRecording true; }这段代码里需要重点理解的参数有三个。第一channelCount: 1表示强制单声道麦克风录入的语音用单声道足够文件体积能少一半。第二echoCancellation: true在语音打卡这类场景里能自动过滤回声和部分环境噪音但如果你需要采集最原始的音频信号做分析应该把它设为 false。第三noiseSuppression: true同样是一把双刃剑它会让语音更干净但也会吃掉一些高频细节。3.2 第二步AudioWorklet 采集 PCM 数据并缓冲AudioWorklet 是替代 ScriptProcessorNode 的现代方案它运行在独立的音频渲染线程里不会因为主线程做动画或网络请求而中断录音。我们需要单独建一个recorder-processor.js文件这个文件不能和主页面脚本混在一起因为addModule加载的是独立线程脚本。class PCMRecorderProcessor extends AudioWorkletProcessor { process(inputs) { const channelData inputs[0] inputs[0][0]; if (channelData) { // 必须 slice 拷贝一份postMessage 之后原始数组可能被复用 this.port.postMessage({ float32Array: channelData.slice(0), sampleRate: sampleRate }); } return true; } } registerProcessor(pcm-recorder, PCMRecorderProcessor);这里最关键的是channelData.slice(0)。音频处理线程内部会复用输入数组如果你直接把channelData发出去主线程收到的数据可能将来会被改写。拷贝一份的代价是每次 postMessage 多复制 128 个 Float32 采样但换来的是数据可靠。return true也不能漏它告诉音频系统这个处理器继续保持活跃。如果返回 false处理器会被销毁后续录音数据就断了。在这个 worklet 里你也可以把sampleRate一起发出来因为编码 MP3 时必须知道当前 AudioContext 用的真实采样率故意硬编码 44100 或 48000 会踩大坑我在第 6 章会细说。3.3 第三步停止录音后用 lamejs 编码并导出 MP3录音停止时先把麦克风轨道停掉防止录音图标一直挂在那里然后把所有 Float32 chunk 拼成一个完整的 Int16Array交给 lamejs 按 1152 个采样一块去编码。1152 是 MP3 帧的标准大小LAME 内部按这个粒度处理分块不齐会导致文件损坏。function float32ToInt16(input) { const output new Int16Array(input.length); for (let i 0; i input.length; i) { const sample Math.max(-1, Math.min(1, input[i])); output[i] sample 0 ? sample * 0x8000 : sample * 0x7FFF; } return output; } function encodeChunksToMP3(chunks, sampleRate, bitRate) { const blockSize 1152; // 单声道编码采样率用 AudioContext 的真实值 const encoder new lamejs.Mp3Encoder(1, sampleRate, bitRate); const mp3Data []; const totalLength chunks.reduce((sum, chunk) sum chunk.length, 0); const allPCM new Int16Array(totalLength); let offset 0; for (const chunk of chunks) { allPCM.set(float32ToInt16(chunk), offset); offset chunk.length; } for (let start 0; start allPCM.length; start blockSize) { const end Math.min(start blockSize, allPCM.length); let block; if (end - start blockSize) { // 末尾不满一帧时补 0 block new Int16Array(blockSize); block.set(allPCM.subarray(start, end)); } else { block allPCM.subarray(start, end); } // 第二个参数是 right 声道占位单声道编码器会忽略它 const frame encoder.encodeBuffer(block, block); if (frame.length 0) { mp3Data.push(new Int8Array(frame)); } } const tail encoder.flush(); if (tail.length 0) { mp3Data.push(new Int8Array(tail)); } return mp3Data; } async function stopAndExport() { if (!isRecording) return; isRecording false; stream.getTracks().forEach((track) track.stop()); workletNode.port.postMessage(stop); const sampleRate audioContext.sampleRate; const mp3Data encodeChunksToMP3(pcmChunks, sampleRate, 96); // 这里调用第 4 章里的下载函数 triggerDownload(mp3Data, recording-${Date.now()}.mp3); pcmChunks []; audioContext.close(); }new lamejs.Mp3Encoder(1, sampleRate, bitRate)三个参数分别是声道数、采样率、比特率。单声道人声录音我一般用 96kbps文件大小适中语音清晰度足够如果对音质要求不高可以降到 64kbps如果录的是音乐片段就提到 128kbps。encoder.flush()必须调用它会把编码器缓冲区里剩余的数据排空漏掉这行会让导出的 MP3 在结尾处少一段。4. 导出 MP3 文件与上传从 ArrayBuffer 到 FormData 的完整链路4.1 把编码结果拼成 Blob 并触发浏览器下载lamejs 返回的是一组字节块它们只是 MP3 数据的一部分需要拼成 Blob 才会被浏览器识别成音频文件。创建下载时要注意URL.revokeObjectURL不能立刻调用否则部分浏览器会在点击下载前就取消链接。function triggerDownload(mp3Data, filename) { const blob new Blob(mp3Data, { type: audio/mpeg }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download filename || recording-${Date.now()}.mp3; document.body.appendChild(link); link.click(); link.remove(); // 延迟释放避免下载被取消 setTimeout(() URL.revokeObjectURL(url), 1000); }Blob 的type字段要写成audio/mpeg不要写成audio/mp3虽然大多数场景没区别但严格遵循 MIME 规范会让部分上传平台少出幺蛾子。link.download必须有.mp3后缀否则有些系统会按无扩展名文件处理。上传链路也很简单把同一个 Blob 塞进 FormData 就行function uploadMP3(mp3Data, fileId) { const blob new Blob(mp3Data, { type: audio/mpeg }); const formData new FormData(); formData.append(audio, blob, recording-${fileId}.mp3); return fetch(/api/audio/upload, { method: POST, body: formData }); }后端收到的就是一个标准 MP3 文件不需要额外标记来源是浏览器还是桌面端。如果你要兼容multipart/form-data以外的接口也可以先把 Blob 转成 ArrayBuffer 或 base64但 FormData 是最不容易出错的路径。4.2 单声道与采样率的选择文件体积、兼容性和参数设置导出环节最常被忽略的是参数选择很多开发者直接把 AudioContext 默认采样率丢给 lamejs然后发现时长对不上。这里有一个很重要的原则lamejs 的采样率参数必须等于 AudioContext 的sampleRate。参数推荐值理由声道数1麦克风语音单声道足够双声道文件体积接近翻倍采样率audioContext.sampleRate避免时长错位MP3 强制转换还会影响音质比特率64-128kbps语音建议 96音乐建议 128编码块大小1152MP3 帧采样数必须按这个分块采样率这里有一个优先级的考虑桌面浏览器通常支持 48000 或 44100移动端可能只支持硬件驱动的采样率。你可以在创建 AudioContext 时传一个{ sampleRate: 48000 }但如果设备不支持浏览器会悄悄用别的采样率不会抛错。因此在代码里用audioContext.sampleRate永远是最稳的。4.3 二次编辑导出临时 WAV 文件用于调试当 MP3 导出后出现问题我会先导出一个 WAV 文件做对照。WAV 没有压缩不需要编码器参与如果 WAV 能正常播放而 MP3 不能问题一定出在 lamejs 编码环节如果 WAV 本身时长或波形就是错的那问题在录音采集环节。function createWavBlob(chunks, sampleRate) { const numChannels 1; const bitsPerSample 16; const bytesPerSample bitsPerSample / 8; const totalSamples chunks.reduce((sum, chunk) sum chunk.length, 0); const buffer new ArrayBuffer(44 totalSamples * bytesPerSample); const view new DataView(buffer); function writeString(offset, text) { for (let i 0; i text.length; i) { view.setUint8(offset i, text.charCodeAt(i)); } } writeString(0, RIFF); view.setUint32(4, 36 totalSamples * bytesPerSample, true); writeString(8, WAVE); writeString(12, fmt ); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); view.setUint16(32, numChannels * bytesPerSample, true); view.setUint16(34, bitsPerSample, true); writeString(36, data); view.setUint32(40, totalSamples * bytesPerSample, true); let offset 44; for (const chunk of chunks) { for (let i 0; i chunk.length; i) { const sample Math.max(-1, Math.min(1, chunk[i])); view.setInt16(offset, sample 0 ? sample * 0x8000 : sample * 0x7FFF, true); offset 2; } } return new Blob([buffer], { type: audio/wav }); }这段 WAV 生成代码可以复用pcmChunks数据模型和 MP3 编码完全一致。调试时我会先调通 WAV再调 MP3能少浪费很多时间。5. 在线录音模块的 5 个高频坑现象、原因与解决5.1 录了 10 秒导出的 MP3 播出来却是 20 秒现象录音时长和播放时长对不上要么翻倍要么减半音频文件能打开但语速明显不对。原因编码采样率和实际录音采样率不一致。AudioContext 默认采样率可能是 48000但你传给Mp3Encoder的采样率写死成了 44100LAME 会按错误的时间基准解释采样点时长自然失真。解决不要硬编码采样率把audioContext.sampleRate一路传到编码函数。如果你在 AudioWorklet 内部拿到了sampleRate优先用 worklet 里那个值因为它是音频线程的真实输出采样率。我在第 3.2 节的消息里带上sampleRate字段就是为了排查这个问题。5.2 导出的 MP3 文件播放器打不开提示文件损坏现象录音流程没报错下载下来的文件有几百 KB但双击播放时提示格式不支持或文件损坏。原因常见原因有三个。第一编码数据没有按 1152 个采样分块导致 MP3 帧边界错乱第二漏调了encoder.flush()结尾数据缺失第三Blob 类型写成了其他 MIME播放器在某些系统里不认。解决分块逻辑严格按blockSize 1152走最后不足一帧补 0编码完成后一定要把flush()的返回结果 push 进mp3DataBlob 的type一律写audio/mpeg。如果仍然损坏把录音 chunk 先导出成 WAV 对照确认是采集问题还是编码问题。5.3 录音时间一长页面内存暴涨甚至崩溃现象录制 5 分钟以内还能跑录到 10 分钟时浏览器标签页卡住点击停止要等很久才有反应。原因pcmChunks里累积的是 Float32 数组停止录音后又复制成一份 Int16Array两倍数据同时存在内存里。一分钟的录音大约有 288 万个采样点Float32 占 11.5MB双声道和长时间录制会把内存迅速吃满。解决如果要支持长录音不要把所有数据留到停止时一次性处理。常见做法是在主线程收到 chunk 后立刻转成 Int16Array并释放 Float32 原数组更稳的做法是把编码任务放到 Web Worker 里边采边编码只保留已编码的 MP3 片段。短录音场景可以维持现有方案但要在产品文档里写清时长上限。5.4 移动端点击录音没反应或者授权后没有声音现象按钮点了权限弹窗也出来了但录音结束后发现文件里全是静音或者某些浏览器直接抛异常。原因移动端浏览器对 AudioContext 的自动播放策略很严格如果没有在用户手势里调用resume()上下文会一直挂在 suspended 状态麦克风数据不会流动到 worklet。另外部分移动浏览器对getUserMedia的audio约束对象支持不完整太复杂的约束配置反而会导致初始化失败。解决把创建 AudioContext 和resume()都放在 click 事件的处理函数里不要异步延后。getUserMedia的约束先用最简单的{ audio: true }跑通再逐步加上降噪、回声消除和自动增益。如果加了autoGainControl就报错说明当前设备不支持该约束需要做降级。5.5 AudioWorklet 加载失败或页面直接报错现象本地打开 HTML 文件时录音初始化报addModule失败或者控制台提示跨域错误。原因AudioWorklet 模块要求运行在安全上下文里file://协议直接打不开跨域引用 JS 文件也会被拒绝。另外部分老旧浏览器内核不支持 AudioWorklet你写好的recorder-processor.js根本不会注册成功。解决开发调试时不要双击 HTML 文件起一个本地静态服务再访问。兼容性上先检测audioContext.audioWorklet是否存在不存在就回退到 ScriptProcessorNode。虽然现在不建议新项目用 ScriptProcessorNode但它在老浏览器里仍然可用作为兜底方案比直接黑屏强。6. 继续做得更稳三件事让录音模块接近生产可用把基本的录音导出跑通之后我会再做三件事采样率归一化、音量监测和降级备份。采样率归一化的核心是让编码参数永远来自运行时的 AudioContext而不是人的习惯。每次录音开始时把audioContext.sampleRate记录到变量里编码时直接传入。如果有人真的希望所有音频都统一成 48000可以创建一个带{ sampleRate: 48000 }的 AudioContext但要清楚设备不支持时浏览器会静默降级不能以为所有用户都会得到 48000。音量监测用 AudioWorklet 顺手就能做。在process函数里计算 RMS 值再通过 postMessage 更新 UI能让用户看到自己的收音是否正常。这段逻辑也不复杂let volumeFrameCount 0; class PCMRecorderProcessor extends AudioWorkletProcessor { process(inputs) { const channelData inputs[0] inputs[0][0]; if (channelData) { let sum 0; for (let i 0; i channelData.length; i) { sum channelData[i] * channelData[i]; } const rms Math.sqrt(sum / channelData.length); volumeFrameCount; if (volumeFrameCount % 10 0) { this.port.postMessage({ type: volume, value: rms }); } this.port.postMessage({ type: audio, float32Array: channelData.slice(0), sampleRate: sampleRate }); } return true; } } registerProcessor(pcm-recorder, PCMRecorderProcessor);降级备份同样重要。我会在启动时做一个能力检测当AudioWorklet不可用或者lamejs没有加载成功时回退到 MediaRecorder 录 webm并在界面上提示用户“当前环境不支持 MP3 导出将使用兼容格式”。这样功能不会完全不可用后端至少能转格式。我曾经在一个语音点评项目里把 MediaRecorder 当成默认录音方案信心满满地以为指定audio/mpeg就能拿到 MP3结果上线当天就收到反馈一半用户录出来的文件上传后播放不了后端只能紧急改接口。后来换成 Web Audio lamejs前端自己编码 MP3 后这类问题再也没出现过。我的习惯是先把最小链路跑通再逐步加音量监测、格式降级和长音频分段编码每一步都留好验证方法。希望帮到你。本文还有配套的精品资源点击获取