在实现使用 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 | 专业音频/视频 |
fmtChunk 是音频文件底层数据中存放音频格式信息的块
声道数
声道数表示音频的声音通道数量。每个声道代表一条独立的声音信号流。就像你有几只耳朵听不同的声音:
| 值 | 名称 | 说明 |
|---|---|---|
| 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 的起始位置 - 中间可能遇到
LIST、fact、cue等可选 chunk,它们会被自动跳过 - 找到
datachunk 后立即break,因为后面的数据通常不重要,不需要额外解析。
时长计算:
ini
duration = dataSize / byteRate
dataSize:data chunk 的字节数byteRate:每秒的字节数(来自 fmt chunk)- 结果单位为秒
有读者可能会有疑问,为什么会自动跳过 LIST、fact、cue 等可选 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 、data、LIST、fact 还是 cue,结构完全一样。所以只需要:
- 读出
chunkSize(这个 chunk 的数据有多少字节) - 跳过
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 | 有符号 | ✅ |
相比 TypedArray,DataView 的最大优势是可以在同一个 buffer 上混合读取不同大小和字节序的数据------这正是解析 WAV 这类混合格式时最需要的特性。
字节序(Endianness)
WAV 文件中的所有多字节整数均采用**小端序(Little-Endian)**存储。
- 小端序:低位字节在低地址("高高低低"------高位在高地址,低位在低地址)
- 例如 16 进制
0x12345678在内存中存储为78 56 34 12 DataView方法的第二个参数传true即表示小端序读取
为什么要归一化到 Float32Array
原始的 PCM 数据是整数,范围取决于位深度(如 16 bit 范围是 -32768, 32767)。归一化到 [-1, 1] 的浮点数范围有以下好处:
- 统一接口:无论源文件是 8/16/24/32 bit,输出格式一致
- Web Audio API 兼容 :
AudioBuffer的getChannelData()返回的就是Float32Array,范围[-1, 1] - 便于后续处理:混音、增益调节、可视化等操作在归一化数据上更为直观
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 文件解析。