简介:面向Web前端开发者的一套在线录音方案代码,解决在浏览器中实时获取麦克风音频并导出MP3的核心需求,适用于在线教育、语音留言、录音笔记等场景。压缩包共6个文件、约58KB,包含3个JavaScript文件负责录音控制、实时处理与MP3编码,1个HTML页面作为操作入口,以及ashx和cs服务端脚本用于接收上传文件。已有949人学习浏览。实现逻辑完整覆盖从浏览器麦克风权限申请,到AudioContext构建音频处理图,通过MediaStreamAudioSourceNode连接麦克风输入,再由ScriptProcessorNode监听音频帧并收集PCM原始数据;随后使用ArrayBuffer与Float32Array完成样本读取,交由lamejs在Web Worker中编码为MP3,最终利用下载属性或异步上传接口保存文件。同时说明了服务端接收逻辑,并兼顾桌面与移动端浏览器适配。包内页面、脚本、编码库与服务端示例齐全,适合具备JavaScript基础的前端开发者快速搭建网页录音工具,亦可作为理解Web Audio API、getUserMedia与AudioContext协作的实战参考。
1. 网页录音的起点:需求与技术选型
先别急着写代码,咱们把这事儿捋清楚。你搜“js在线录音录制MP3音频导出代码”,大概率是遇到了这类需求:在页面上做一个录音功能,用户点一下开始说话,点一下结束,然后把录音生成一个 MP3 文件下载到本地,或者上传到服务器。这个场景在在线客服、语音备注、消息回复、语音评测类项目里非常常见。
先说结论:浏览器原生能力录不出 MP3 文件。这是很多新手第一次踩坑的地方——以为MediaRecorder直接指定audio/mp3就能导出,结果在 Chrome 里一跑,发现要么报错,要么导出的是webm或者ogg格式。原因很简单,MP3 编码是有专利的,虽然专利已过期,但浏览器厂商出于各种考量,普遍没有内置 MP3 编码器。Chrome 里实测支持格式通常是webm;codecs=opus和audio/webm;Safari 能录audio/mp4;Firefox 能录audio/ogg。所以,想让输出文件是 MP3,必须在拿到原始音频数据后,自己用 JS 做一次编码。
那怎么做?有两条主流路线:
- 路线一:用
MediaRecorder录制浏览器原生支持的格式,再把音频解码成 PCM,最后交给 MP3 编码器编码。这个方案多了一次转码,而且解码和编码都得在前端完成,性能开销大不说,代码复杂度也高。 - 路线二:直接用
getUserMedia拿麦克风音频流,接入Web Audio API采集原始 PCM 数据,然后通过lamejs(LAME 编码器的 JavaScript 移植版)把 PCM 编码成 MP3。这个方案链路短、效率高,直接绕开了MediaRecorder的格式限制,也是目前社区里比较主流的做法。
我下面要分享的方案就是路线二。整体代码量不大,核心依赖只有一个lamejs,加上浏览器原生的getUserMedia和AudioContext,就能实现从录音到 MP3 导出的完整闭环。
提示:
lamejs有维护不太活跃的问题,业界还有@breezystack/lamejs这类 fork 版本修复了部分 bug。我用原版做示例,但生产环境建议使用维护更好的 fork,基础 API 基本一致。
2. 核心依赖与前置准备
这一节先把每个环节用到的 API 和依赖讲透,后边直接看代码就不会晕。
2.1 麦克风权限获取
录音的第一步是拿到麦克风的音轨,用的是navigator.mediaDevices.getUserMedia,它是一个返回 Promise 的异步 API,必须在 HTTPS 或 localhost 环境下运行,否则浏览器会拒绝调用。
async function initMicrophone() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error('当前浏览器不支持录音功能,请使用新版 Chrome、Edge 或 Firefox'); } const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, // 开启回声消除 noiseSuppression: true, // 开启降噪 autoGainControl: true // 自动增益 } }); return stream; }这三项音频处理配置建议全开。回声消除在录音场景里尤其重要,如果你的页面里有扬声器在播放提示音,不开回声消除,麦克风会把扬声器的声音一起录进去,导致回放时全是回声。而且这属于系统级的处理,比后期写代码消除效果要好得多。
2.2 lamejs:前端 MP3 编码器
lamejs的工作机制,简单说就是把 LAME 编码器(一个开源的 MP3 编码库,几乎所有的 MP3 编码软件底层都是它)编译成 JavaScript/WASM,让我们能在浏览器里直接用 JS 调用 MP3 编码逻辑。
引入方式有两种:
<!-- CDN 引入 --> <script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script> <!-- 或者 npm 安装 --> npm install lamejs如果你用 npm 安装,需要留意一点:原版lamejs的main入口走的是 CommonJS,在 Vite 这类 ESM 环境下引入会报错。建议直接改成import { Mp3Encoder } from 'lamejs'或者使用 fork 版本,这里先不多展开,后边的常见问题部分会细说。
lamejs 里最核心的类是Mp3Encoder,实例化时需要传入三个参数:声道数、采样率、比特率。
const mp3Encoder = new lamejs.Mp3Encoder(1, sampleRate, 128); // 参数含义:单声道、采样率(如 44100)、比特率 128kbps这个sampleRate必须与AudioContext的采样率保持一致,否则会出现音频时长错乱、音调变高等问题,这个坑后边我会单独讲。
2.3 录音需要的基础 HTML 结构
为了让示例直接能跑,我先把整个页面的骨架写出来:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>在线录音 MP3 导出</title> </head> <body> <button id="startBtn">开始录音</button> <button id="stopBtn" disabled>停止并导出 MP3</button> <audio id="player" controls></audio> <a id="downloadLink" style="display:none">下载 MP3 文件</a> <script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script> <script> // 核心逻辑后边逐步展开 </script> </body> </html>我习惯把“停止录音”和“导出 MP3”合并成一个动作,简化用户操作路径——点停止,一起生成文件。如果你有“先停止、再预览、最后手动导出”的需求,也可以把导出拆成独立按钮,逻辑不变。
3. 录音核心实现:PCM 采集与 MP3 编码
现在进入正题。整个实现链路是:麦克风流 → AudioContext 解码 → ScriptProcessorNode 采集 PCM → lamejs 编码 → Blob 导出。
3.1 创建音频数据处理链
AudioContext是 Web Audio API 的核心,负责管理音频处理图。我们用createMediaStreamSource把麦克风流接入音频处理图,再用ScriptProcessorNode(也可以选 AudioWorklet,后边会讲差异)监听音频数据回调:
let audioContext, source, processor, mediaStream; let pcmData = []; async function startRecording() { mediaStream = await initMicrophone(); audioContext = new (window.AudioContext || window.webkitAudioContext)(); source = audioContext.createMediaStreamSource(mediaStream); // 4096 是缓冲区大小,数值越小延迟越低、回调越频繁 processor = audioContext.createScriptProcessor(4096, 1, 1); // 处理音频数据 processor.onaudioprocess = (event) => { const inputBuffer = event.inputBuffer; const channelData = inputBuffer.getChannelData(0); // getChannelData 返回 Float32Array,值域 [-1, 1] // 这里先拷贝一份再存,避免后续被内部复用导致数据错乱 pcmData.push(new Float32Array(channelData)); }; // 连接音频处理链 source.connect(processor); processor.connect(audioContext.destination); }几个很容易忽略的细节:
getChannelData返回的数组是 AudioBuffer 内部存储的引用,不是拷贝。如果不new Float32Array(channelData)深拷贝一下,后边获取到的数据可能已经被新数据覆盖,最终导出文件时长对、内容却是乱的。这一点特别坑,我第一次做的时候就因为省了这个拷贝,导出的 MP3 全是“电流声”一样的杂音。processor.connect(audioContext.destination)这一步不能省。如果不把processor连接到destination,onaudioprocess根本不会被触发。也许你会想“我又不播放,连什么 destination”,但ScriptProcessorNode的设计就是这样——必须连接输出端才有数据流。- 我把
echoCancellation、noiseSuppression等设置放在了getUserMedia的配置里,这套系统级处理比任何 JS 层面的降噪方案都稳定可靠。
注意:
ScriptProcessorNode官方已标记为 deprecated,推荐用AudioWorklet替代。但AudioWorklet需要额外加载worklet模块文件,配置成本高不少,而ScriptProcessorNode在主流浏览器上仍然能正常工作。个人录音工具、内部系统这类对兼容性要求不极端的场景,用ScriptProcessorNode是完全可行的。如果你追求未来兼容性,可以自己搜一下“AudioWorklet recorder”,原理相似。
3.2 PCM 数据转 MP3
录音结束后,pcmData里存了一堆Float32Array,每个元素的值域是 [-1, 1],代表采样点的振幅。但 lamejs 的encodeBuffer方法接收的是16-bit 整数 PCM 数据,值域是 [-32768, 32767],所以要先做一次类型转换:
function encodeMP3(audioContext, pcmData) { const sampleRate = audioContext.sampleRate; const mp3Encoder = new lamejs.Mp3Encoder(1, sampleRate, 128); // 把 Float32Array 拼接成一个大数组 const totalLength = pcmData.reduce((sum, chunk) => sum + chunk.length, 0); const pcmFloat32 = new Float32Array(totalLength); let offset = 0; for (const chunk of pcmData) { pcmFloat32.set(chunk, offset); offset += chunk.length; } // 转成 16-bit PCM const pcmInt16 = new Int16Array(totalLength); for (let i = 0; i < pcmFloat32.length; i++) { let sample = pcmFloat32[i] * 32767; // 把 [-1, 1] 映射到整数范围 sample = Math.max(-32768, Math.min(32767, sample)); // 钳制防止溢出 pcmInt16[i] = sample; } // 分块编码 const mp3Chunks = []; const blockSize = 1152; // lamejs 内部帧大小,按这个尺寸切块最稳定 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) { mp3Chunks.push(new Uint8Array(encoded)); } } // 收尾:flush 用于刷新编码器内部缓冲区 const end = mp3Encoder.flush(); if (end.length > 0) { mp3Chunks.push(new Uint8Array(end)); } // 合并所有 Uint8Array const totalBytes = mp3Chunks.reduce((sum, chunk) => sum + chunk.length, 0); const mp3Blob = new Blob(mp3Chunks, { type: 'audio/mp3' }); return mp3Blob; }这里解释几个关键决策:
- 为什么做钳制:
Float32Array在极少数情况下可能因为回声消除算法等处理,出现超出 [-1, 1] 的异常值。不钳制的话,乘以 32767 后可能超过Int16Array范围,出现溢出回绕,听到的结果就是噪声爆音。 - 编码为什么要分块:
lamejs的encodeBuffer每帧处理 1152 个采样点。传一个超长数组进去,它内部会循环处理,但内部缓冲可能不够,容易出问题。按 1152 块大小切片,是社区里经过大量验证的稳妥做法。因为 MP3 属于有损压缩,每个编码帧是一块独立数据,切片编码后再拼接,对整个文件没有任何影响。 - 为什么最后要调一次
flush():编码器内部通常有残留数据还没输出,flush()的作用就是把缓冲区清空,补齐完整的 MP3 帧。不调用它,导出的文件很可能缺结尾数据,播放到最后一秒会突然中断。
3.3 导出下载:从 Blob 到 MP3 文件
编码完成后拿到一个Blob对象,它本质上就是 MP3 文件的二进制内容,接下来只需要触发浏览器下载:
function triggerDownload(mp3Blob, filename = 'recording.mp3') { // 创建本地对象 URL const url = URL.createObjectURL(mp3Blob); // 预览 const player = document.getElementById('player'); player.src = url; // 下载 const downloadLink = document.getElementById('downloadLink'); downloadLink.href = url; downloadLink.download = filename; downloadLink.style.display = 'inline-block'; // 释放对象 URL setTimeout(() => URL.revokeObjectURL(url), 10000); }这一步有一个很容易被忽视的细节:URL.createObjectURL创建的对象 URL 是有生命周期的,页面关闭前需要手动释放,否则会占用内存。我一般是在用户停止录音、点击下载后延迟一段时间再revokeObjectURL。如果提前释放,下载会莫名其妙地失败。别问我是怎么知道的,问就是被虐过。
停止录音时,还需要清理掉一切占用资源:
function stopRecording() { processor.disconnect(); source.disconnect(); audioContext.close(); mediaStream.getTracks().forEach(track => track.stop()); const mp3Blob = encodeMP3(audioContext, pcmData); triggerDownload(mp3Blob); // 重置状态,准备下一次录音 pcmData = []; }mediaStream.getTracks().forEach(track => track.stop())至关重要。不主动停止麦克风轨,浏览器会一直显示“正在使用麦克风”的图标,而且麦克风一直处于占用状态。用户如果在你这个页面录完音,又去开一个视频会议,会发现麦克风被占用了——这就是忘记停止音轨导致的。
4. 常见问题排查与避坑指南
这一节是我个人实操中最常踩的坑,也是社区里被问得最多的问题,逐条整理给大家。
4.1 麦克风获取失败或权限拒绝
这个问题的现象是getUserMedia的 Promise 直接 reject,报错信息常见的有NotAllowedError、NotFoundError、NotReadableError。
| 错误类型 | 可能原因 | 处理方向 |
|---|---|---|
NotAllowedError | 用户拒绝授权,或之前点过“禁止” | 提示用户手动点击浏览器地址栏的摄像头/麦克风图标,重新授权 |
NotFoundError | 设备上没有可用的音频输入设备 | 插上麦克风或耳机再试 |
NotReadableError | 麦克风已被其他应用程序占用 | 关闭占用麦克风的软件,尤其是会议软件和直播软件 |
SecurityError | 页面不是 HTTPS 或 localhost | 部署到 HTTPS 环境,本地开发使用 localhost |
有一个相对隐蔽的问题:用户第一次点了“允许”后,浏览器会记住授权状态,但不会因为页面刷新就自动解除。如果用户中途在系统层面关闭了麦克风权限,getUserMedia会继续返回已过期的 stream,录音出来的声音全是静音。所以建议每次录音前,都重新调用一次getUserMedia,不要缓存 stream 复用。
4.2 导出的 MP3 播放时长与实际不符
这是一类特别常见又特别隐蔽的问题。录了 30 秒,导出的 MP3 播放出来却只有 20 秒,或者反过来,声音变快、音调变高。原因几乎都是:lamejs 输入的采样率跟 AudioContext 的采样率不一致。
比如你的AudioContext经过浏览器自动降级,实际采样率是48000,但你写死了44100传给Mp3Encoder。lamejs 内部按照44100的格式打包 MP3 帧头信息,而数据本身是按48000采的——播放器按44100去解析,时长和音调自然就全乱了。
解决方案是每次动态读取audioContext.sampleRate:
const mp3Encoder = new lamejs.Mp3Encoder(1, audioContext.sampleRate, 128);另外要注意开发调试时控制台命名的“音频设备默认采样率”可能不是设备实际值。建议在页面打印出audioContext.sampleRate对比确认。
4.3lamejs引入报错
在Vite项目中直接import lamejs from 'lamejs',大概率会报lamejs is not defined或模块导出异常。原因是原版lamejs打包方式比较老旧,用 UMD 格式且默认导出在module.exports上挂了一个mp3encoder方法。
最简单的处理方式是直接通过 CDN 引入,或者用 npm 包@breezystack/lamejs,这个 fork 修复了原版的不少编码 bug(原版有部分音质问题,即便正常使用也可能偶尔出现杂音)。
如果是自己封装 npm 项目,还有一个经验:优先 import 具体的类,而不是整个命名空间。
import { Mp3Encoder } from '@breezystack/lamejs';4.4 录音数据二次处理与多段拼接
如果你不满足于“录一段导出一段”,想把多段录音拼接成一个 MP3,这里有个简单技巧:PCM 数据直接拼接,再一次性编码。因为 PCM 是纯采样数据,没有格式头,拼接起来完全无损。
// 把所有录音的 PCM 数据拼到一起 let combinedPCM = new Float32Array(totalLength); // 依次拷贝每段录音数据 // 最后一次性交给 encodeMP3 处理但如果你录的是 MP3 文件,就直接拼接二进制就不行了,因为每个 MP3 文件都有 ID3 标签和帧头信息,直接拼接会导致播放器解析错乱。正确做法是解码成 PCM 再拼接,或者用专门的音频处理库处理,这超出了本文范围,不过有需求的可以顺着这个方向去查。
4.5 录制过程中出现爆音、卡顿
这个问题在这几年移动设备上尤其明显。排查重点有二:
- 回调频率太高。
createScriptProcessor的缓冲区大小设成 4096,在低端手机上仍可能因为回调太频繁造成卡顿。可尝试提高到 8192,牺牲少量延迟换取稳定性。 - 页面主线程阻塞。如果录音过程中有大量 DOM 操作、大数据渲染任务,会导致音频回调丢失数据,产生断音。尽量把录音和其他重逻辑分开处理,例如把编码操作放到
requestIdleCallback或 Web Worker 中。
Web Worker 方案可以这么做:把采集到的 PCM 数据postMessage给 Worker,Worker 内部跑 lamejs 编码,最后再把 MP3 Blob 传回主线程。这样即使编码再耗时,也不会卡住页面 UI。代码量会增加一些,但如果做的是正式产品,这一步必要度还是很高的。
5. 实际操作心得与扩展思路
最后聊一点我在实际开发中的体会。
这套方案我最初是在一个在线面试系统里实现的。候选人需要录制一段自我介绍,然后上传到服务器,面试官后边收听。当时遇到一个很实际的问题:录音文件太大太占磁盘。如果直接录wav格式,一分钟大约 10MB 左右,服务器压力很大;转成 MP3 后,一分钟只有 1MB 上下,压缩率非常可观,这就是为什么“MP3 导出”这个需求会反复出现在各类产品中。
还有一个容易被忽略的点:录音前要做麦克风音量检测。用户点了“开始录音”,但麦克风是坏的,或者音量被调到最低,录了整整两分钟后才发现导出的 MP3 是无声的,体验极其糟糕。建议在录音前用getByteFrequencyData检测一下底噪,小于某个阈值就给出提示“未检测到声音,请检查麦克风”。
另外,导出的文件名最好加上时间戳,避免用户连续录制后下载一堆同名recording.mp3导致浏览器自动改名成recording (1).mp3。我一般用这种格式:
const filename = `recording_${Date.now()}.mp3`;还是用new Date().toISOString().replace(/[:.]/g, '-')生成可读性更强的名字,看团队习惯。
最后再分享一个小技巧:录制过程中实时展示音量波形图。做法是在onaudioprocess里拿一块 PCM 数据的振幅做平方根均值,更新到一个canvas或简单的 CSS 条形动画上。实现成本很低,但用户体验提升非常明显——用户知道自己“确实在录音”,而不是对着静默页面产生疑惑。“这个功能怎么没反应”的反馈,能少掉一大半。
如果你后续还想扩展:上传到服务器用FormData把 Blob 直接 append 进去就行;转wav的话,给 PCM 数据手动加一个 44 字节的 WAV 文件头就行;想做 Web Worker 方案,把Mp3Encoder独立封装成一个通信模块即可。传统做法没有太多绕路的地方,先跑通最简版本,再逐步叠加能力,会更稳妥。
本文还有配套的精品资源,点击获取