简介:面向网页应用的实时录音工具代码,利用浏览器自带的音频处理接口与脚本语言实现麦克风声音采集,并把录音转成通用的压缩音频格式,支持下载到本地或提交到服务器,适合在线课堂、语音留言、录音笔记等场景。压缩包内共有六个文件,包含一个页面入口、三个前端脚本、一个服务端处理程序和一个配套代码文件;三个前端脚本分别负责录音控制、音频数据采集和后台线程压缩转换,服务端文件负责接收上传的音频数据,便于前后端直接联调。具体编码时,先请求用户麦克风权限,再在音频处理节点中监听每一帧的浮点样本数据,将这些数据放入二进制缓冲作临时保存,最后通过压缩编码器转换成最终音频文件,完整覆盖了权限申请、样本收集、格式转换、文件输出等环节。整个压缩包只有五十八千字节,体积小巧、结构清晰,既能直接嵌入到现有项目使用,也可以作为理解浏览器录音与音频编码流程的参考范例。目前已有九百四十九人学习,能够帮助开发者快速掌握实时录音、音频压缩以及前后端交互的关键实现。 做这类网页录音导出的小工具,十有八九会撞上同一个坎:录音功能跑通了,导出的文件拿到Windows上打不开、发到微信里没法直接播放,最后查了一圈发现罪魁祸首是浏览器默认输出的webm或ogg格式。js在线录音录制MP3音频导出这个需求,说白了就是要把浏览器采集到的音频,绕开原生格式限制,实时编码成MP3,再触发下载。我在实际项目里踩过不少坑,这套方案最终稳定跑在生产环境,今天把它完整拆出来讲清楚,希望能帮你少走弯路。
这个方法适合前端工程师、独立开发者,也适合做在线面试、语音留言、语音批注、语音打卡这类场景的技术选型参考。核心思路是:用getUserMedia采集麦克风,经Web Audio API拿到原始PCM数据,再用lamejs库实时编码成MP3,最后生成Blob对象触发浏览器下载。整个过程不需要后端参与,一个静态页面就能完成。
1. 需求拆解与方案选型
1.1 原生MediaRecorder为什么不够用
浏览器其实提供了一个现成的录音接口MediaRecorder,很多第一次做录音功能的人会直接用这个。它的API极其简洁,创建实例后start()就开始录,数据用ondataavailable回调接收,几行代码就能把录音跑通。
问题出在输出格式上。MediaRecorder的编码格式由浏览器内部决定,你没法强制让它输出MP3。实测下来各浏览器的表现大概是这样:
| 浏览器 | 默认容器格式 | 实际编码格式 |
|---|---|---|
| Chrome | webm | opus |
| Firefox | ogg | opus/vorbis |
| Safari | mp4/m4a | aac |
这个兼容性矩阵放在今天依然没有太大变化。网页录音工具最常见的使用者偏偏就是普通用户,他们的文件可能在各种设备之间流转。webm和ogg在Windows自带播放器、老版本手机、微信内置浏览器里的支持度都一言难尽。而MP3作为音频格式的“通用语言”,几乎所有平台都能打开,这决定了如果要做面向用户的工具,MP3导出几乎是一个绕不开的硬需求。
1.2 三条技术路线的对比与选择
为了输出MP3,我调研了好几种方案,最终归纳出三条可行的路线:
方案A:MediaRecorder录制webm/ogg,再用ffmpeg.wasm在浏览器里转成MP3。优点是不用碰底层的音频处理,缺点也很明显:ffmpeg.wasm打包体积大约25MB,加载一次都要好几秒,转码时内存占用也高得吓人,长时间录音甚至可能把页面卡死。
方案B:getUserMedia + Web Audio API采集原始PCM,用lamejs库实时编码成MP3。iamjs是一个由Emscripten把LAME编码器编译成JavaScript的库,总共不到200KB,编码过程完全在本地运行,内存占用可控。
方案C:采集PCM之后先封装成WAV,再交给后端转MP3。这个方案引入了服务端依赖,如果只是做纯前端工具就太重了。
实际选型时我直接排除了A,原因不只是体积,转码流程里多了一步格式解析和重新编码,会引入额外延迟,而且ffmpeg.wasm本身对移动端的兼容性也不如纯JS方案。最终走了方案B,理由很直接:lamejs体积小、实时编码不打断录音、输出格式完全可控。这套方案的缺点是需要自己处理一些底层细节,比如采样率匹配、数据格式转换、浏览器兼容性,后面我会逐个讲清楚。
提示:如果业务场景必须保留原始无压缩音频,可以考虑同时输出WAV版本,这只需要在录音过程中把PCM数据额外存一份,成本很低。
2. 录音与MP3编码的核心原理
2.1 浏览器音频处理管线
先厘清一个概念:麦克风采集到的模拟音频信号,经过声卡模数转换之后,变成了一串又一串的数字采样点。在Web Audio API里,这些采样点以Float32数组的形式呈现,取值范围在-1到1之间,正常说话时大部分采样点其实都集中在-0.3到0.3这个区间。
这些原始采样点就是PCM数据,它本身携带完整的波形信息,但体积非常大。MP3编码要做的事情,就是利用人耳的心理声学模型,丢弃人耳不敏感的频率成分,然后用变长编码把数据压缩到原来的十分之一左右。
整个处理链路在这个项目里是这样的:
- getUserMedia拿到麦克风流(MediaStream)
- 创建AudioContext,把麦克风流转换成音频源节点
- 用ScriptProcessor或AudioWorklet节点挂一个回调,周期性拿到Float32格式的PCM数据
- 把Float32转成Int16(16位PCM),因为MP3编码器接受的是Int16格式
- 把Int16数据块喂给lamejs的编码器
- 编码器输出MP3数据块,逐块拼接
- 停止录音时调用flush,补齐最后一帧数据,生成Blob并导出
这里最关键的一步是采样率匹配。AudioContext.sampleRate在绝大多数设备上是48000Hz,也有一部分设备返回44100Hz,如果你写死在代码里用44100,在48kHz的设备上就会出现音调和速度完全不对的诡异效果。这也是很多人在手机端测试时发现声音变调的根源。
2.2 lamejs是什么?为什么用它
lamejs是LAME编码器的JavaScript移植版本。LAME全称是Lame Aint an MP3 Encoder,这么多年一直是开源社区里最成熟的MP3编码器,几乎所有需要生成MP3的软件底层用的都是它。lamejs通过Emscripten把C代码编译成WebAssembly和JavaScript,让我们能在浏览器里直接调用LAME的编码能力。
引入方式有两种:
npm install lamejs或者直接在HTML里用CDN:
<script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script>用npm引入时需要在构建配置里处理一下worker线程相关的问题;用CDN则最简单,全局会挂一个lamejs对象,直接new Mp3Encoder就行。
lamejs的基本用法很直观,先初始化编码器,然后持续灌入16位PCM数据,每灌一次取一次编码结果:
// 单声道、44100Hz采样率、128kbps码率 const encoder = new lamejs.Mp3Encoder(1, 44100, 128); const mp3Data = []; // samples是Int16Array类型的PCM数据块 const mp3buf = encoder.encodeBuffer(samples); if (mp3buf.length > 0) { mp3Data.push(mp3buf); }2.3 采样率与码率的权衡
代码里有两个参数值得花点心思:采样率和码率。
采样率建议直接读取audioContext.sampleRate,不要写死。这样无论设备是48000Hz还是44100Hz,编码器都能和采集端保持一致,省去重采样环节,这也是避免变调最简单粗暴的方式。
码率方面,128kbps是语音场景下的甜点值。对人声录音来说,96kbps已经能保证基本清晰,128kbps则几乎没有可感知的损失,192kbps更适合录制音乐或需要后期精修的素材。码率越高文件越大,128kbps录一分钟的MP3大约0.96MB,10分钟不到10MB,放在内存里完全没压力。
单声道还是双声道也得想清楚。大部分人声采集场景一个声道足够,文件体积直接减半。如果你要录的是钢琴弹唱、双人对话这种需要空间感的场景,再考虑双声道。在人声录音里强行用双声道,除了让文件变大,没有任何实质收益。
3. 完整实操:从0到1实现在线录音导出MP3
3.1 搭建页面骨架与状态管理
先把页面结构搭出来。开始录音和停止录音两个按钮,加上一个状态提示区,这是录音工具最基础的交互。我习惯把录音状态用枚举管理,而不是散落的布尔变量,后面遇到暂停、恢复、导出这些状态切换时会清晰很多。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>在线录音MP3导出</title> </head> <body> <div id="app"> <h3>网页录音工具</h3> <div id="status">当前状态:未开始</div> <div id="timer">00:00</div> <button id="startBtn">开始录音</button> <button id="stopBtn" disabled>停止并导出</button> <button id="pauseBtn" disabled>暂停</button> <a id="downloadLink" style="display:none;">下载MP3</a> </div> <script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script> <script src="./recorder.js"></script> </body> </html>不要在页面加载时就初始化AudioContext,这会触发浏览器的自动播放策略限制,导致后续录音静默失败。更好的做法是等用户点击开始录音按钮后,在按钮点击的回调函数里再实例化AudioContext,属于用户手势触发的调用,浏览器会放行。
3.2 录制核心模块:从麦克风到PCM
这是整个项目最关键的部分,我在recorder.js里实现了一个Recorder类,把录音、编码、导出三层逻辑拆开。核心代码分三块:获取麦克风权限和创建音频图、采集PCM并转换格式、停止时生成MP3。
初始化音频图和采集PCM的逻辑如下:
let audioContext = null; let mediaStream = null; let sourceNode = null; let scriptNode = null; let mp3Encoder = null; let mp3Data = []; let isRecording = false; async function startRecording() { if (isRecording) return; // 浏览器支持性检查 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert('当前浏览器不支持录音功能,请使用新版Chrome或Edge'); return; } // 1. 获取麦克风权限 mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); // 2. 创建音频上下文,必须通过用户手势触发 const AudioCtx = window.AudioContext || window.webkitAudioContext; audioContext = new AudioCtx(); // 3. 建立音频图:麦克风 -> source -> scriptProcessor -> zeroGain -> destination sourceNode = audioContext.createMediaStreamSource(mediaStream); scriptNode = audioContext.createScriptProcessor(4096, 1, 1); // 用一个静音GainNode保持音频图活跃,避免扬声器播放录音导致的啸叫 const zeroGain = audioContext.createGain(); zeroGain.gain.value = 0; // 4. 初始化MP3编码器 const sampleRate = audioContext.sampleRate; mp3Encoder = new lamejs.Mp3Encoder(1, sampleRate, 128); mp3Data = []; // 5. 注册PCM数据回调 scriptNode.onaudioprocess = onAudioProcess; // 6. 连接节点 sourceNode.connect(scriptNode); scriptNode.connect(zeroGain); zeroGain.connect(audioContext.destination); isRecording = true; document.getElementById('status').textContent = '当前状态:录音中...'; }有一个细节容易被忽略:ScriptProcessor节点如果不连接到destination,音频图不完整,部分浏览器会直接挂起音频上下文,导致回调不触发。但如果你直接让它连接到destination,录音时扬声器会实时播放麦克风采集的声音,放桌上测试时会啸叫。所以我用了一个Gain值设为0的GainNode把声音吸收掉,这样既保证了音频图完整,又不会产生回声。
onaudioprocess回调里做的就是把Float32数组转成Int16数组,再喂给编码器:
function onAudioProcess(e) { const inputBuffer = e.inputBuffer.getChannelData(0); const samples = new Int16Array(inputBuffer.length); for (let i = 0; i < inputBuffer.length; i++) { // 先将样本限制在[-1, 1]范围,再转为16位整数 const s = Math.max(-1, Math.min(1, inputBuffer[i])); samples[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } const mp3buf = mp3Encoder.encodeBuffer(samples); if (mp3buf.length > 0) { mp3Data.push(mp3buf); } }关于scriptProcessor的bufferSize,我选的是4096。这个值越大,回调触发的频率越低,CPU占用越平稳;但单次处理的数据量也大,如果被其他主线程任务阻塞,录音数据可能会出现微小断裂。4096在绝大多数设备上都够用,每个回调大约85毫秒(4096/48000),完全可以满足实时处理的节奏。
3.3 MP3编码与文件导出
停止录音时,需要先把编码器内部的缓冲数据全部刷出来,再生成Blob并触发下载。这里有一个常见坑:很多人忘记调用flush方法,导致录出来的MP3尾部会有一段缺失,表现为播放到最后突然被截断。
function stopRecording() { if (!isRecording) return; // 1. 停止麦克风轨道 if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()); } // 2. 断开音频图并关闭上下文 if (sourceNode) sourceNode.disconnect(); if (scriptNode) scriptNode.disconnect(); if (audioContext) audioContext.close(); // 3. 刷出编码器缓冲的最后一段数据 const end = mp3Encoder.flush(); if (end.length > 0) { mp3Data.push(end); } // 4. 合并所有MP3数据块,生成Blob const blob = new Blob(mp3Data, { type: 'audio/mp3' }); // 5. 生成下载链接 const url = URL.createObjectURL(blob); const filename = `recording_${timestamp()}.mp3`; const downloadLink = document.getElementById('downloadLink'); downloadLink.href = url; downloadLink.download = filename; downloadLink.style.display = 'inline-block'; downloadLink.textContent = '下载 MP3 文件'; isRecording = false; } function timestamp() { const d = new Date(); return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`; }这里有一个资源回收的细节:URL.createObjectURL生成的对象URL在用完之后要记得调用URL.revokeObjectURL释放内存。下载按钮被点击一次就够的场景,可以直接在click事件后把URL回收,否则长时间运行会积累垃圾。
Blob类型我写成audio/mp3而不是application/octet-stream,这样大多数浏览器会把它识别为音频文件,方便后续直接点击预览。
3.4 暂停恢复与录音时长限制
暂停功能用AudioContext的suspend和resume来实现最干净,它会暂停整个音频图的时钟,包括ScriptProcessor回调的触发。这样暂停期间的静音数据不会混入录音文件,音频时间轴也不会有断裂。
function pauseRecording() { if (isRecording && audioContext.state === 'running') { audioContext.suspend(); document.getElementById('status').textContent = '当前状态:已暂停'; document.getElementById('pauseBtn').textContent = '继续录音'; } } function resumeRecording() { if (isRecording && audioContext.state === 'suspended') { audioContext.resume(); document.getElementById('status').textContent = '当前状态:录音中...'; document.getElementById('pauseBtn').textContent = '暂停'; } }对于长时录音的场景,加一个时长限制很有必要。我一般用setInterval每秒钟更新一次UI计时,同时检查总时长,超过设定值自动停止录音。注意计时逻辑要处理暂停态,暂停期间不累计。
let elapsedTime = 0; let timerInterval = null; function updateTimer() { elapsedTime++; const mm = String(Math.floor(elapsedTime / 60)).padStart(2, '0'); const ss = String(elapsedTime % 60).padStart(2, '0'); document.getElementById('timer').textContent = `${mm}:${ss}`; // 超过10分钟自动停止 if (elapsedTime >= 600) { stopRecording(); clearInterval(timerInterval); } }提示:如果你的录音频率很高,start和stop之间切换频繁,建议每次stop时把全局变量都重置或重建,避免上次录音的数据残留在新录音里。
4. 常见问题排查与优化心得
4.1 高频踩坑一览
我把自己在实际开发中踩过的坑整理成了一张速查表,优先级从上到下是按出现频率排的:
| 表现 | 可能原因 | 解决方案 |
|---|---|---|
| 导出的MP3没声音 | ScriptProcessor未连接到任何输出节点,音频图不完整 | 用Gain(0)节点连接到destination |
| 声音变调、节奏变快 | lamejs采样率与audioContext.sampleRate不一致 | 动态读取audioContext.sampleRate传入编码器 |
| MP3尾部截断 | 忘了调用encoder.flush() | 停止录音时务必执行flush() |
| 手机上点击录音没反应 | 未在用户手势中创建AudioContext,或未开启HTTPS | 按钮回调里创建,生产环境必须HTTPS |
| 录音文件打开失败 | Blob类型写成了application/octet-stream | 用audio/mp3作为MIME类型 |
| 长时间录音内存持续上涨 | mp3Data数组无限累积 | 定期写入IndexedDB,或分段保存后合并 |
| 暂停后恢复录音时间轴错乱 | 用track.enabled=false而不是AudioContext.suspend | 使用suspend/resume暂停整个音频图 |
移动端尤其是iOS Safari,限制非常严格。首次打开页面时不能在DOMContentLoaded事件里请求麦克风权限,必须等用户点击录音按钮后才能调用getUserMedia。页面如果不是通过HTTPS协议打开,getUserMedia直接被拒,本地联调时用localhost是豁免的,但局域网IP访问也必须走HTTPS。
4.2 性能优化与后续扩展
ScriptProcessorNode虽然能正常跑,但它的问题在于回调运行在主线程上,如果页面同时有大量DOM操作或者复杂动画,音频处理的实时性会受到干扰。新标准里的AudioWorklet把音频处理逻辑放到了一个独立线程,可靠性强得多。
用AudioWorklet改造的核心思路是:在worklet处理器里接收Float32Array,手动转成Int16Array,再用postMessage把数据发回主线程,主线程里负责喂给lamejs编码。这个方案在长时间录音场景下优势明显,主线程几乎不会卡顿。代价是代码复杂度上了一个台阶,需要单独写一个worklet文件。
如果你的应用需要录音转文字,采集到的PCM数据可以同时喂给Web Speech API或后端语音识别服务,不用等MP3编码完成。我试过在编码的同时把Float32数据降采样到16kHz再单独存一份,用于后续识别模型,效果比直接转MP3再送识别好得多。
另一个可以扩展的方向是波形可视化。sourceNode连接ScriptProcessor的同时,再连一个AnalyserNode,通过requestAnimationFrame读取时域数据,就能在录音过程中实时绘制音量波形,这个交互对用户体验的提升非常明显。
4.3 一个小技巧:规避浏览器的自动播放策略
AudioContext在创建时状态通常是suspended,尤其在你需要恢复播放的时候。这里有个稳妥的写法:在按钮点击回调里获取audioContext,如果state是suspended就调用resume。
async function ensureAudioContextRunning() { if (audioContext && audioContext.state === 'suspended') { await audioContext.resume(); } }这个方法不仅对录音有效,对后续要播放录音预览也通用。很多人录音录的是自己的声音,希望能录完马上听一遍,这个场景就要用起来。我在项目里把预览播放按钮也放在下载链接旁边,点击后直接用HTMLAudioElement播放刚生成的Blob URL,不用再走服务端回读,体验非常流畅。
我个人实际使用下来最满意的部分是这套方案完全不需要后端配合,部署的时候扔到一个静态服务器上就能用,运维成本几乎为零。如果你正在做网页录音、语音批注、在线面试这类产品,可以考虑直接复制这套核心代码跑一遍,再根据业务场景去扩展。最后再提醒一次测试要点:一定要在Chrome、Edge、Safari和微信内置浏览器里各测一遍,录音这类功能在不同内核里的表现差异,往往比你想的还要大。
本文还有配套的精品资源,点击获取