23、序列化与音频处理:WAV文件格式的序列化与解析
音频文件,说白了就是一堆数字。但要让这些数字能被播放器识别,就得按规矩来。WAV格式,就是最经典的一种规矩。
我个人习惯把WAV看作一个「带头的包裹」——头部告诉播放器:这音频是啥采样率、多少位深、几个声道。后面才是真正的音频数据。解析WAV,本质上就是把这个包裹拆开,把头部信息读出来,再把音频数据按规则还原。
WAV文件结构概览
WAV文件遵循RIFF(Resource Interchange File Format)规范。它的结构很简单,就是一个个「块」(Chunk)拼接而成。
核心结构如下:
| 偏移地址 | 长度(字节) | 内容 | 说明 |
|---|---|---|---|
| 0x00 | 4 | "RIFF" | RIFF标识 |
| 0x04 | 4 | 文件总长度 - 8 | 小端存储 |
| 0x08 | 4 | "WAVE" | WAV格式标识 |
| 0x0C | 4 | "fmt " | 格式块标识(注意有空格) |
| 0x10 | 4 | 格式块长度 | 通常为16(PCM) |
| 0x14 | 2 | 音频格式 | 1表示PCM |
| 0x16 | 2 | 声道数 | 1=单声道,2=立体声 |
| 0x18 | 4 | 采样率 | 如44100 |
| 0x1C | 4 | 字节率 | 采样率 × 块对齐 |
| 0x20 | 2 | 块对齐 | 声道数 × 位深/8 |
| 0x22 | 2 | 位深 | 如16、24、32 |
| 0x24 | 4 | "data" | 数据块标识 |
| 0x28 | 4 | 音频数据长度 | 字节数 |
| 0x2C | N | 音频数据 | PCM采样值 |
关键点:WAV头部固定为44字节(标准PCM格式)。但有些工具会写入额外信息块(如"LIST"、"fact"),所以解析时不能硬编码偏移量,必须按块标识来查找。
用C语言序列化WAV头部
序列化,就是把结构体数据按字节顺序写入文件。WAV头部用C结构体表示非常自然。
#include <stdint.h>
// 注意:需要按1字节对齐
#pragma pack(push, 1)
typedef struct {
char riff_id[4]; // "RIFF"
uint32_t file_size; // 文件总长度 - 8
char wave_id[4]; // "WAVE"
char fmt_id[4]; // "fmt "
uint32_t fmt_size; // 格式块长度(16)
uint16_t audio_format; // 音频格式(1=PCM)
uint16_t num_channels; // 声道数
uint32_t sample_rate; // 采样率
uint32_t byte_rate; // 字节率
uint16_t block_align; // 块对齐
uint16_t bits_per_sample; // 位深
char data_id[4]; // "data"
uint32_t data_size; // 音频数据长度
} wav_header_t;
#pragma pack(pop)
我的经验:结构体对齐是个坑。我曾在ARM Cortex-M上吃过亏——默认4字节对齐,导致结构体成员之间出现填充字节,写出来的WAV文件播放器根本不认。所以一定要用 #pragma pack(push, 1) 强制1字节对齐。
序列化函数很简单:
int wav_serialize(const wav_header_t *header, const int16_t *samples,
uint32_t sample_count, const char *filename) {
FILE *fp = fopen(filename, "wb");
if (!fp) return -1;
// 写入头部
fwrite(header, sizeof(wav_header_t), 1, fp);
// 写入音频数据
fwrite(samples, sizeof(int16_t), sample_count, fp);
fclose(fp);
return 0;
}
嗯,这里要注意:data_size 字段必须等于 sample_count * sizeof(int16_t),否则播放器会报错。
解析WAV文件
解析就是序列化的逆过程。但有个问题——你不能假设文件一定从偏移44字节开始就是数据。我遇到过一些软件生成的WAV,在"fmt"和"data"之间插入了"LIST"块(包含作者、录音日期等信息)。
所以,正确的做法是:
- 读取RIFF头部,验证是WAV格式
- 读取"fmt "块,获取音频参数
- 跳过所有非"data"的块,直到找到"data"块
- 读取音频数据
int wav_parse(const char *filename, wav_header_t *header,
int16_t **samples, uint32_t *sample_count) {
FILE *fp = fopen(filename, "rb");
if (!fp) return -1;
// 读取RIFF头部
fread(header, 1, sizeof(wav_header_t), fp);
// 验证RIFF和WAVE标识
if (memcmp(header->riff_id, "RIFF", 4) != 0 ||
memcmp(header->wave_id, "WAVE", 4) != 0) {
fclose(fp);
return -2;
}
// 跳过非data块
while (1) {
char chunk_id[4];
uint32_t chunk_size;
fread(chunk_id, 1, 4, fp);
fread(&chunk_size, 4, 1, fp);
if (memcmp(chunk_id, "data", 4) == 0) {
// 找到数据块
*sample_count = chunk_size / sizeof(int16_t);
*samples = (int16_t*)malloc(chunk_size);
fread(*samples, 1, chunk_size, fp);
break;
} else {
// 跳过当前块
fseek(fp, chunk_size, SEEK_CUR);
}
}
fclose(fp);
return 0;
}
我曾经踩过的坑:有些WAV文件的"data"块大小字段写的是0,但实际后面有数据。这是因为某些录音软件先写头部,再写数据,最后才更新data_size。遇到这种情况,你得用文件总长度减去当前偏移量来计算数据长度。
WAV序列化与解析的完整流程
下面这张图,把整个流程串起来了:
实战:生成一个1kHz正弦波WAV文件
光说不练假把式。我们来生成一个1kHz的正弦波,采样率44100,16位,单声道,时长1秒。
#include <math.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define SAMPLE_RATE 44100
#define DURATION 1.0
#define FREQUENCY 1000.0
#define AMPLITUDE 0.8
int main() {
wav_header_t header;
uint32_t sample_count = (uint32_t)(SAMPLE_RATE * DURATION);
int16_t *samples = (int16_t*)malloc(sample_count * sizeof(int16_t));
// 填充头部
memcpy(header.riff_id, "RIFF", 4);
header.file_size = 36 + sample_count * sizeof(int16_t);
memcpy(header.wave_id, "WAVE", 4);
memcpy(header.fmt_id, "fmt ", 4);
header.fmt_size = 16;
header.audio_format = 1; // PCM
header.num_channels = 1;
header.sample_rate = SAMPLE_RATE;
header.bits_per_sample = 16;
header.block_align = header.num_channels * header.bits_per_sample / 8;
header.byte_rate = header.sample_rate * header.block_align;
memcpy(header.data_id, "data", 4);
header.data_size = sample_count * sizeof(int16_t);
// 生成正弦波
for (uint32_t i = 0; i < sample_count; i++) {
double t = (double)i / SAMPLE_RATE;
double value = sin(2.0 * M_PI * FREQUENCY * t) * AMPLITUDE;
samples[i] = (int16_t)(value * 32767);
}
// 序列化到文件
if (wav_serialize(&header, samples, sample_count, "sine_1khz.wav") == 0) {
printf("WAV文件生成成功!\n");
}
free(samples);
return 0;
}
我的一点建议:生成正弦波时,注意不要削顶。如果振幅超过1.0,采样值会超过int16_t的范围,播放出来就是刺耳的失真。我在调试音频算法时,经常先用正弦波验证通路是否正常——如果正弦波听起来有毛刺,那肯定是哪里溢出了。
避坑指南
- 字节序问题:WAV使用小端字节序。在x86上没问题,但在大端处理器(如某些网络芯片)上,需要手动转换。
- data_size为0:有些文件data_size字段写0,实际数据在文件末尾。可以用文件总长度减去当前偏移量来补救。
- 非PCM格式:WAV也支持压缩格式(如ADPCM),此时fmt块长度大于16,且audio_format不为1。解析时要注意。
- 内存分配:解析大文件时,不要一次性malloc整个音频数据。我建议分块读取,或者用mmap映射文件。
WAV格式的序列化与解析,说白了就是「按规矩读写字节」。规矩清楚了,代码自然就写对了。下次遇到播放器不认你的WAV文件,先检查头部字节对不对——八成是结构体对齐或者data_size算错了。
公众号:蓝海资料掘金营,微信deep3321