拆开 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 文件解析。

相关推荐
_瑞2 小时前
深入理解灵动岛
前端·ios·app
想你依然心痛2 小时前
嵌入式日志系统:分级日志、环形缓冲区与远程输出——运行时调试、非侵入
android·前端·javascript
这是个栗子2 小时前
【Vue代码分析】 import.meta.env.DEV:现代前端工程中的环境变量
前端·javascript·vue.js·环境变量
像牛奶却不是牛奶2 小时前
记录几个前端实用小函数
前端·javascript
IDIOT___IDIOT2 小时前
Windows 解锁后 Chrome 弹出白屏的排查与修复
前端·chrome
tuddy7894642 小时前
前端应用的离线暂停更新策略:原理、实现与最佳实践
前端
希冀1232 小时前
【JavaScript】Javascript—JS进阶—Day03
开发语言·javascript·原型模式
hunterandroid2 小时前
多线程与线程池:从 Thread 到高效并发
前端
光影少年3 小时前
RN 架构:JS层、原生桥接层、原生渲染层 通信原理
开发语言·javascript·架构