拆开 WAV,看见声音——JavaScript 二进制数组实战全解析

在实现使用 JS 二进制数组解析 WAV 音频文件之前,我们先了解音频文件相关的专业名词。

音频文件相关名词解释

音频编码格式(AudioFormat)

在文件底层数据中,音频编码格式为 1 ,表示音频编码格式为 PCM。

常见的音频编码格式:

编码格式 说明
1 PCM 未压缩的原始脉冲编码调制(最常见)
3 IEEE Float 32 位浮点格式
6 A-law 欧洲电话压缩标准
7 μ-law 北美/日本电话压缩标准
85 MP3 MPEG Layer 3(极少用于 WAV)
0xFFFE Extensible 扩展格式(需读额外头部)

采样率

采样率表示每秒对声音信号的采样次数。位于 WAV 音频文件的 fmt Chunk 中,常见值:

采样率 用途
8,000 Hz 电话音质
16,000 Hz 语音识别(VoIP)
22,050 Hz 低质量音频
44,100 Hz CD 音质
48,000 Hz 专业音频/视频

fmt Chunk 是音频文件底层数据中存放音频格式信息的块

声道数

声道数表示音频的声音通道数量。每个声道代表一条独立的声音信号流。就像你有几只耳朵听不同的声音:

名称 说明
1 单声道(Mono) 所有声音混合在一条通道里,左右扬声器播放相同内容
2 立体声(Stereo) 左右两条独立通道,能营造空间感和方向感

声道数也位于 WAV 音频文件的 fmt Chunk 中。

位深度

位深度表示每个采样点用多少二进制位(bit)来存储

位深度决定了每个采样点的动态范围 (最大与最小声压的比值)和量化精度

位深度 每个采样占几字节 动态范围 用途
8 bit 1 字节 ~48 dB 低质量语音
16 bit 2 字节 ~96 dB CD 音质、语音
24 bit 3 字节 ~144 dB 专业录音
32 bit 4 字节 ~192 dB 母带处理

动态范围指音频最响和最轻之间的跨度,单位为分贝(dB)。
量化精度指的是用有限的离散数字去近似连续的模拟信号时,每一步有多"细"

把音频波形想象成一条平滑的斜坡,量化就是用台阶去近似它:

位深度也位于 WAV 音频文件的 fmt Chunk 中。

样本数

样本数就是 WAV 文件中总共包含多少个音频采样点。

变量 含义 单位
totalSamples 总采样点数
dataSize data chunk 数据区的字节大小 byte
bitsPerSample 位深度 bit
bitsPerSample / 8 每个采样点占用的字节数 byte

字节率(Byte Rate)

字节率表示每秒音频数据占多少字节的参数。也位于 fmt Chunk 中。

块对齐大小(Block Align)

块对齐大小表示一个采样帧(Sample Frame)占用多少个字节,即同一时刻所有声道采样值的总字节数 。块对齐大小也位于 fmt Chunk 中。

所谓采用帧是指:

同一时刻,所有声道的采样值组成的一组数据。

它表示的是一个时间点的完整音频信息,而不是一张"视频帧"。

单声道(Mono)

只有一个声道,因此:

复制代码
时间 →
t0    t1    t2

100   120   110

每一个采样值就是一个采样帧:

ini 复制代码
Frame0 = 100
Frame1 = 120
Frame2 = 110

双声道(Stereo)

有左、右两个声道,因此同一时刻需要保存两个采样值:

markdown 复制代码
时间 →
        左声道   右声道

Frame0   100     120
Frame1   105     125
Frame2   110     130

一个采样帧就是:

ini 复制代码
Frame0 = [100, 120]
Frame1 = [105, 125]
Frame2 = [110, 130]

WAV 文件的结构

对音频相关知识有一定概念后,我们开始认识一下 WAV 音频文件的结构。

WAV(Waveform Audio File Format)是一种基于 RIFF(Resource Interchange File Format) 的音频文件格式。它实际上就是由多个 Chunk(数据块) 组成,每个 Chunk 都有固定的结构。

RIFF(Resource Interchange File Format,资源交换文件格式) 是微软和 IBM 在 1991 年提出的一种通用二进制文件容器格式。它定义了一套组织数据的规则,而不是某一种具体的文件类型。

整体结构

对于常见的 PCM WAV 音频文件主要包含三个部分,通常没有可选 Chunk :

PCM 音频数据是什么?

PCM(Pulse Code Modulation,脉冲编码调制) 是一种数字化表示声音的方法,也是最常见、最基础的音频数据格式。

声音本质上是一条连续的波形(模拟信号):

PCM 音频数据就是将声音按固定采样率进行采样,并把每个采样点的振幅量化为数字后形成的一串原始音频样本。

WAV 音频文件结构说明

一个典型 WAV 文件的结构:

文件头中包含 RIFF 字符串标识这是一个 RIFF 容器格式的文件(Resource Interchange File Format),它是一种通用的二进制容器规范,不仅用于 WAV,也用于 AVI 视频等。

WAVE 字符串标识该 RIFF 容器内存储的具体数据格式是 WAV 音频。

一个 Chunk 的通用结构是:

对于 fmt Chunk:

这里的 16 表示:

fmt Chunk 的数据部分(ChunkData)有 16 个字节。

PCM 的 fmt 数据为什么刚好是 16 字节?

因为 PCM 格式只需要下面 6 个字段:

字段 大小
AudioFormat 2 Bytes
NumChannels 2 Bytes
SampleRate 4 Bytes
ByteRate 4 Bytes
BlockAlign 2 Bytes
BitsPerSample 2 Bytes

加起来:

diff 复制代码
2
+2
+4
+4
+2
+2
------
16 Bytes

所以:

ini 复制代码
ChunkSize = 16

不过要注意,不是所有 WAV 的 fmt Chunk 都是 16 字节,有的 fmt Chunk 的数据部分可能比 16 字节长。

PCM 格式的 fmt Chunk 中,ChunkSize = 16,是因为它的数据部分正好由 6 个固定字段组成,共 16 字节。

对于其他音频格式,由于需要额外的格式信息,ChunkSize 可能是其他值。

纯前端 WAV 音频文件解析器的实现

使用 JS 二进制数组解析 WAV 音频文件,得出 WAV 音频文件的文件大小、采样率、声道数、位深度、时长和样本数。

核心流程如下:

整个解析过程依赖 DataView 来按字节精确读取二进制数据,无需任何后端或第三方库。

核心函数详解

readString(view, offset, length) ------ 字节 → 字符串

javascript 复制代码
function readString(view, offset, length) {
  let str = "";
  for (let i = 0; i < length; i++) {
    str += String.fromCharCode(view.getUint8(offset + i));
  }
  return str;
}

作用 :从 DataView 的指定偏移量开始,逐字节读取并拼接成 ASCII 字符串。

关键点

  • 使用 view.getUint8(offset + i) 逐字节读取,每个字节对应一个 ASCII 字符
  • 用于读取 chunk ID(如 "RIFF""WAVE""fmt ""data"),这些标识符恰好是 4 字节的 ASCII 字符串
  • "fmt " 后面有一个空格,补齐到 4 字节,这是 WAV 规范的要求

parseFmtChunk(view, offset) ------ 解析格式块

javascript 复制代码
function parseFmtChunk(view, offset) {
  const audioFormat = view.getUint16(offset, true);
  if (audioFormat !== 1) throw new Error("不支持的音频格式...");

  return {
    audioFormat, // 音频格式(1 = PCM)
    channels: view.getUint16(offset + 2, true), // 声道数
    sampleRate: view.getUint32(offset + 4, true), // 采样率(Hz)
    byteRate: view.getUint32(offset + 8, true), // 字节率 = sampleRate * blockAlign
    blockAlign: view.getUint16(offset + 12, true), // 数据块对齐 = channels * bitsPerSample / 8
    bitsPerSample: view.getUint16(offset + 14, true), // 位深度
  };
}

作用 :解析 WAV 文件的 fmt chunk,提取音频格式元数据。

各字段含义

字段 说明 示例(CD 音质)
audioFormat 1 = PCM(无损),其他值表示压缩格式 1
channels 1 = 单声道,2 = 立体声 2
sampleRate 每秒采样次数 44100 Hz
byteRate 每秒数据字节数 176400
blockAlign 每个采样帧的字节数 4
bitsPerSample 每个样本的位数 16

关键点

  • 所有多字节数值均使用 小端序(Little-Endian)getUint16/32 的第二个参数 true 即表示小端序

  • 仅支持 PCM 格式(audioFormat === 1),遇到其他格式会直接抛出错误

  • byteRate = sampleRate × channels × bitsPerSample / 8,可用于计算音频时长

有读者可能会问,为什么 byteRate 可用于计算音频时长?

其实,就像"路程 ÷ 速度 = 时间" ,byteRate 表示每秒有多少字节数据,那音频的总字节数 除以每秒字节数,就可以得到音频时长。公式如下:

readPcmSamples(view, offset, size, fmt) ------ 读取 PCM 样本数据

javascript 复制代码
function readPcmSamples(view, offset, size, fmt) {
  const totalSamples = size / (fmt.bitsPerSample / 8);
  const samples = new Float32Array(totalSamples);
  const maxVal = Math.pow(2, fmt.bitsPerSample - 1);

  for (let i = 0; i < totalSamples; i++) {
    let raw;
    if (fmt.bitsPerSample === 8) {
      raw = view.getUint8(offset + i) - 128;
    } else if (fmt.bitsPerSample === 16) {
      raw = view.getInt16(offset + i * 2, true);
    } else if (fmt.bitsPerSample === 24) {
      raw =
        view.getUint8(offset + i * 3) |
        (view.getUint8(offset + i * 3 + 1) << 8) |
        (view.getInt8(offset + i * 3 + 2) << 16);
    } else if (fmt.bitsPerSample === 32) {
      raw = view.getInt32(offset + i * 4, true);
    } else {
      raw = 0;
    }
    samples[i] = raw / maxVal;
  }
  return samples;
}

作用 :从 data chunk 的原始字节中逐样本读取 PCM 数据,并归一化到 [-1, 1] 的浮点数范围。

核心逻辑分两步

第一步:读取原始整数值
位深度 读取方式 说明
8 bit getUint8() - 128 8 位 PCM 是无符号的(0~255),减 128 转为有符号(-128~127
16 bit getInt16(offset, true) 有符号 16 位整数,小端序
24 bit 手动拼接 3 字节 DataView 没有 getInt24,需手动将 3 个字节组合为 32 位有符号整数
32 bit getInt32(offset, true) 有符号 32 位整数,小端序

24 bit 拼接详解

scss 复制代码
byte0 | (byte1 << 8) | (byte2 << 16)
  • byte0:最低有效字节(无符号)
  • byte1:中间字节(无符号,左移 8 位)
  • byte2:最高有效字节(有符号,左移 16 位,用于符号扩展)

最高字节用 getInt8(有符号)读取是关键------当该字节的最高位为 1 时,JavaScript 的位运算会自动进行符号扩展,使拼接结果正确表示负数。

第二步:归一化
css 复制代码
samples[i] = raw / maxVal

其中 maxVal = 2^(bitsPerSample - 1),即该位深度下最大振幅的一半。例如:

  • 16 bit:maxVal = 2^15 = 32768,原始值范围 [-32768, 32767],归一化到 [-1.0, 1.0]
  • 8 bit:maxVal = 2^7 = 128,原始值范围 [-128, 127],归一化到 [-1.0, 1.0]

注意 :对于 16 bit 有符号整数,最小值 -32768 归一化后为 -32768/32768 = -1.0,最大值 32767 归一化后为 32767/32768 ≈ 0.99997。归一化结果存在微小的不对称性(无法达到精确的 +1.0),这是有符号整数表示的特性,属于正常现象。

返回值Float32Array,每个元素 ∈ -1, 1,可直接用于 Web Audio API 等场景。

parseWav(buffer) ------ 主解析函数

javascript 复制代码
function parseWav(buffer) {
  const view = new DataView(buffer);

  // 1. 校验 RIFF 头
  const riff = readString(view, 0, 4);
  if (riff !== "RIFF") throw new Error("不是有效的 WAV 文件: 缺少 RIFF 标识");

  const riffSize = view.getUint32(4, true);
  const fileSize = riffSize + 8;

  // 2. 校验 WAVE 格式
  const wave = readString(view, 8, 4);
  if (wave !== "WAVE") throw new Error("不是有效的 WAV 文件: 缺少 WAVE 标识");

  // 3. 遍历子块(sub-chunks)
  let offset = 12;
  let fmt = null;
  let dataOffset = -1;
  let dataSize = 0;

  while (offset < buffer.byteLength) {
    const chunkId = readString(view, offset, 4);
    const chunkSize = view.getUint32(offset + 4, true);

    if (chunkId === "fmt ") {
      fmt = parseFmtChunk(view, offset + 8);
    } else if (chunkId === "data") {
      dataOffset = offset + 8;
      dataSize = chunkSize;
      break; // 找到 data chunk 后停止遍历
    }

    offset += 8 + chunkSize; // 跳到下一个 chunk
  }

  // 4. 校验必要数据
  if (!fmt) throw new Error("未找到 fmt chunk");
  if (dataOffset === -1) throw new Error("未找到 data chunk");

  // 5. 读取 PCM 样本并计算时长
  const samples = readPcmSamples(view, dataOffset, dataSize, fmt);
  const duration = dataSize / fmt.byteRate;

  return { fileSize, sampleRate, channels, bitDepth, duration, samples };
}

分步解析

步骤 操作 说明
1 校验 RIFF 头 确认文件是 RIFF 容器格式
2 校验 WAVE 标识 确认容器内是 WAV 音频数据
3 遍历子块 从偏移 12 开始扫描所有 chunk,提取 fmt data
4 校验完整性 确保同时找到了格式信息和音频数据
5 解析样本 从 data chunk 读取 PCM 样本,计算时长

chunk 遍历机制

  • 每个 chunk 的结构:[4字节ID][4字节大小][大小字节的数据]
  • 当前 chunk 结束后,偏移量增加 8 + chunkSize,即跳到下一个 chunk 的起始位置
  • 中间可能遇到 LISTfactcue 等可选 chunk,它们会被自动跳过
  • 找到 data chunk 后立即 break,因为后面的数据通常不重要,不需要额外解析。

时长计算

ini 复制代码
duration = dataSize / byteRate
  • dataSize:data chunk 的字节数
  • byteRate:每秒的字节数(来自 fmt chunk)
  • 结果单位为

有读者可能会有疑问,为什么会自动跳过 LISTfactcue 等可选 chunk ?

完全靠这一行代码:

javascript 复制代码
offset += 8 + chunkSize;

逻辑非常简洁。看这段 while 循环:

javascript 复制代码
while (offset < buffer.byteLength) {
  const chunkId = readString(view, offset, 4); // 读 4 字节 ID
  const chunkSize = view.getUint32(offset + 4, true); // 读 4 字节大小

  if (chunkId === "fmt ") {
    fmt = parseFmtChunk(view, offset + 8); // ✅ 认识,解析它
  } else if (chunkId === "data") {
    dataOffset = offset + 8; // ✅ 认识,记录位置
    dataSize = chunkSize;
    break;
  }

  offset += 8 + chunkSize; // 不管认不认识,都跳到下一个 chunk
}

关键在 offset += 8 + chunkSize 这一行。它的作用是:无条件地把偏移量推进到下一个 chunk 的开头


为什么能"无条件"?

因为 WAV 的每个 chunk 是自描述的------它在头部明确告诉你了"我的数据有多长":

不管这个 chunk 是 fmt dataLISTfact 还是 cue,结构完全一样。所以只需要:

  1. 读出 chunkSize(这个 chunk 的数据有多少字节)
  2. 跳过 8 + chunkSize 字节(= 头部的 8 字节 + 数据的 chunkSize 字节)

光标就恰好落在了下一个 chunk 的起始位置。


图解:遇到 LIST chunk 时发生了什么

代码层面,chunkId === 'LIST',不匹配 'fmt ' 也不匹配 'data',两个 if 分支都不进。然后执行 offset += 8 + chunkSize,直接跳过整个 LIST chunk。

关键技术点总结

DataView ------ 二进制数据的瑞士军刀

DataView 是 JavaScript 处理二进制数据的核心 API,提供:

方法 读取字节数 有无符号 字节序控制
getUint8() 1 无符号 无需(单字节)
getInt16() 2 有符号
getUint32() 4 无符号
getInt32() 4 有符号

相比 TypedArrayDataView 的最大优势是可以在同一个 buffer 上混合读取不同大小和字节序的数据------这正是解析 WAV 这类混合格式时最需要的特性。

字节序(Endianness)

WAV 文件中的所有多字节整数均采用**小端序(Little-Endian)**存储。

  • 小端序:低位字节在低地址("高高低低"------高位在高地址,低位在低地址)
  • 例如 16 进制 0x12345678 在内存中存储为 78 56 34 12
  • DataView 方法的第二个参数传 true 即表示小端序读取

为什么要归一化到 Float32Array

原始的 PCM 数据是整数,范围取决于位深度(如 16 bit 范围是 -32768, 32767)。归一化到 [-1, 1] 的浮点数范围有以下好处:

  1. 统一接口:无论源文件是 8/16/24/32 bit,输出格式一致
  2. Web Audio API 兼容AudioBuffergetChannelData() 返回的就是 Float32Array,范围 [-1, 1]
  3. 便于后续处理:混音、增益调节、可视化等操作在归一化数据上更为直观

24 bit PCM 的特殊处理

JavaScript 没有原生的 24 位整数类型,因此需要手动用 3 个字节拼接:

javascript 复制代码
raw = byte0 | (byte1 << 8) | (byte2 << 16);

这实际上是构造了一个 32 位的 JavaScript 整数,其中低 24 位来自 WAV 数据。最高字节使用有符号读取getInt8)是为了正确处理负数的符号扩展。

总结

要实现 JavaScript 解析 WAV 文件,关键在于掌握 WAV 文件结构和 JavaScript 二进制数组的用法。

依据 WAV 的文件结构特点,使用 JavaScript 二进制数组完成 WAV 文件解析。

相关推荐
whyfail6 小时前
前端学 Spring Boot(5):从“你是谁”到“服务真的上线了”
前端·spring boot·后端
程序员黑豆7 小时前
鸿蒙开发:RelativeContainer相对布局组件实战——实现5个文本组件对齐
前端·华为·harmonyos
环境栈笔记7 小时前
高性价比指纹浏览器推荐与选型:如何对照价格和实际可用功能筛选候选
前端·人工智能·后端·自动化
hyf3266337 小时前
泛程序哪有想象中难!小白跟着走一遍就全懂
前端·爬虫·搜索引擎·seo·蜘蛛池
码农学院8 小时前
React公众号开发:母婴用品会员积分体系技术方案
前端·react.js·前端框架
路光.8 小时前
Vue2升级Vue3处理原 UMD/CommonJS 打包文件,不是标准 ESM
前端·javascript·vue.js·vue3
黄华SJ520it8 小时前
门店预约系统开发:提升服务行业数字化转型
前端·数据库·系统开发
一只月月鸟呀9 小时前
移动端tap与click的区别 && 点透事件
开发语言·前端·javascript
雪隐9 小时前
AI股票小助手10-我用代码管住自己的手:一个普通人的市场观察笔记
前端·人工智能·后端
KaMeidebaby10 小时前
卡梅德生物技术快报|抗体筛选:抗体筛选中的噬菌体展示四参数调优——从流程设计到数据验证
开发语言·前端·javascript