惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

雷峰网
雷峰网
宝玉的分享
宝玉的分享
I
InfoQ
P
Privacy International News Feed
V
V2EX
IT之家
IT之家
S
SegmentFault 最新的问题
D
Darknet – Hacking Tools, Hacker News & Cyber Security
V2EX - 技术
V2EX - 技术
C
CERT Recently Published Vulnerability Notes
C
Check Point Blog
The Register - Security
The Register - Security
爱范儿
爱范儿
博客园 - 三生石上(FineUI控件)
AWS News Blog
AWS News Blog
M
MIT News - Artificial intelligence
C
Cyber Attacks, Cyber Crime and Cyber Security
F
Fortinet All Blogs
B
Blog
N
Netflix TechBlog - Medium
B
Blog RSS Feed
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Last Week in AI
Last Week in AI
T
Threatpost
Forbes - Security
Forbes - Security
U
Unit 42
A
Arctic Wolf
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
P
Palo Alto Networks Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Recorded Future
Recorded Future
L
Lohrmann on Cybersecurity
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Proofpoint News Feed
月光博客
月光博客
Spread Privacy
Spread Privacy
MongoDB | Blog
MongoDB | Blog
Jina AI
Jina AI
I
Intezer
V
Visual Studio Blog
阮一峰的网络日志
阮一峰的网络日志
The Hacker News
The Hacker News
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
L
LangChain Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园_首页
MyScale Blog
MyScale Blog
腾讯CDC
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
量子位

博客园 - 进军的蜗牛

Web 语音场景下多路麦克风采集与音量异常:成因与两种工程取舍 从一次权限错误,彻底搞懂Mac权限管理那些事 js实现svga编辑器 Chrome浏览器普通模式访问页面报错 ERR_CONNECTION_REFUSED 问题排查 Web语音聊天室中录音无声问题分析与解决方案 蓝湖SVG上传Iconfont阿里图标库部分元素缺失问题分析与解决方案 前端测试新体验:Cypress让UI测试变得如此简单 iOS H5页面高度闪烁问题与100vh兼容性详解 为什么录音生成的音频文件有时长但没声音?—— 一次音频采集问题的排查与解决 前端实现采集图片某个区域的颜色色值 浏览器多Tab间通信方法全面总结:从WebView需求出发的技术方案 pcm文件转为mp3并播放 github https方式 push无法提交代码的处理方法 前端如何计算js代码执行时长 基于swiper和animate.css的全屏H5动效和花瓣飘落效果 混合开发H5页面的数字字体在IOS 安卓上显示不一样 node基于自签名证书搭建https服务 ios系统上h5页面点击播放audio标签声音有延迟问题处理 基于svelte webpack开发umd格式的npm包 CSS运动路径offset-path之path的语法解析
浏览器音频入门:录音生成 PCM、单声道/立体声、采样率、转 MP3
进军的蜗牛 · 2026-05-12 · via 博客园 - 进军的蜗牛

故事背景

业务里常见路径是:前端录音 → 在浏览器里解码并导出裸 PCM → 把 采样率、声道数、每声道采样点数、位深 等元数据 随文件一起交给后端,后端再 转 MP3 或做其它音频处理;
本文就按照单声道录音和生成单声道 PCM 的流程,把核心写法和思路写出来。

这条链路里最容易踩坑的是:元数据描述的是「如何解读这段字节流」,必须与 OSS/磁盘上 PCM 的真实布局 严格一致。
典型问题包括:文件实际只写了 单声道(mono),却按解码器的 numberOfChannels 上报成 立体声(stereo)
后端按 L/R 交错解析会导致 时长约减半;或 采样率 上报/工具侧与真实不符,又会出现 时长差数倍、体积对不上等现象。

本文把相关概念捋清楚,并给出与演示页、业务代码思路一致的 核心写法,方便前后端对齐。


1. Mono 和 Stereo 是什么?

英文 中文常说 含义
Mono(单声道) 单声道 一条声音波形;PCM 里通常是连续的 s₀, s₁, s₂…
Stereo(立体声) 立体声(常见为双声道) 两条波形(左/右);裸 PCM 里常见 交错L₀,R₀,L₁,R₁…

重要:「解码出来是 2 声道」≠「你写进文件的 PCM 一定是立体声交错」。
若代码里只写了第 0 条声道到文件,那文件在布局上就是 mono;若上报里写 channels: 2,下游按 stereo 去拆字节,容易出现时长约减半、听感异常等问题。


2. 采样率(samplesPerSec / Hz)

含义:每秒采多少个「音量快照」,单位 Hz(如 48000 = 每秒 48000 个点)。

  • 同样录音时长,采样率越高,点数越多,未压缩 PCM 体积越大(约与采样率成正比,同声道、同位深时)。
  • 常见值:44100(CD)、48000(视频/直播常用);设备/浏览器也可能给出 96000、192000 等。

audioBuffer.sampleRate 就是解码后浏览器给出的采样率。


3. 声道数(channels

含义:解释「这段 PCM 字节流」时,用几条声道。

  • channels === 1:整段 buffer 按 一个 int16 接一个 int16 读,就是一条时间轴。
  • channels === 2:按 LRLR… 两个 int16 为一帧,再拆成左、右两条时间轴。

必须与文件真实布局一致;否则长度、时长都会错。


4. 每声道采样点数(samplesPerChannel

含义每一条声道上有多少个采样点(不是「总 int16 个数」除以 1 或 2 的混淆,而是「每条波形多长」)。

如果只导出 mono,就是:

samplesPerChannel = pcmInt16Data.length

(与「这条 mono 波形有多少个点」一致。)


5. 每个采样占多少字节(bytesPerSample

含义:一个采样点在文件里占几字节。

  • 本演示固定 16 bit = 2 字节(小端 Int16),即 bytesPerSample = 2
  • 若为 32 bit PCM,则为 4,以此类推。

文件大小(mono) 近似:

字节数 ≈ samplesPerChannel × channels × bytesPerSample

channels = 1bytesPerSample = 2 时:字节数 = 采样点数 × 2


对照记忆

变量 含义
audioBuffer.sampleRate 解码后的采样率(Hz)
audioBuffer.numberOfChannels 解码后有几条声道(常为 2)
getChannelData(0) 第 0 条声道的波形
pcmInt16Data 要写进文件的 mono 16bit 数据

不要numberOfChannels 直接当成「上传 PCM 的 channels」,除非你真的把多声道按规则写进了同一个文件。


6. 常见坑

  1. mono 文件 + 上报 channels: 2:下游按 stereo 解析 → 时长约 减半
  2. 高采样率 PCM + 工具默认 48k mono 读:时长可能 变长数倍(同一段样本被「慢放」理解)。
  3. audioBuffer.channels = 1 这类写法:标准 AudioBuffer没有这种改声道数的 API;应显式 channels = 1 上报并与 blob 一致。

7. 下面是一个完整的例子

功能截图如下:
截屏2026-05-12 11.34.01

代码如下,代码完整复制就可以直接用

<!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>