WAV 是最常见的声音文件格式之一,是微软公司专门为 Windows 开发的一种标准数字音频文件,该文件能记录各种单声道或立体声的声音信息,并能保证声音不失真。但 WAV 文件有一个致命的缺点,就是它所占用的磁盘空间太大。它符合资源互换文件格式(RIFF)规范,用于保存 Windows 平台的音频信息资源,被 Windows 平台及其应用程序所广泛支持。Wave 格式支持 MSADPCM、CCITT A 律、CCITT μ 律和其他压缩算法,支持多种音频位数、采样频率和声道,是 PC 机上最为流行的声音文件格式;但其文件尺寸较大,多用于存储简短的声音片段。
一、RIFF 规范
资源交换档案标准(Resource Interchange File Format) (RIFF) 是一种把资料储存在被标记的区块(tagged chunks)中的档案格式(meta-format)。 它是在 1991 年时,由 Microsoft 和 IBM 提出。它是 Electronic Arts 1985 提出的 Interchange File Format 的翻版。这两种标准的唯一不同处是在多位元整数的储存方式。 RIFF 使用的是小端序,这是 IBM PC 使用的处理器 80x86 所使用的格式,而 IFF 储存整数的方式是使用大端序,这是 Amiga 和 Apple Macintosh 电脑使用的处理器可处理的整数型态。
RIFF 文件所包含的数据类型由该文件的扩展名来标识,能以 RIFF 格式存储的数据有:
• 音频视频交错格式数据 .AVI
• 波形格式数据 .WAV
• 位图数据格式 .RDI
• MIDI格式数据 .RMI
• 调色板格式 .PAL
• 多媒体电影 .RMN
• 动画光标 .ANI
• 其他的RIFF文件 .BND
RIFF 文件由不同数量的 chunk 组成,下面来学习其定义。
1.1 chunk
chunk(区块)是 RIFF 文件的基本单元,RIFF 文件由不同数量的 chunk 组成,每个 chunk 由“标识符”、“数据大小”和“数据”三个部分组成,“标识符”和“数据大小”都是占用 4 个字节空间,chunk 的基本结构如下所示:
struct chunk
{
u32 ID; //标识符
u32 Size; //数据大小
u8 Data[Size]; //数据
};
ID:标识符由 4 个 ASCII 字符组成,用以识别块中所包含的数据。如:‘RIFF’,‘LIST’,'fmt '(最后一个字符为空格),‘data’,'WAV ','AVI’等,这种文件结构最初是由 Microsoft 和 IBM 为 PC 机所定义,所以 RIFF 文件是按照小端字节顺序写入的。
Size:块数据大小,存储在 Data 域中的数据长度,不包含 ID 和 Size 的大小。
Data:块数据,数据以字为单位存放,如果数据长度为奇数(字节为单位),则最后添加一个空字节。
chunk 是可以嵌套的,但是只有块标识符为’RIFF’或者’LIST’的 chunk 才能包含其他的 chunk。
1.2 FourCC
FourCC(Four Character Codes)是一个 4 字节 32 位的标识符,通常用来标识文件的数据格式。例如,在音视频播放器中,可以通过文件的 FourCC 来决定调用哪种 CODEC 进行音视频的解码。例如:DIV3、DIV4、DIVX 和 H264 等,对于音频则有:WAV、MP3 等。FourCC 是 4 个 ASCII 字符,不足四个字符的则在最后补充空格(不是空字符)。比如,FourCC fmt,实际上是’f’ ‘m’ ‘t’ ’ '。
1.3 RIFF chunk 和 LIST chunk
块标识符 ID 为’RIFF’的 chunk 是比较特殊的,每一个 RIFF 文件首先存放的必须是一个 RIFF chunk,并且只能有一个标识符为’RIFF’的 chunk。RIFF chunk 的数据域的起始位置是一个 4 字节的 FormType(FourCC 格式),用于标识 RIFF chunk 数据域中所包含的 chunk 的数据类型。紧接着 FormType 之后的数据域的内容则是 RIFF chunk 所包含的 subchunk。一个简单的 RIFF chunk 的示意图如下所示:
上图中的 RIFF chunk 包含有两个 subchunk,可以看出 RIFF chunk 的数据域首先是 4 字节的 FormType,接着是两个 subchunk,每一个 subchunk 又包含有自己的标识符、数据域的大小以及数据域。
除了 RIFF chunk 可以嵌套其他的 chunk 外,另一个可以包含 subchunk 的就是 LIST chunk,其示意图如下所示:
上图中,首先 RIFF 文件必须的 RIFF chunk,其数据域又包含有两个 subchunk,其中一个 subchunk 的类型为’LIST’,该 LIST chunk 又包含了两个 subchunk。
RIFF chunk 和 LIST chunk 的基本结构如下所示:
struct chunk
{
u32 ID; //标识符: 'RIFF'或者'LIST'
u32 Size; //数据大小
struct ChunkData { //数据
u32 Type; //包含的 subchunk 的数据类型, 与上面图中的 FormType 和 ListType 对应
u8 Data[Size-4]; //包含的 subchunk
};
};
备注:一个 RIFF 文件的总大小为:RIFF chunk 的 Size + 8,这里的 8 是 ID 和 Size 所占用的空间。
二、WAV 格式
WAV 文件是在 PC 机平台上很常见的、最经典的多媒体音频文件,最早于 1991 年 8 月出现在 Windows3.1 操作系统上,文件扩展名为 WAV,是 WaveForm 的简写,也称为波形文件,可直接存储声音波形,还原的波形曲线十分逼真。WAV 文件格式简称 WAV 格式是一种存储声音波形的数字音频格式,是由微软公司和 IBM 联合设计的,经过了多次修订,可用于 Windows、Macintosh、Linux 等多种操作系统。WAV 支持多种音频数字、取样频率和声道,标准格式化的 WAV 文件和 CD 格式一样,也是 44.1kHz 的取样频率,16 位量化数字,因此声音文件质量和 CD 相差无几。
WAV 的特点如下:真实记录自然声波形,基本无数据压缩,数据量大。一般来说,由 WAV 文件还原而成的声音的音质取决于声音卡采样样本的尺寸,采样频率越高,音质就越好,但开销就越大,WAV 文件也就越大。
最基本的 WAVE 文件是 PCM(脉冲编码调制)格式的,这种文件直接存储采样的声音数据没有经过任何的压缩,是声卡直接支持的数据格式,要让声卡正确播放其它被压缩的声音数据,就应该先把压缩的数据解压缩成 PCM 格式,然后再让声卡来播放。
声源发出的声波通过话筒被转换成连续变化的电信号,经过放大、抗混叠滤波后,按固定的频率进行采样,每个样本是在一个采样周期内检测到的电信号幅度值;接下来将其由模拟电信号量化为由二进制数表示的积分值;最后编码并存储为音频流数据。常见的声音文件主要有两种,分别对应于单声道(11.025 KHz 采样率、8 Bit 的采样值)和双声道(44.1 KHz 采样率、16 Bit 的采样值)。采样率是指:声音信号在“模→数”转换过程中单位时间内采样的次数。采样值是指每一次采样周期内声音模拟信号的积分值。
对于单声道声音文件,采样数据为 8 位的整数(char 0x00~0xFF);而对于双声道立体声声音文件,每次采样数据为一个 16 位的整数(short),高八位和低八位分别代表左右两个声道。在单声道 WAVE 文件中,声道 0 代表左声道,声道 1 代表右声道。在多声道 WAVE 文件中,样本是交替出现的。
2.1 PCM
脉冲编码调制(Pulse Code Modulation,PCM)就是把一个时间连续,取值连续的模拟信号变换成时间离散,取值离散的数字信号后在信道中传输。脉冲编码调制就是对模拟信号先抽样,再对样值幅度量化,编码的过程。
抽样,就是对模拟信号进行周期性扫描,把时间上连续的信号变成时间上离散的信号,抽样必须遵循奈奎斯特抽样定理。该模拟信号经过抽样后还应当包含原信号中所有信息,也就是说能无失真的恢复原模拟信号。它的抽样速率的下限是由抽样定理确定的。
量化,就是把经过抽样得到的瞬时值将其幅度离散,即用一组规定的电平,把瞬时抽样值用最接近的电平值来表示,通常是用二进制表示。
量化误差:量化后的信号和抽样信号的差值。量化误差在接收端表现为噪声,称为量化噪声。 量化级数越多误差越小,相应的二进制码位数越多,要求传输速率越高,频带越宽。
为使量化噪声尽可能小而所需码位数又不太多,通常采用非均匀量化的方法进行量化。 非均匀量化根据幅度的不同区间来确定量化间隔,幅度小的区间量化间隔取得小,幅度大的区间量化间隔取得大。
一个模拟信号经过抽样量化后,得到已量化的脉冲幅度调制信号,它仅为有限个数值。
编码,就是用一组二进制码组来表示每一个有固定电平的量化值。然而,实际上量化是在编码过程中同时完成的,故编码过程也称为模/数变换,可记作 A/D。
2.2 WAV
WAVE 文件虽然可以压缩,但一般都使用不压缩的格式。下面是 WAV 格式详细说明。
endian | field name | Size | description |
big | ChunkID | 4 | 文件头标识,一般就是" RIFF" 四个字母 |
little | ChunkSize | 4 | 整个数据文件的大小,不包括上面 ID 和 Size 本身 |
big | Format | 4 | 一般就是 “WAVE” 四个字母 |
big | SubChunk1ID | 4 | 格式说明块,本字段一般就是 "fmt " |
little | SubChunk1Size | 4 | 本数据块的大小,不包括 ID 和 Size 字段本身 |
little | AudioFormat | 2 | 存储音频文件的编码格式,例如若为PCM则其存储值为1,若为其他非PCM格式的则有一定的压缩。 |
little | NumChannels | 2 | 声道数,单通道(Mono)值为1,双通道(Stereo)值为2等等 |
little | SampleRate | 4 | 采样率 |
little | ByteRate | 4 | 比特率,每秒存储的bit数,其值=SampleRate * NumChannels * BitsPerSample/8 |
little | BlockAlign | 2 | 数据块对齐单元,值=NumChannels * BitsPerSample/8 |
little | BitsPerSample | 2 | 采样时模数转换的分辨率,每个采样点的bit数,一般为8、16和32等 |
big | SubChunk2ID | 4 | 真正的声音数据块,本字段一般是 “data” |
little | SubChunk2Size | 4 | 本数据块的大小,不包括 ID 和 Size 字段本身,其值=NumSamples * NumChannels * BitsPerSample/8 |
little | Data | N | 音频的采样数据 |
对于 Data 块,根据声道数和采样率的不同情况,布局如下:
PCM 数据类型 | 采样 | 采样 |
8 Bit 单声道 | 声道0 | 声道0 |
8 Bit 双声道 | 声道0 | 声道1 |
16 Bit 单声道 | 声道0低位,声道0高位 | 声道0低位,声道0高位 |
16 Bit 双声道 | 声道0低位,声道0高位 | 声道1低位,声道1高位 |
下面是一段给 PCM 加头变为 WAV 的 Kotlin 代码。
/**
* 文件名 PCMHandler
* 描 述 PCM 数据处理成 mkv、mp4 可直接打包的格式
* 作 者 lhw
* 时 间 2020/10/22 11:40
*/
class PCMHandler {
companion object {
public const val WAV_HEADER_SIZE = 44
private val RIFF = "RIFF".toByteArray(Charsets.US_ASCII)
private val WAVE = "WAVE".toByteArray(Charsets.US_ASCII)
private val FMT = "fmt ".toByteArray(Charsets.US_ASCII)
private val DATA = "data".toByteArray(Charsets.US_ASCII)
private val WAV_HEADER = ByteArray(WAV_HEADER_SIZE)
}
fun makeFreeScaleWAVHeader(pcmDataLen: Int): ByteArray {
return makeWAVHeader(pcmDataLen, 8000, 1, 16)
}
private fun makeWAVHeader(
pcmDataLen: Int,
sampleRate: Int,
numChannels: Short,
bitsPerSample: Short
): ByteArray {
// ChunkID 内容为"RIFF"
System.arraycopy(RIFF, 0, WAV_HEADER, 0, RIFF.size)
// ChunkSize 存储文件的字节数(不包含ChunkID和ChunkSize这8个字节)
fillSizeByPos(4, pcmDataLen + (WAV_HEADER_SIZE - 8))
// Format 内容为"WAVE"
System.arraycopy(WAVE, 0, WAV_HEADER, 8, WAVE.size)
// Subchunk1ID 内容为"fmt "
System.arraycopy(FMT, 0, WAV_HEADER, 12, FMT.size)
// Subchunk1Size 存储该子块的字节数(不含前面的Subchunk1ID和Subchunk1Size这8个字节)
fillSizeByPos(16, 16)
// AudioFormat 存储音频文件的编码格式,例如若为PCM则其存储值为1,若为其他非PCM格式的则有一定的压缩。
fillSizeByPos(20, 1.toShort())
// NumChannels 通道数,单通道(Mono)值为1,双通道(Stereo)值为2,等等
fillSizeByPos(22, numChannels)
// SampleRate 采样率,如8k,44.1k等
fillSizeByPos(24, sampleRate)
// ByteRate 每秒存储的bit数,其值=SampleRate * NumChannels * BitsPerSample/8
val byteRate = sampleRate * numChannels * bitsPerSample / 8
fillSizeByPos(28, byteRate)
// BlockAlign 块对齐大小,其值=NumChannels * BitsPerSample/8
val blockAlign = numChannels * bitsPerSample / 8
fillSizeByPos(32, blockAlign.toShort())
// BitsPerSample 每个采样点的bit数,一般为8,16,32等。
fillSizeByPos(34, bitsPerSample)
// Subchunk2ID 内容为“data”
System.arraycopy(DATA, 0, WAV_HEADER, 36, DATA.size)
// Subchunk2Size 内容为接下来的正式的数据部分的字节数,其值=NumSamples * NumChannels * BitsPerSample/8
fillSizeByPos(40, pcmDataLen)
return WAV_HEADER
}
private fun fillSizeByPos(startPos: Int, size: Int, data: ByteArray = WAV_HEADER) {
// 小端
data[startPos] = (size and 0xff).toByte()
data[startPos + 1] = (size shr 8 and 0xff).toByte()
data[startPos + 2] = (size shr 16 and 0xff).toByte()
data[startPos + 3] = (size shr 24 and 0xff).toByte()
}
private fun fillSizeByPos(startPos: Int, size: Short, data: ByteArray = WAV_HEADER) {
// 小端
data[startPos] = (size.toInt() and 0xff).toByte()
data[startPos + 1] = (size.toInt() shr 8 and 0xff).toByte()
}
}