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"块(包含作者、录音日期等信息)。

所以,正确的做法是:

  1. 读取RIFF头部,验证是WAV格式
  2. 读取"fmt "块,获取音频参数
  3. 跳过所有非"data"的块,直到找到"data"块
  4. 读取音频数据
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序列化与解析的完整流程

下面这张图,把整个流程串起来了:

WAV序列化与解析流程 序列化(写入) 解析(读取) 1. 填充 wav_header_t 结构体 2. 设置 data_size = 样本数 × 字节数 3. fwrite 写入头部(44字节) 4. fwrite 写入 PCM 采样数据 1. 读取并验证 RIFF/WAVE 标识 2. 读取 fmt 块,获取音频参数 3. 循环查找 data 块(跳过其他块) 4. 读取音频数据到内存 数据流方向 ⚠ 注意:解析时不要硬编码偏移量,必须按块标识(chunk ID)查找 序列化时务必使用 #pragma pack(1) 避免结构体填充

实战:生成一个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