PCM 编码原理详解
一、PCM 基本概念
PCM(Pulse Code Modulation,脉冲编码调制)是一种将模拟信号数字化的无损编码方式,包含三个核心步骤:
-
采样(Sampling)
- 按照固定时间间隔(奈奎斯特频率)测量模拟信号的瞬时值
- 例如:CD音频采样率为44.1kHz(人耳最高识别20kHz的2倍以上)
- 公式:采样频率 ≥ 2 × 信号最高频率
-
量化(Quantization)
- 将连续幅值离散化为有限个量化电平
- 量化精度(位深):常见16位(65,536级)、24位(16,777,216级)、32位(4,294,967,296级)
- 量化误差:实际值与量化值之差(引入"量化噪声")
-
编码(Coding)
- 将量化值转换为二进制序列
- 线性PCM:直接二进制映射
- 编码公式:数字值 = (模拟值/满量程) × (2ⁿ-1)
二、关键技术特征
特性 | 说明 |
---|---|
信号保真度 | 直接取决于采样率和量化位深 |
数据量 | 未压缩原始数据:位深×声道数×采样率 |
动态范围 | 每增加1比特提升约6dB信噪比 |
处理复杂度 | 编码/解码只需ADC/DAC转换,几乎无计算负载 |
Waveform 音频文件结构(WAV 格式)
PCM编码后的声音数据是需要保存的,WAVE文件常常用来保存PCM编码数据。WAVE文件是微软公司(Microsoft)开发的一种声音文件格式,用于保存Windows平台的音频信息资源,被Windows平台及其应用程序所广泛支持,WAVE文件默认打开工具是WINDOWS的媒体播放器。
1. RIFF文件格式标准
WAVE文件是以微软RIFF格式为标准的,RIFF全称为资源互换文件格式(Resources Interchange File Format),是Windows下大部分多媒体文件遵循的一种文件结构。RIFF文件所包含的数据类型由该文件的扩展名来标识,能以RIFF格式存储的数据有很多:音频视频交错格式数据(.AVI)、波形格式数据(.WAV)、位图数据格式(.RDI)、MIDI格式数据(.RMI)、调色板格式(.PAL)、多媒体电影(.RMN)、动画光标(.ANI)等。
如下代码所示的CK结构体是RIFF文件的基本单元,该基本单元也称 Chunk。其中ckID用于标识块中所包含的数据类型,其取值可有'RIFF'、'LIST'、'fmt '、'data'等;ckSize表示存储在ckData域中的数据长度(不包含ckID和ckSize的大小);ckData存储数据,数据以字节为单位存放,如果数据长度为奇数,则最后添加一个空字节。
/*
由于RIFF文件结构最初是由Microsoft和IBM为PC机所定义
RIFF文件是按照小端little-endian字节顺序写入的
*/
typedef unsigned long DWORD;
typedef unsigned char BYTE;
typedef DWORD FOURCC; // Four-character code
typedef struct {
FOURCC ckID; // The unique chunk identifier
DWORD ckSize; // The size of field <ckData>
BYTE ckData[ckSize]; // The actual data of the chunk
} CK;
2. WAVE文件结构
WAVE是Microsoft开发的一种音频文件格式,它符合上面提到的RIFF文件格式标准,可以看作是RIFF文件的一个具体实例。既然WAVE符合RIFF规范,其基本的组成单元也是Chunk。一个 WAVE文件 通常有三个Chunk以及一个可选Chunk,其在文件中的排列方式依次是:RIFF Chunk,Format Chunk,Fact Chunk(附加块,可选),Data Chunk,如下图所示:
WAV 文件基于 RIFF(Resource Interchange File Format) 结构,由多个区块(Chunks)组成,关键区块如下:
RIFF 头区块
- Chunk ID:固定为
"RIFF"
(4 字节)。 - Chunk Size:文件总大小减去 8 字节(4 字节,小端序)。
- Format:固定为
"WAVE"
(4 字节)。
fmt 子区块
- Subchunk ID:固定为
"fmt "
(4 字节)。 - Subchunk Size:一般为 16 字节(PCM 格式)(4 字节)。
- Audio Format:编码格式(2 字节),如 PCM 为 1。
- Num Channels:声道数(2 字节),如单声道为 1,立体声为 2。
- Sample Rate:采样率(Hz,4 字节),如 44100。
- Byte Rate:每秒字节数(4 字节),计算公式:
SampleRate × NumChannels × BitsPerSample / 8
- Block Align:每样本字节数(2 字节),计算公式:
NumChannels × BitsPerSample / 8
- Bits Per Sample:量化位数(2 字节),如 16。
data 子区块
- Subchunk ID:固定为
"data"
(4 字节)。 - Subchunk Size:音频数据大小(4 字节)。
- Data:连续的 PCM 样本数据,按声道交替存储(如立体声为 L-R-L-R…),小端序。
扩展说明
- 非 PCM 格式:WAV 文件可支持 ADPCM、IEEE 浮点数等,需修改
Audio Format
字段。 - 扩展区块:可能包含
"fact"
(压缩格式必需)或"LIST"
(元信息)等子区块。
示例代码
一、WAV文件头的定义
#pragma pack(push, 1) // 关键!确保编译器使用1字节对齐,无填充。
struct WAVHEADER
{
/********** RIFF Chunk **********/
char RiffName[4]; // <-> 'R' 'I' 'F' 'F'
uint32_t nRiffLength; // <-> Total File Size - 8
char WavName[4]; // <-> 'W' 'A' 'V' 'E'
/********** fmt Chunk **********/
char FmtName[4]; // <-> 'f' 'm' 't' ' ' (注意最后一个字节是空格' '的ASCII码0x20)
uint32_t nFmtLength; // <-> fmt块的数据部分长度 (应为16)
/****** fmt Chunk Data *******/
uint16_t nAudioFormat; // <-> 1 (PCM)
uint16_t nChannleNumber; // <-> 声道数 (1, 2...)
uint32_t nSampleRate; // <-> 采样率
uint32_t nBytesPerSecond; // <-> 字节速率
uint16_t nBytesPerSample; // <-> 采样帧字节数 (== BlockAlign)
uint16_t nBitsPerSample; // <-> 每个采样的位数
/********** data Chunk Header **********/
char DATANAME[4]; // <-> 'd' 'a' 't' 'a'
uint32_t nDataLength; // <-> 音频数据的长度
};
#pragma pack(pop) // 恢复默认对齐方式
- 成员顺序: 结构体成员的顺序严格对应了WAV文件格式要求的块(RIFF, fmt, data)的顺序以及各块内部字段的顺序。
- 字段意义: 每个结构体成员变量名清晰反映了其对应的WAV文件字段的含义,命名规范合理(如
nRiffLength
,nSampleRate
)。 - 字符数组 vs 字符串: 使用
char[4]
(如RiffName
,WavName
等)存储4字符代码(FourCC)是最合适的。const char*
在这里不合适,因为它存储的是指针而非实际数据。memcpy("RIFF", ...)
确保将4个有效字符复制进去,没有C风格字符串的终止符'\0'
,符合二进制格式要求。 - 整数类型选择:
uint32_t
,uint16_t
严格对应了WAV格式中相应字段的大小(4字节和2字节)。 -
#pragma pack(push, 1)
/pop
: 这是最关键的部分!- WAV文件格式要求字段紧密排列,中间不允许有字节填充(padding)。
- C/C++编译器为了提高内存访问效率,通常会对结构体成员进行内存对齐(比如在
uint16_t
后面插入2个字节的填充,使下一个uint32_t
对齐到4字节地址)。这会破坏与文件二进制布局的对应关系。 #pragma pack(1)
强制编译器按1字节对齐方式打包结构体,确保成员之间没有任何填充字节。push
/pop
确保只在定义这个结构体时改变对齐方式,避免影响其他代码。
- 最常见的WAVE Format的Tag值定义
二、WAV文件结构体的初始化
WAVHEADER wavHeader;
void set_wav_header()
{
memcpy(wavHeader.RiffName, "RIFF", 4);
memcpy(wavHeader.WavName, "WAVE", 4);
memcpy(wavHeader.FmtName, "fmt ", 4);
memcpy(wavHeader.DATANAME, "data", 4);
wavHeader.nFmtLength = 16;
wavHeader.nAudioFormat = 1;
wavHeader.nBitsPerSample = 32;
wavHeader.nChannleNumber = 2;
wavHeader.nSampleRate = mSetWaveSamplate;
wavHeader.nBytesPerSample = wavHeader.nChannleNumber * wavHeader.nBitsPerSample / 8;
wavHeader.nBytesPerSecond = wavHeader.nSampleRate * wavHeader.nBytesPerSample;
}
三、WAVE文件数据解析
当WAVE文件的头被解析成功后,下一步便是获取WAVE文件里的声音源数据,我们知道声音文件有单声道和多声道之分,对于单声道文件很好理解,声音数据就是按序排放。
而如果是立体声(2声道)文件,那么左右声道的声音数据到底是怎么排放的呢?下面以一个示例立体声文件数据(仅分析前72bytes)进行解释:
offset(h)
00000000: 52 49 46 46 24 08 00 00 57 41 56 45 66 6d 74 20
00000010: 10 00 00 00 01 00 02 00 22 56 00 00 88 58 01 00
00000020: 04 00 10 00 64 61 74 61 00 08 00 00 00 00 00 00
00000030: 24 17 1e f3 3c 13 3c 14 16 f9 18 f9 34 e7 23 a6
00000040: 3c f2 24 f2 11 ce 1a 0d
下图是这个72bytes数据解析图,从图中可以看到,左右声道的声音数据是按块(nBlockAlign指定)交替排放的。
四、WAVE文件实例分析
WAVE文件格式我们都了解透彻了,下面我们尝试分析一个经典的WAVE文件:"Windows XP 启动.wav",让我们直接用二进制编辑器HxD打开它:
按wave_head_t解析WAVE头可知,这段wave是44.1kHz/16bit双声道线性PCM码音频,实际音频数据总长度为1361076bytes(1361076(datasize)/176400(nAvgBytesPerSec)=7.7158秒),最后再用Adobe Audition(原Cool Edit)打开查看其波形图: