Bootstrap

WAVE音频文件格式及其64位扩展格式的简要介绍

正文

关于 WAVE 文件格式,网上有不少介绍,但关于WAVE 64位扩展格式的介绍却是几乎没有。

所以本文的目的是简要介绍标准的 WAVE 格式,以及两种主要的扩展格式。

文中所有代码都用C语言来描述,尽管C语言有些不那么方便,但十分通用;虽然本人更擅长Pascal语言,而且描述本文代码可能更方便一些,但是现在用Pascal的真的不多了。

如果你想要查看最完整,最详细的标准文档,我会在文章最后提供网盘链接。(想要深入了解的强烈建议看看文档)

同时由于本人水平有限,难免会出现纰漏,如有问题敬请指出。

本文的目的不仅仅是为了介绍这些内容,更是希望能引导读者造出个轮子来(至少对于WAVE这种基础内容来说)。

开始之前

我们先定义一下需要用到的数据类型,便于后面内容的讲解。

typedef char Int8;
typedef short Int16;
typedef long Int32;
typedef long long Int64;
typedef unsigned char UInt8;
typedef unsigned short UInt16;
typedef unsigned long UInt32;
typedef unsigned long long UInt64;
typedef UInt8 Byte;
typedef UInt16 Word;
typedef UInt32 DWord;
typedef UInt64 QWord;

typedef struct
{
    DWord D1;
    Word D2;
    Word D3;
    Byte D4[8];
} Guid;

typedef union
{
    DWord dw;
    char chr[4];
} FourCC;

上述整数类型见名知意,同时便于使用,其中Byte~QWord是汇编语言内存访问常见的用法。


GUID(或者叫UUID)是一个16字节的结构体,通过特定的算法来确保其二进制是独一无二的(对于Windows来说,ole32.dll有个CoCreateGuid就实现了这样的功能),如果翻翻Windows注册表会发现里面有大量其字符串形式(下面会说)的键,当然了解COM编程的话对此也非常熟悉,总之是Windows系统的一个重要组成部分。

这里先简单介绍一下其字符串形式与二进制的对应关系。

我们给D1~D4赋以下值:

D1=0x12345678;
D2=0x9ABC;
D3=0xDEF0;
D4={'A','B','C','D','E','F','G','H'};

那么其字符串形式就是这样的,就是用16进制按字节表示:

{12345678-9ABC-DEF0-4142-434445464748}

注意一下这里是小端的情况,一般我们用的x86架构处理器(主要是IntelAMD两家),都使用小端存储。

如果是大端的话对应的情况就应该是这样的:

D1=0x78563412;
D2=0xBC9A;
D3=0xF0DE;
D4={'A','B','C','D','E','F','G','H'};

对于GUID字符串形式和二进制形式的一些问题,后面会继续讨论。

注:前面Guid是为了区分windows.h中GUID的定义。


FourCC(Four char code)类型是用来标记区块的,实际使用中可以用DWord或者char[4]代替(char类型必须是单字节的)。

这个类型的主要用处是用4个字符来表示一个区块的类型,比如后面会讲到的"RIFF"等区块。

至于为什么用一个DWord和char[4]组成union,那是为了方便读写使用的,如果用C++的话有运算符重载其实问题不大,如果对于C语言的话就比较麻烦了,所以除了C语言这个DWord类型是不建议使用的。

注意如果用DWord来读写的话,要注意大小端的问题,比如在小端下,'FFIR'才与"RIFF"是等效的,而大端情况下二者是一致的,因为这个问题,所以才不建议使用DWord类型。

然而使用类似于'FFIR'这样的类型当作整数使用的话,编译器是会给警告的,如果要消除这个问题的话,倒是可以用宏定义来解决:

// 大端把后面的a,b,c,d反过来就行了
#define MAKE_DWORD(a,b,c,d) (DWord)(((a&0xff))|((b&0xff)<<8)|((c&0xff)<<16)|((d&0xff)<<24))
#define RIFF_CHUNK_DWID MAKE_DWORD('R','I','F','F')
// ...

但是实际上不如"RIFF"这样干脆、直接,所以如果用C语言不嫌麻烦的话,可以用char[4]strncmp函数(或者写个宏来判断),其他语言的话直接运算符重载更方便。

注:windows.h中有宏MAKEFOURCC与这里的MAKE_DWORD效果是一样的。


好了不啰嗦了,具体的细节相信大家总会有解决办法的,我们开始主题吧。

RIFF/WAVE 标准格式

关于标准格式的介绍以及相关文档的获取,可以点击这个链接

WAVE格式属于RIFF格式,该格式的文件结构基于区块,具体可以去网上查询或者查看文档。

简要介绍一下区块头:

typedef struct
{
    FourCC id; // 区块类型
    DWord size; // 区块大小(不包括id和size字段的大小)
} RIFFChunkHeader;
  • size字段可以是奇数,但实际区块大小必须是偶数,也就是说按2字节对齐,所以要在最后补0

一般每一个区块都可以用如下的方式定义:

typedef struct
{
    RIFFChunkHeader header;
    // ...
} SomeChunkName;

但是为方便读写起见后文并没有按照这样的格式来定义。


RIFF文件头的定义如下:

typedef struct
{
    FourCC id; // 必须是 "RIFF"
    DWord size; // 文件大小(字节数)-8
    FourCC type; // 必须是 "WAVE"
} RIFFHeader;

明白区块头的意思之后,size字段就不必过多解释了;而type字段原因和id字段相同。


紧随其后的,便是非常重要的fmt块了。

typedef struct
{
    FourCC id; // 必须是 "fmt " (注意后面的空格哦)
    DWord size; // 必须是 16
    Word FormatTag;
    Word Channels;
    DWord SampleRate;
    DWord BytesRate;
    Word BlockAlign;
    Word BitsPerSample;
} WaveChunkFormat;

简单说一下

  • FormatTag一般是1或3,其中1代表PCM,3代表IEEE浮点数
  • Channels是通道数,一般为1或2,分别代表单声道和立体声
  • SampleRate是采样率,一般采用8000,44100,48000等
  • BytesRate是每秒播放的字节数,等于BlockAlign*SampleRate
  • BlockAlign是每一个音频帧的字节数,等于BitsPerSample*Channels/8
  • BitsPerSample是每一个采样的位数,一般为8,16,24,32,64

由于声音是交替存储1的,所以每一个音频帧的大小是每一个采样的大小乘上声道数


对于扩展的Format块,一般有下面两个

首先是

typedef struct
{
    FourCC id; // 必须是 "fmt "
    DWord size; // 一般是18
    Word FormatTag;
    Word Channels;
    DWord SampleRate;
    DWord BytesRate;
    Word BlockAlign;
    Word BitsPerSample;
    Word ExSize; // 一般是0
} WaveChunkNonPCMFormat;
  • size字段一般为18,这取决于ExSize的大小,实际大小等于ExSize+18
  • ExSize字段一般为0,如果不为0则必须在后面添加自己的相应结构

这个结构通常用于非PCM编码的格式(往往是压缩的格式),但是现在一般不常见,具体用什么格式由FormatTag字段指定。说实话这种格式的文件我见过一些,但大多其FormatTag的值是3,也就是表示采样格式IEEE浮点数2。虽然IEEE浮点数不算PCM3,但由于效果与之类似4,所以我们如果需要使用IEEE格式的采样,也会使用标准的WaveChunkFormat,而不是这个尴尬的WaveChunkNonPCMFormat,除非你有自己定义的扩展格式。

对于这种格式的文件来说,其一般有一个fact区块(对IEEE浮点来说也可以没有):

typedef struct
{
    FourCC id; // "fact"
    DWord size; // 12
    DWord FactSize; // 每个通道的采样总数
} WaveChunkFact;

该区块的主要目的是提供采样总数(即音频帧总数),估算压缩格式解压后的实际大小,非压缩格式用不到。


然后是

typedef struct
{
    FourCC id; // 必须是 "fmt "
    DWord size; // 必须是40
    Word FormatTag; // 必须是0xFFFE
    Word Channels;
    DWord SampleRate;
    DWord BytesRate;
    Word BlockAlign;
    Word BitsPerSample;
    Word ExSize; // 必须是22
    Word ValidBitsPerSample;
    DWord ChannelMask;
    Guid SubFormat;
} WaveChunkFormatExtensible;

这个是微软用的最多的格式,如果你知道WASAPI的话,你会发现Windows混音器内部用的就是这个格式。

  • VaildBitsPerSample指的是实际采样位数,比如BitsPerSample为24,那么该字段就可以是17-24,意思是使用24位中的部分或全部比特位;常见的搭配是12/16,20/24,但是一般都会等于BitsPerSample,因为这个实在是太罕见了
  • ChannelMask指的是多声道扬声器排列方式,比如5.1声道,7.1声道,具体定义详见文档
  • SubFormat是一个16位的Guid,由于FormatTag字段必须为0xFFFE,所以需要在后面重新定义,其前2位(即一个Word)代表原来的FormatTag,后面几个位是固定的,但是为了方便,实际上第3-6字节都是0,例如{00000001-0000-0010-8000-00AA00389B71}代表PCM,{00000003-0000-0010-8000-00AA00389B71}代表IEEE浮点数

之后可以是各种其他块,但一般用不到,除非有特殊需求可以用类似作者信息块、播放列表块、乐器类型块、采样块等,然而这些内容是很少遇到的。

想要详细了解各种其他区块,可以去标准文档里找一找。


然后就是真正存放数据的数据块了(也是我们读取前置信息后的主要目的)。

数据块的定义很简单:

typedef struct
{
    FourCC id; // 必须是 "data"
    DWord size; // 实际数据大小
    // 后面就是以音频帧为单位存放的数据了
} WaveChunkData;

到这里,一个最简单——同时也是最重要的部分就完成了,根据这些就完全可以创造或者读取一个标准WAVE格式的文件了。

扩展的 WAVE 格式

如果看到WAVE格式的文件,一般肯定会想到是无损音频文件,然而实际上,WAVE格式是一个容器,也可以存储其他压缩格式的数据,比如ADPCMALawMuLaw等,但是一般我们确实以无损PCM编码为主。

但是你也看到了,标准的WAVE格式文件头部使用的size字段是一个DWord类型的,其最大仅能表示4GiB大小的数据,而对于多声道无损存储的需求来说,这么点大小只够保存没几十分钟的数据,那也太少了,所以我们需要对标准WAVE格式进行扩展。

那怎么扩展呢?微软并没有给出答案,而对于这方面有需求的广播电视行业和唱片行业制定了自己的标准。

于是就有了下面的两大主角。

不过在介绍它们之前,我们先补充一点前置知识。


关于JUNK(垃圾)块的那些事。

JUNK块是RIFF标准的一部分,适用于所有使用RIFF的文件格式,而不是WAVE特有的。

那么首先回答一个问题,为什么要JUNK块?

由于WAVE文件的数据是连续存放在数据块的,所以一旦开始写入,那么数据块在文件中的偏移量就是固定不变的了,那么如果我们要在数据块之前添加其他区块怎么办呢?那么就用一个垃圾区块占位,这个区块没有任何数据,读取时会直接跳过,但是我们可以在文件写完之后回过头来改写这个区块,将其中的部分内容改为其他区块,并缩小这个JUNK块的大小。当然还有一个原因是填充垃圾实现文件对齐,我见过有的WAVE文件的数据块的偏移量是4088字节,而实际音频数据的偏移量就是4096字节(刚好4K),这样可能有利于文件的顺序读取。

综上可知垃圾块的定义应该非常简单:

typedef struct
{
    FourCC id; // "JUNK"
    DWord size;
    // 垃圾数据
} RIFFChunkJunk;
  • id也用用小写"junk"的,但是不多见
  • 一般填充0,当然也可以填充垃圾进去(写入一段随机的内存)

对于扩展格式,为什么需要用到JUNK块呢?后面我会给出相应的解答。但是现在我们不妨先看看这两种格式具体长什么样子吧。

RF64/WAVE 扩展格式

顾名思义,RF64是对RIFF格式的64位扩充,但它是只针对其中WAVE格式来说的。

RF64使用和RIFF相同的文件头,区别之一是id字段从"RIFF"变成了"RF64",区别之二是size字段要填充为0xFFFFFFFF(其实无所谓)。

与RIFF略有不同的是,RF64要求紧跟文件头的必须是ds64块,其定义如下:

typedef struct
{
    FourCC id; // 必须是 "ds64"
    DWord size; // 一般是28
    UInt64 RIFFSize; // 实际的RIFF大小(即文件大小-8)
    UInt64 DataSize; // 实际的数据块大小
    UInt64 FactSize; // 实际的数据量(解压后)
    DWord TableLen; // 一般为0
    // 后面紧跟 RIFFChunkHeader64 数组
} RIFFChunkDS64;
  • size一般是28,如果TableLen不为0则需要加上12*TableLen
  • RIFFSize用于代表实际的RIFF头size字段
  • DataSize用于代表实际的数据块size字段
  • FactSize用于压缩格式的大小,一般不压缩,等于DataSize即可
  • TableLen一般为0,因为很少有需要64位大小的字段;如果不为0,则size大小需要重新计算且后面需要跟一个RIFFChunkHeader64数组

其中RIFFChunkHeader64定义如下:

typedef struct
{
    FourCC id;
    UInt64 size;
} RIFFChunkHeader64;

然后就是fmt块和data块了,这一点和RIFF是一样的(除了data块的size设为0xFFFFFFFF),不过一般fmt块用的是WaveChunkFormatExtensible,这是因为广播电视行业需要支持环绕声,但是使用另外两个也是完全可以的。

由此可见RF64对RIFF的改变还是挺少的,所以RF64对原有的格式兼容性还是非常好的,许多程序不必添加过多的代码就可以轻松支持这种格式了。

Sony Wave64 扩展格式

对于扩展WAVE格式,Sonic Foundry给出了他们的方案,后来这家公司被索尼收购,于是他们的标准也就成为了Sony Wave64

Sony Wave64改动不小,首先它就把区块头改掉了。

typedef struct
{
    Guid id;
    Int64 size;
} SonyWave64Header;
  • id改成了16字节的Guid,具体的值文档中给出了定义
  • size改成了8字节的有符号整数,而且其值包括了idsize本身的大小,这与RIFF和RF64非常不同

文件头的定义也就改成了这样:

typedef struct
{
    Guid id;    // 必须是 {66666972-912E-11CF-A5D6-28DB04C10000}
    Int64 size; // 等于文件大小
    Guid type;  // 必须是 {65766177-ACF3-11D3-8CD1-00C04F8EDB8A}
} SonyWave64Wave;

如果仔细观察的话,你会发现它们的前4字节刚好就是4个字符"riff"和"wave"

不过其对于WAVE各种块的具体定义并没有做什么修改,只是把每个块的区块头改成了SonyWave64Header而已。

当然每个区块头的定义还是有所不同的,主要是后12字节,下面列出两个主要的块:

  • 'fmt '块: {20746D66-ACF3-11D3-8CD1-00C04F8EDB8A}
  • 'data’块: {61746164-ACF3-11D3-8CD1-00C04F8EDB8A}

同时它还对文件结构做出了严格的要求——所有块必须按8字节对齐,而不是原来的2字节对齐。

说回JUNK

对于WAVE格式的扩展,如果说RF64只是小修小补,那Sony Wave64就是大大阔斧的改变了。

但是万变不离其宗,这两种扩展还是基于原来的区块机制的,所以我们完全可以用一个JUNK块来提前占位,这样就可以实现WAVE格式对RF64或Sony Wave64的动态扩充了。

当然JUNK块也可以用来给数据起始位置进行4K对齐,不过这不是我们主要探讨的。

动态扩展的具体实现方式是提前计算好JUNK块的大小并填充进去,然后写入数据,等到要结束的时候检测写入量的大小:如果写入量小于4GiB,那么我们就可以直接收尾,不管这个垃圾块了;否则我们就利用垃圾块占位的空间把文件头部的信息改写成符合该格式的信息就行了。

一般把WAVE扩充成RF64需要的基本JUNK大小是28字节;而扩充到Sony Wave64需要的基本JUNK大小则是52字节,并且每多一个区块就要多16字节。

当然具体的代码实现还是非常复杂的,如果要写出来那得花上不少时间,比如一款叫Reaper5的软件就实现了这个功能。

再说GUID

由于C语言的问题,关于GUID字符串形式与二进制形式的转化问题比较麻烦,这对于其他高级语言来说不是问题,所以如果用C语言,就不能用字符串了。

比如Pascal(包括Free Pascal和Delphi)就可以用这样的形式来定义一个确定的GUID常量:

const
  WavSubFmtPCM:TGUID       = '{00000001-0000-0010-8000-00AA00389B71}';
  WavSubFmtIEEEFloat:TGUID = '{00000003-0000-0010-8000-00AA00389B71}';

我相信现在的主流语言基本都支持类似的方法。

比如微软C++就有uuid可以用。

对于C语言,如果需要使用,可以配合下面的宏定义来使用

#define MAKE_GUID(uid,dw1,w1,w2,b1,b2,b3,b4,b5,b6,b7,b8) \
    const Guid uid = {dw1,w1,w2,{b1,b2,b3,b4,b5,b6,b7,b8}};
#define MAKE_WAVE_SUBFORMAT(uid,d) \
    MAKE_GUID(uid,d,0,0x10,0x80,0,0,0xAA,0x00,0x38,0x9B,0x71)
MAKE_WAVE_SUBFORMAT(WAV_SUB_FMT_IEEE, 3)
MAKE_WAVE_SUBFORMAT(WAV_SUB_FMT_FLAC, 0xF1AC)
MAKE_GUID(SONY_WAVE64_RIFF,0x66666972,0x912e,0x11cf,0xa5,0xd6,0x28,0xdb,0x04,0xc1,0,0)
// ...

后记

其实写完AIFF的文章之后就想写这个WAV的了,但是一直没写(因为工作量远大于AIFF的),甚至后来写了一半又停了,好不容易现在终于写完了(前后拖了一个月)。

以后有机会再写一些怎么读取WAV并用DirectSound或者WASAPI播放的内容,尤其是WASAPI,这方面的资料还是比较少的。

还有机会的话再来一些简单的声音处理的内容。

网盘

https://lanzoui.com/b011tcx9g 密码:fz7c

如果链接打不开,可以把lanzoui改成lanzoux或其他,具体可以去搜关键字蓝奏云打不开

里面有多个文件,按需下载即可,如果全部需要,可以下载waveformat.zip文件,其打包了所有文件。


  1. 关于声音的具体存放方式,网上有不少资料,这里不再赘述 ↩︎

  2. 使用IEEE浮点数的目的是为了获得更大的动态范围,也可以避免失真。 ↩︎

  3. PCM全称脉冲编码调制,是用整数来量化采样的模拟信号,而浮点数不是。 ↩︎

  4. 都是未经压缩的编码方式。 ↩︎

  5. 这是一款非常极客的音频软件,官网为 https://www.reaper.fm/index.php ↩︎

;