




















业务里常见路径是:前端录音 → 在浏览器里解码并导出裸 PCM → 把 采样率、声道数、每声道采样点数、位深 等元数据 随文件一起交给后端,后端再 转 MP3 或做其它音频处理;
本文就按照单声道录音和生成单声道 PCM 的流程,把核心写法和思路写出来。
这条链路里最容易踩坑的是:元数据描述的是「如何解读这段字节流」,必须与 OSS/磁盘上 PCM 的真实布局 严格一致。
典型问题包括:文件实际只写了 单声道(mono),却按解码器的 numberOfChannels 上报成 立体声(stereo),
后端按 L/R 交错解析会导致 时长约减半;或 采样率 上报/工具侧与真实不符,又会出现 时长差数倍、体积对不上等现象。
本文把相关概念捋清楚,并给出与演示页、业务代码思路一致的 核心写法,方便前后端对齐。
| 英文 | 中文常说 | 含义 |
|---|---|---|
| Mono(单声道) | 单声道 | 一条声音波形;PCM 里通常是连续的 s₀, s₁, s₂… |
| Stereo(立体声) | 立体声(常见为双声道) | 两条波形(左/右);裸 PCM 里常见 交错:L₀,R₀,L₁,R₁… |
重要:「解码出来是 2 声道」≠「你写进文件的 PCM 一定是立体声交错」。
若代码里只写了第 0 条声道到文件,那文件在布局上就是 mono;若上报里写 channels: 2,下游按 stereo 去拆字节,容易出现时长约减半、听感异常等问题。
samplesPerSec / Hz)含义:每秒采多少个「音量快照」,单位 Hz(如 48000 = 每秒 48000 个点)。
audioBuffer.sampleRate 就是解码后浏览器给出的采样率。
channels)含义:解释「这段 PCM 字节流」时,用几条声道。
channels === 1:整段 buffer 按 一个 int16 接一个 int16 读,就是一条时间轴。channels === 2:按 LRLR… 两个 int16 为一帧,再拆成左、右两条时间轴。必须与文件真实布局一致;否则长度、时长都会错。
samplesPerChannel)含义:每一条声道上有多少个采样点(不是「总 int16 个数」除以 1 或 2 的混淆,而是「每条波形多长」)。
如果只导出 mono,就是:
samplesPerChannel = pcmInt16Data.length
(与「这条 mono 波形有多少个点」一致。)
bytesPerSample)含义:一个采样点在文件里占几字节。
Int16),即 bytesPerSample = 2。文件大小(mono) 近似:
字节数 ≈ samplesPerChannel × channels × bytesPerSample
当 channels = 1、bytesPerSample = 2 时:字节数 = 采样点数 × 2。
对照记忆:
| 变量 | 含义 |
|---|---|
audioBuffer.sampleRate |
解码后的采样率(Hz) |
audioBuffer.numberOfChannels |
解码后有几条声道(常为 2) |
getChannelData(0) |
第 0 条声道的波形 |
pcmInt16Data |
要写进文件的 mono 16bit 数据 |
不要把 numberOfChannels 直接当成「上传 PCM 的 channels」,除非你真的把多声道按规则写进了同一个文件。
channels: 2:下游按 stereo 解析 → 时长约 减半。audioBuffer.channels = 1 这类写法:标准 AudioBuffer 上没有这种改声道数的 API;应显式 channels = 1 上报并与 blob 一致。功能截图如下:

代码如下,代码完整复制就可以直接用
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>录音 → PCM → MP3(演示,无上传)</title>
<!-- lamejs:浏览器端 MP3 编码 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/lamejs/1.2.1/lame.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 20px auto; padding: 0 14px; color: #222; }
h1 { font-size: 1.2rem; }
.hint { color: #666; font-size: 0.88rem; margin-bottom: 16px; }
button { margin: 4px 8px 4px 0; padding: 8px 12px; cursor: pointer; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
fieldset { border: 1px solid #ccc; padding: 10px 12px; margin: 12px 0; border-radius: 4px; }
#status { margin: 8px 0; color: #555; min-height: 1.4em; font-size: 0.9rem; }
#decodeInfo { font-size: 0.85rem; color: #444; margin: 6px 0; }
#pcmSampleRate { width: 100px; }
.audio-wrap { margin-top: 10px; }
</style>
</head>
<body>
<h1>录音 → PCM → MP3</h1>
<p class="hint">概念与字段说明见仓库内 <code>OfficialSite/recordAndUpload-explained.md</code>(可贴到博客)。本页仅代码演示,无 STS / OSS / 上报。</p>
<button type="button" id="btnStart">开始录制</button>
<button type="button" id="btnStop" disabled>停止并生成 PCM</button>
<button type="button" id="btnDlPcm" disabled>下载 PCM</button>
<div id="status"></div>
<div id="decodeInfo"></div>
<fieldset>
<legend>PCM → MP3</legend>
<label for="pcmSampleRate">采样率 Hz</label>
<input type="number" id="pcmSampleRate" min="1" step="1" placeholder="录音后自动填" />
<label for="pcmChannels" style="margin-left:12px;">声道</label>
<select id="pcmChannels">
<option value="1" selected>1 mono</option>
<option value="2">2 stereo</option>
</select>
<label style="margin-left:8px;"><input type="checkbox" id="forceMono" /> 强制 mono</label>
<br />
<button type="button" id="btnMp3" disabled>转 MP3 试听</button>
</fieldset>
<div class="audio-wrap" id="mp3Wrap" style="display:none;">
<audio id="audioMp3" controls></audio>
<button type="button" id="btnDlMp3" disabled>下载 MP3</button>
</div>
<script>
// --- lamejs 支持的 MPEG1 Layer3 采样率;其它值编码前会先重采样到 48000 ---
const MP3_ENCODER_RATES = new Set([32000, 44100, 48000]);
// --- Web Audio 解码器:把 WebM 等解码成 AudioBuffer(浮点、多声道由解码结果决定)---
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
// --- MediaRecorder 实例;录完后在 onstop 里处理 ---
let mediaRecorder = null;
// --- getUserMedia 返回的麦克风流;用完要 stop 所有 track ---
let mediaStream = null;
// --- 录制过程中累积的 Blob 分片 ---
let audioChunks = [];
// --- 最后一次生成的 PCM 原始内存(16bit 小端,与下方导出一致)---
let lastPcmBuffer = null;
// --- 最后一次编码得到的 MP3 Blob,用于下载 ---
let lastMp3Blob = null;
// --- DOM 引用:按钮 ---
const btnStart = document.getElementById('btnStart');
const btnStop = document.getElementById('btnStop');
const btnDlPcm = document.getElementById('btnDlPcm');
const btnMp3 = document.getElementById('btnMp3');
const btnDlMp3 = document.getElementById('btnDlMp3');
// --- 状态行、解码信息展示 ---
const statusEl = document.getElementById('status');
const decodeInfoEl = document.getElementById('decodeInfo');
/** 更新页面底部状态文字 */
function setStatus(t) {
statusEl.textContent = t;
}
/**
* 触发浏览器下载:为 Blob 创建临时 blob: URL,模拟点击 a 标签
* @param {Blob} blob 要下载的二进制
* @param {string} filename 本地保存文件名
*/
function triggerDownload(blob, filename) {
const a = document.createElement('a'); // 创建临时链接触发下载
a.href = URL.createObjectURL(blob); // 为 Blob 生成可在浏览器内引用的 URL
a.download = filename; // 指定下载文件名
a.click(); // 程序化点击,开始下载
URL.revokeObjectURL(a.href); // 释放 URL,避免内存泄漏
}
// --- 点击「开始录制」:申请麦克风 → MediaRecorder 录 WebM ---
btnStart.addEventListener('click', async () => {
try {
// --- 向用户申请音频输入;需 HTTPS 或 localhost,用户授权后才有 stream ---
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = []; // 清空上一轮 ondataavailable 收到的块(本演示一次 stop 通常 1 块或少量块)
mediaRecorder = new MediaRecorder(mediaStream); // 用默认编码(多为 webm/opus)
// --- stop 时浏览器可能推送 1 段或多段 Blob,先收集再拼成一个 WebM(非业务上的「分段录音」)---
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
audioChunks.push(e.data);
}
};
// --- stop() 完成后触发:在此做解码、量化、保存 lastPcmBuffer ---
mediaRecorder.onstop = async () => {
try {
// --- 将本次录音产生的 Blob 合并为单个 WebM,再解码 ---
const webmBlob = new Blob(audioChunks, { type: 'audio/webm' });
// --- Blob → ArrayBuffer,供 decodeAudioData 使用 ---
const arrayBuffer = await webmBlob.arrayBuffer();
// --- slice(0) 拷贝一份,避免部分浏览器解码后 detached 原 buffer ---
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
// --- 只取第 0 声道浮点波形 [-1,1];本演示导出的 PCM 等价 mono ---
const pcmData = audioBuffer.getChannelData(0);
// --- 新建与浮点等长的 16 位整型数组,每个采样 2 字节 ---
const pcmInt16Data = new Int16Array(pcmData.length);
// --- 逐点把 float32 钳制到 [-1,1] 再映射到 int16 满幅 0x7fff ---
for (let i = 0; i < pcmData.length; i++) {
pcmInt16Data[i] = Math.max(-1, Math.min(1, pcmData[i])) * 0x7fff;
}
// --- 保存 ArrayBuffer 供下载与后续 MP3;slice 避免与 TypedArray 视图生命周期纠缠 ---
lastPcmBuffer = pcmInt16Data.buffer.slice(0);
// --- 把解码采样率填到输入框,供 MP3 段使用(与 audioBuffer.sampleRate 一致)---
document.getElementById('pcmSampleRate').value = String(audioBuffer.sampleRate);
// --- 展示解码声道数(常为 2)与导出 mono 的区别,便于对照文档 ---
decodeInfoEl.textContent =
'decode: sampleRate=' +
audioBuffer.sampleRate +
', numberOfChannels=' +
audioBuffer.numberOfChannels +
' | export mono samples=' +
pcmInt16Data.length +
', ~' +
(pcmInt16Data.length * 2 / 1024).toFixed(1) +
' KiB';
btnDlPcm.disabled = false; // 允许下载 PCM
btnMp3.disabled = false; // 允许转 MP3
setStatus('PCM 已就绪。');
} catch (err) {
console.error(err);
setStatus('解码失败: ' + (err && err.message ? err.message : String(err)));
} finally {
// --- 无论成功失败都释放麦克风流 ---
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop());
mediaStream = null;
}
btnStart.disabled = false; // 可再次录制
btnStop.disabled = true; // 停止按钮回到不可用直到下次 start
}
};
mediaRecorder.start(); // 开始采集编码
btnStart.disabled = true; // 录制中禁止重复点「开始」
btnStop.disabled = false; // 允许点「停止」
setStatus('录制中…');
} catch (e) {
alert('麦克风: ' + (e && e.message ? e.message : String(e)));
}
});
// --- 点击「停止」:结束 MediaRecorder,随后触发 onstop ---
btnStop.addEventListener('click', () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
});
// --- 下载最后一次 PCM:原始 16bit LE mono 字节流 ---
btnDlPcm.addEventListener('click', () => {
if (!lastPcmBuffer) {
return;
}
triggerDownload(new Blob([lastPcmBuffer], { type: 'application/octet-stream' }), 'demo-recording.pcm');
});
/**
* 从表单读取「如何解读 PCM 字节流」的元数据,供 lame 编码前使用
* @returns {{channels:number,samplesPerSec:number,bytesPerSample:number}|null}
*/
function readPcmMetaForMp3() {
const srRaw = document.getElementById('pcmSampleRate').value.trim(); // 输入框字符串
const sampleRate = srRaw === '' ? 48000 : Number(srRaw); // 空则默认 48000 Hz
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
alert('请填写有效采样率');
return null;
}
let channels = Number(document.getElementById('pcmChannels').value); // 1 或 2
if (document.getElementById('forceMono').checked) {
channels = 1; // 勾选则强制按单声道解读整段 buffer
}
return { channels: channels, samplesPerSec: sampleRate, bytesPerSample: 2 }; // 固定 16bit=2 字节
}
/**
* 立体声交错 Int16 → 左右两个独立数组(各长度一半)
* @param {Int16Array} interleaved L0,R0,L1,R1,...
*/
function deinterleaveStereo(interleaved) {
const n = (interleaved.length / 2) | 0; // 每声道采样点数(向下取整)
const L = new Int16Array(n); // 左声道缓冲区
const R = new Int16Array(n); // 右声道缓冲区
for (let i = 0; i < n; i++) {
L[i] = interleaved[i * 2]; // 偶数下标为左
R[i] = interleaved[i * 2 + 1]; // 奇数下标为右
}
return { L: L, R: R };
}
/**
* 线性插值重采样:把 input 长度变为 outLen(用于 192k→48k 等非 lame 直接支持的率)
* @param {Int16Array} input
* @param {number} outLen 目标长度
*/
function resampleInt16Linear(input, outLen) {
const inLen = input.length; // 输入长度
if (outLen <= 0) {
return new Int16Array(0);
}
if (inLen <= 1) {
return new Int16Array(outLen).fill(input[0] || 0);
}
const out = new Int16Array(outLen); // 输出缓冲
for (let i = 0; i < outLen; i++) {
const pos = (i * (inLen - 1)) / (outLen - 1); // 映射到输入域的浮点位置
const i0 = Math.floor(pos); // 左邻索引
const i1 = Math.min(i0 + 1, inLen - 1); // 右邻索引
const t = pos - i0; // 插值系数 0~1
const s = input[i0] * (1 - t) + input[i1] * t; // 线性插值
const v = Math.round(s); // 四舍五入到整数
out[i] = v < -32768 ? -32768 : v > 32767 ? 32767 : v; // 钳制到 int16 范围
}
return out;
}
/** 若已是 lame 支持的采样率则原样返回,否则目标 48000 */
function pickMp3SampleRate(sourceRate) {
return MP3_ENCODER_RATES.has(sourceRate) ? sourceRate : 48000;
}
/**
* PCM ArrayBuffer → MP3 Blob(lamejs)
* @param {ArrayBuffer} pcmAb 原始 PCM 字节
* @param {{channels:number,samplesPerSec:number,bytesPerSample:number}} meta
*/
function encodePcmBufferToMp3(pcmAb, meta) {
let channels = meta.channels; // 解读声道数(可能因 forceMono 为 1)
let sampleRate = meta.samplesPerSec; // 解读采样率(Hz)
const bytesPerSample = meta.bytesPerSample; // 每采样字节数,此处恒为 2
const frameBytes = channels * bytesPerSample; // 一帧(所有声道一个采样点)占字节数
if (pcmAb.byteLength % frameBytes !== 0) {
throw new Error('字节数不能整除 frameBytes'); // 声道与文件不匹配时拒绝
}
const pcmInt16 = new Int16Array(pcmAb); // 按 int16 小端视图读整个文件
let left; // 左或 mono 声道数据
let right; // 右声道;mono 时为 null
if (channels === 2) {
const lr = deinterleaveStereo(pcmInt16); // 拆成交错双声道
left = lr.L;
right = lr.R;
} else {
left = pcmInt16; // mono:整段即一条声道
right = null;
}
const encRate = pickMp3SampleRate(sampleRate); // lame 最终使用的采样率
if (encRate !== sampleRate) {
// --- 需要先改变采样率再编码:按时间比例算输出帧数 ---
const outFrames = Math.max(1, Math.round(left.length * (encRate / sampleRate)));
if (channels === 2) {
left = resampleInt16Linear(left, outFrames);
right = resampleInt16Linear(right, outFrames);
} else {
left = resampleInt16Linear(left, outFrames);
right = null;
}
sampleRate = encRate; // 编码器参数与数据对齐
}
const mp3encoder = new lamejs.Mp3Encoder(channels, sampleRate, 128); // 128 kbps
const mp3Data = []; // 收集各段 mp3 二进制块
const block = 1152; // MP3 一帧常见 1152 采样(lamejs 约定)
/** 编码一块 int16;立体声传左右各一块 */
const encodeChunk = (lChunk, rChunk) => {
const buf =
channels === 2 ? mp3encoder.encodeBuffer(lChunk, rChunk) : mp3encoder.encodeBuffer(lChunk);
if (buf.length) {
mp3Data.push(buf);
}
};
// --- 按 block 步进遍历整条左声道(mono 时即全文件)---
for (let i = 0; i < left.length; i += block) {
const lChunk = left.subarray(i, i + block); // 可能不足 block 长
const rChunk = channels === 2 ? right.subarray(i, i + block) : undefined;
const pad = block - lChunk.length; // 最后一块不足 1152 时需零填充
if (pad > 0) {
const lPad = new Int16Array(block);
lPad.set(lChunk);
if (channels === 2) {
const rPad = new Int16Array(block);
rPad.set(rChunk);
encodeChunk(lPad, rPad);
} else {
encodeChunk(lPad);
}
} else {
encodeChunk(lChunk, rChunk);
}
}
const end = mp3encoder.flush(); // 冲刷编码器尾部
if (end.length) {
mp3Data.push(end);
}
return new Blob(mp3Data, { type: 'audio/mp3' }); // 拼成完整 mp3 文件
}
// --- 转 MP3:读表单元数据 + 内存中的 lastPcmBuffer ---
btnMp3.addEventListener('click', () => {
if (!lastPcmBuffer) {
alert('请先录音');
return;
}
const meta = readPcmMetaForMp3();
if (!meta) {
return;
}
try {
lastMp3Blob = encodePcmBufferToMp3(lastPcmBuffer, meta);
const url = URL.createObjectURL(lastMp3Blob);
const audio = document.getElementById('audioMp3');
if (audio.src.startsWith('blob:')) {
URL.revokeObjectURL(audio.src);
}
audio.src = url;
document.getElementById('mp3Wrap').style.display = 'block';
btnDlMp3.disabled = false;
setStatus('MP3 已生成。');
} catch (e) {
alert(e && e.message ? e.message : String(e));
}
});
// --- 下载最后一次生成的 MP3 ---
btnDlMp3.addEventListener('click', () => {
if (!lastMp3Blob) {
return;
}
triggerDownload(lastMp3Blob, 'demo-recording.mp3');
});
</script>
</body>
</html>
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。