【文件格式探究】EP.2 WAV 音频文件格式
“WAV” 全名 “Waveform Audio File Format”, 是一种常见的存储音频信息的文件格式标准, 从其名称上即可看出其存储的是音频的波形信息. 一般 WAV 存储的音频是未压缩的, 且遵循 RIFF 标准来构建文件内容, 相较于其他音频格式省去了解压缩等处理, 复杂度较低. 本文将通过一个真实的 WAV 格式文件示例, 一一说明 WAV 文件格式中各个数据块的含义和位置, 并提供简单地读写 WAV 文件的代码实现, 并最终尝试生成自定义波形的 WAV 文件. 本文除涉及 WAV 格式最重要的文件头和数据块之外, 还将讲解 LIST-INFO 元信息格式在 WAV 中的应用.
前置知识: 音频的数位化表示
声现象的本质是振动. 发声源通过产生人耳可听范围内某一频率的振动, 人耳即可以听到声音. 若将扬声器的表面看作一个富有弹性的平面, 且初始状态静止于零形变处, 那么扬声器在工作时, 可以认为其表面不停地向内或向外产生相对于初始状态的位移 - 这被称为 “振幅”. 扬声器工作时其表面的位移量, 可以看作是一个关于时间的函数.
上述场景在真实世界中很容易理解, 但在计算机中需要考虑一些很重要的问题, 例如: 计算机存储数据的精度是有限的, 而真实世界中完全可能出现在时间等于 $\pi$ 秒时振幅为 $\sqrt{2}$; 同理, 扬声器的振幅可以为 $1$, 也可以为 $1e10$. 计算机如何存储精度要求极高或如此巨大的数字? 答案很简单: 不存储. 但我们自然希望尽可能精确且全面地记录下这些振幅以及对应的时刻, 这就牵扯到后面提到的 “PCM” (Pulse-code modulation, 脉冲编码调制).
PCM
调制可以理解为将输入信号转变为另一个信号的过程. 而在我们上述的场景中, 当我们面对一个信息量巨大且信息精度很高的 “真实世界信号” (或者应该称之为 “模拟信号”) 时, 我们希望通过一种调制方式将其转变为计算机可接受的 “数字信号”. 一种应用相当普遍的调制方式即为 PCM, 它经常用于模数转换.
简单来说, PCM 是在时域上对模拟信号的时间轴和振幅轴同时做量化处理, 使之可以用一系列有限值来表示. 为方便起见, 本文仅考虑 LPCM (Linear pulse-code modulation) - 一种量化等级线性均匀排布的调制方式. 因为这种调制方式太过常见, 若无特别说明, 以下的 “PCM” 均指代 “LPCM”.
从数学上来说, 若要将振幅范围 $[0,A]$ 线性映射到一系列量化值 ${0,1,\cdots,N}$, 则对于某一振幅 $x\in[0,A]$, 其将会被量化到 $\mathrm{round}\left(\dfrac{x}{A}N\right)$, 其中 $\mathrm{round}(\cdot)$ 表示四舍五入. 显然 $N$ 决定了我们量化的精度. 较大的 $N$ 对于较大的 $A$ 也可以保持较好的量化效果 (如果不去除这些振幅较大的值), 对于较小的 $A$ 可以使其量化结果更接近于真实值.
一般来说, 量化范围内能表示的所有整数所需要的比特位数被称为位深 (bit depth), 常见的位深有 8-bit, 16-bit, 24-bit, 分别表示量化范围内可以表示 $2^8$, $2^{16}$, $2^{24}$ 个连续整数. 实际应用中, 一般 8-bit PCM 的量化范围为 $[0,2^8)$, (或 $[0,256)$) 也就是无符号整数; 而超过这一位深的量化范围 (以 16-bit 为例) 通常表示为 $[-2^{15},2^{15})$ (或 $[-32768,32768)$), 也就是有符号整数. 对于前者, 其零振幅时的量化值为 $2^7$ (或 128); 对于后者, 其零振幅时的量化值为 0.
此时仍然还有一个需要讨论的问题 - 时间轴如何量化? 一般来说我们仅考虑线性均匀采样, 以一个固定的采样率 (sample rate). 这是与位深类似的概念 - 显然采样率越高, 采样就越精确, 但这会带来很大的存储和处理成本; 而采样率下降, 甚至可能连模拟信号的频率信息都会丢失 (想象频率为 1Hz 的正弦波以 1Hz 均匀采样, 每次采样的结果都是相同的振幅).
对于采样率的下限, 在数字信号处理领域有著名的 Nyquist-Shannon sampling theorem (奈奎斯特-香农采样定理), 表明采样率应不小于波形最高频率的 2 倍, 才可能保证波形的各个分量的频率信息不丢失. 而根据傅里叶变换, 任何函数都可以分解为不同频率的正余弦函数之和. 因此, 在仅考虑幅度超过一定阈值的频率分量的情况下 (这是考虑到傅里叶变换的结果可能包含无穷多个频率分量, 但一般实际情况高频分量的幅度都很小; 限定幅度阈值后可以认为波形的正余弦分量有最高频率), 任何波形都可以应用这一定理, 从而推得采样率的下限.
实际上, 在很多场合下, 采样率仅仅超过最高频率的 2 倍是远远不够的. 在工程相关领域, 这个倍数可以达到 6 倍左右. 一般我们接触到的音频文件的采样率多在 44,100Hz, 少数能达到 48,000Hz 或更高. 人耳能听到的振动频率范围在 20Hz~20,000Hz 左右, 显然 44,100Hz 已经超过 Nyquist-Shannon sampling theorem 所要求的采样率下限. 一般音乐的波形频率不会达到如此高的频率. 以 MIDI 能表示的音高为例, 音符事件的音调最大值 127 (对应音高为 G9) 对应的频率约为 12544Hz, 接近于采样率的四分之一.
对于 PCM 而言, 最重要的两个参数即是上述的位深和采样率.
WAV 文件格式的结构
接下来我们将正式开始讨论 WAV 文件格式是如何构建的. 前文中提到 WAV 格式遵循 RIFF 标准, 因此 WAV 格式本身即是 RIFF 标准中的一个 “RIFF” chunk, 并具有规定的文件头. 除去文件头剩余的文件内容被分为一些 sub-chunk, 例如规定音频格式具体信息的 “fmt “ (注意这里的空格, 很快将知道为什么) sub-chunk 和定义音频波形具体数据的 “data” sub-chunk, 还可能包含 “LIST” 元信息 sub-chunk. 按照 RIFF 标准, “RIFF” 和 “LIST” chunk 的剩余文件内容 (或者也可以理解为这些 chunks 的数据部分) 是其他 sub-chunks, 而 “fmt “ 和 “data” 此类普通的 sub-chunks 的数据部分就是普通的二进制数据流.
更详细地, 按照 RIFF 标准, 所有的数据都是以 chunk 的形式存储. 这些 chunk 都具有如下的结构:
长度 (字节数) | 含义 |
---|---|
4 | chunk 的 ASCII 标识符 (“RIFF”, “LIST” 等) |
4 | chunk 的 “数据部分” 的长度 |
剩余 | chunk 的 “数据部分” |
其中对于 “RIFF” 和 “LIST” 两种特殊的 chunks, 其 “数据部分” 又可以拆分为:
长度 (字节数) | 含义 |
---|---|
4 | chunk “数据部分” 的 ASCII 标识符 (例如 WAV 格式的 “WAVE”, 元信息的 “INFO”) |
剩余 | sub-chunk 1, sub-chunk 2, … |
显然这里的 ASCII 标识符的长度必须为 4 个字节, 因此 “fmt” 后添加了一个空格以填充空隙. 类似的有 AVI 格式的 “RIFF” chunk, 其 “数据部分” 的 ASCII 标识符为 “AVI “, 同样包含一个末尾的空格.
值得注意的是, 这里涉及到各个 chunk 的不同域 (field), 只有 ASCII 标识符是大端序 (big-endian) 的, 其他的都是小端序 (little-endian). 在后续的例子中我们可以看出这二者的区别.
以及, 对于某些情况, 整个 chunk 如果长度不为偶数, 通常会加入一个 padding space (以 \x00
填充). 在本文所述的场景中, \x00
被填充在整个 chunk 的末尾, 并不将其计入整个 chunk 的长度. 这也会在后续的例子中说明.
接下来将详细讨论不同的 sub-chunks. 为方便起见, 对于它们我们仍然称为 “chunks”.
“fmt “ chunk
除去该 chunk 的前 8 个固定字节不谈, 我们仅讨论其 “数据部分” 的结构:
长度 (字节数) | 域名 (field name) | 含义 |
---|---|---|
2 | AudioFormat | 音频格式, 表征普通的 PCM 或压缩处理 |
2 | NumChannels | 频道 (声道) 数 |
4 | SampleRate | 采样率 (单位 Hz) |
4 | ByteRate | 每秒钟所占字节数 |
2 | BlockAlign | 每个样本在所有频道所占字节数之和 |
2 | BitsPerSample | 位深 |
2 | ExtraParamSize | (可选) 额外参数长度 |
剩余 | ExtraParams | 额外参数 |
对于部分 fields 有如下的说明 (以下数字取值均用十进制表示):
- AudioFormat
- PCM 未压缩: 1
- 其他压缩形式: 其他值
- NumChannels
- 单声道: 1
- 双声道: 2
- 7.1 声道: 8
- ByteRate: 计算式为
SampleRate * NumChannels * BitsPerSample / 8
- BlockAlign: 计算式为
NumChannels * BitsPerSample / 8
“LIST” chunk (可选)
上文提及该 chunk 是用来记录 WAV 格式的元信息, 诸如歌曲名, 艺术家, 专辑等等. 这些信息存储在下属的 sub-chunks 内, 且原则上都是可选的. 需要注意在必要的时候为 chunk 的末尾补 padding space. 以下列举可能出现的 sub-chunk 的标识符及其含义.
标识符 | 含义 |
---|---|
INAM | 音频 (track) 标题 |
IPRD | 专辑标题 |
IART | 创作者 |
ICRD | 创作日期 (格式为 “YYYY-MM-DD”, 但通常只会保留年份 “YYYY”) |
ITRK | 音频在专辑中的序号 |
ICMT | 注释 (comment) 文本 |
IKEY | 工程或文件的关键词 |
ISFT | 创建该音频的软件 |
IENG | 参与创建该音频的工程师 (engineer). 若有多人则用 ; 和半角空格隔开. |
IGNR | 流派 |
ICOP | 版权信息 |
ISBJ | 主题 (subject) |
ISRC | 提供音频原始主题的人或组织 |
“data” chunk
这个 chunk 最终依照时间顺序存储各个样本 (sample) 的 PCM 量化数据. 直接考虑其 “数据部分” - 这是一个 (通常很长的) 连续二进制流. 这个二进制流由诸多 samples 组成, 每个 sample 顺序存储不同声道的数据 (且每个 sample 需要结束在偶数字节位置, 不过对于 16-bit 位深的音频通常一定是满足这个条件的), 而每个声道在该样本下的数据为一个固定比特位数的整数 (这个位数由位深决定). 更具体的解释见后文的示例文件.
WAV 文件示例
以下截取ハルカトミユキ的音乐作品《17才》的一个 WAV 格式音频文件的前 0xead0 (十进制 60,112) 行部分内容, 并以 hexdump
命令的形式呈现.
1 | 00000000 52 49 46 46 4e 68 e1 02 57 41 56 45 4c 49 53 54 |RIFFNh..WAVELIST| |
简单说明上述的数据呈现形式: 最左侧的 8 位十六进制数表示该行首个字节在文件中的位置 (offset); 每行中间排列 16 个 2 位十六进制数 (8 位二进制数, 1 个字节); 最右侧是各个对应数据的 ASCII 形式; 大量相同行的数据将会以 *
省略.
后面我们将要涉及到前文暂按下不表的 “小端序”. 简单来说, 小端序指的是将一个超过 8 位的整数按照字节切分后逆向排列各个部分存储在计算机中的方式. 举例来说, 若一个 32 位整数在计算机中存储为 “01 02 03 04” (忽略方便阅读的空格, 后同), 则该整数的实际值应为 0x04030201. 与之相对, 大端序即是将其顺向排列, 也就是 0x04030201 存储为 “04 03 02 01”.
接下来, 我们将一一剖析其中的各种信息.
“RIFF” chunk 头
这可谓是整个小姐最容易分析的部分: 考虑位置 0x0 ~ +8 (表示从 0x0 位置到 0x0+8=0x8 位置, 包含左端点但不包含右端点, 后同), 前 4 个字节是该 chunk 的标识符 “RIFF”, 后 4 个字节表征该文件去除该 chunk 文件头后的字节数. 4e 68 e1 02
应该理解为 0x02e1684e, 也即 48326734 (约 46 MiB, 与文件系统显示的大小相符). 位置 0x8 ~ +4 为 “RIFF” chunk 的 “数据部分” 的 ASCII 标识符 “WAVE”.
“LIST” chunk 的结构
位置 0x12 ~ +12 类似上一小节. 略去.
位置 0x24 ~ +24 为 “IART” chunk. “IART” 的 ASCII 标识符略去. 注意到位置 0x28 ~ +4 标示的 chunk “数据部分” 大小为 0x0000000f 也即 15. 位置 0x20 ~ +15 的字节流使用 GB18030 编码解码后恰好得到的是 “ハルカトミユキ” (不包含引号, 后同) 附加一个空字符 \x00
- 这应该是 CD 在 Windows 平台抓取时平台默认在元信息后增加空白字符所致, 不影响分析; 后续出现类似情况不再赘述. 位置 0x2f 则是 padding space.
后续 “IPRD”, “IGNR”, “ITOC”, “ITRK” chunks 的分析类似. 另外可以看到 “IPRD” chunk 后即没有 padding space, 符合前文的分析.
“fmt “ chunk 的结构
相较于其他的 chunks, 这个 chunk 因为连续排布了很多信息, 很容易看错. 这个 chunk 的位置为 0xa4 ~ +26. 略去与前述小节类似的 0xa4 ~ +8 的部分, 直接考虑后续的 18 个字节. 以下以表格形式呈现不同位置的数据及其表达的信息.
位置 | 域名 (field name) | 原始数据 | 实际十进制数 |
---|---|---|---|
0xac ~ +2 | AudioFormat | 01 00 |
1 |
0xae ~ +2 | NumChannels | 02 00 |
2 |
0xb0 ~ +4 | SampleRate | 44 ac 00 00 |
44100 |
0xb4 ~ +4 | ByteRate | 10 b1 02 00 |
176400 |
0xb8 ~ +2 | BlockAlign | 04 00 |
4 |
0xba ~ +2 | BitsPerSample | 10 00 |
16 |
0xbc ~ +2 | ExtraParamSize | 00 00 |
0 |
“data” chunk 的结构
从位置 0xbe 开始直至文件末尾都是 “data” chunk. 前 4 个字节同样略去不解释. 这里可以看到位置 0xd0 ~ 0xead0 几乎都全是空字节 \x00
, 表示没有音频输出. 0xead0 - 0xd0 为十进制 59,904, 在采样率 44,100Hz 前提下大约为持续 1.4 秒, 符合音乐录音开头空白的通常时长.
因为原音频文件中很难找到各个 sample 之间差异较大的位置, 我将以虚构的一段 “data” chunk 的 “数据部分” 作解释 (假设位深和声道数不变):
1 | 01 02 03 04 05 06 07 08 |
上述 8 个字节总共包含 4 个 samples - 01 02
, 03 04
, 05 06
, 07 08
. 每个 sample 包含 2 个声道的数据, 以第 1 个 sample 举例则是 01
和 02
. 其中第 1 个声道的数据为 01
, 表示其 PCM 量化值为十进制的 1.
代码实现
待补充.
参考文献
本文大量引用如下网络资源:
【文件格式探究】EP.2 WAV 音频文件格式
1.Codeforces 1324B: Yet Another Palindrome Problem
2.Codeforces 363B: Fence & Rust for Competitive Programming
3.Codeforces 1327A: Sum of Odd Integers
4.LeetCode Problem 3: Longest Substring Without Repeating Characters
5.Codeforces 1399D: Binary String to Subsequences
6.Codeforces 1368B: Codeforces Subsequences
7.Codeforces 1430C: Numbers on Whiteboard
8.Codeforces 1419D1: Sage's Birthday (easy version)
1.【ACG音乐分享】Ceui《今、歩き出す君へ》
2.使用 GPG 加密、解密和验证信息
3.【翻译】如何编写 Git 提交消息
4.Linux 时间操作及其同步
5.【实测】Python 和 C++ 下字符串查找的速度对比
6.Codeforces 1312B: Bogosort