11-3_音频文件格式

数字音频文件格式

文件分析软件:

有损压缩格式:

  • MP3(MPEG-1 Audio Layer III):最流行的音频格式之一,具有高压缩比和良好的音质。

  • AAC(Advanced Audio Coding):音质优于MP3,广泛用于流媒体和移动设备。

  • WMA(Windows Media Audio):由微软开发,适用于Windows平台。

  • OGG Vorbis:开源的有损压缩格式,音质和压缩比与MP3相当。

  • Opus:高效、灵活的音频编码标准,适用于多种应用场景,包括实时通信、流媒体和存储。

  • AMR(Adaptive Multi-Rate):用于语音通信的音频压缩算法,适用于低比特率应用。

无损压缩格式:

  • FLAC(Free Lossless Audio Codec):无损压缩格式,能够在不损失音质的情况下减少文件大小。
  • ALAC(Apple Lossless Audio Codec):苹果公司开发的无损压缩格式,适用于Apple设备。
  • APE(Monkey’s Audio):一种无损压缩格式,文件大小大约为WAV的一半。
  • WAV(Waveform Audio File Format):无压缩的音频格式,通常用于高质量音频存储。

1.WAV文件格式

1.1 wav简介

WAV 波形音频文件格式(Waveform Audio File Format),是 Microsoft 资源交换文件格式 (RIFF) 规范的子集,用于存储数字音频文件。该格式不对比特流应用任何压缩,并支持不同的采样率和比特率存储录音。WAV 文件可以包含压缩音频,但最常见的 WAV 音频格式是线性脉冲编码调制 (LPCM) 格式的未压缩音频。LPCM 也是音频 CD 的标准音频编码格式,它存储以 44.1 kHz 采样的双通道 LPCM 音频,每个样本 16 位。WAV (RIFF) 文件的标头长度为 44 字节,格式如下:

位置 样本值 描述
1 - 4 “RIFF” 将文件标记为 RIFF 文件。每个字符的长度为 1 个字节。
5 - 8 文件大小 (整数) 整个文件的大小 - 8 字节,以字节为单位(32 位整数)。
9 -12 “WAVE” 文件类型 Header。就我们的目的而言,它始终等于 “WAVE”。
13-16 “FMT ” 格式化块标记。包括尾随 null
17-20 16 上面列出的格式数据的长度
21-22 1(音频格式) 格式类型(1 是 PCM)- 2 字节整数
23-24 2(声道数) 通道数 - 2 字节整数
25-28 44100(采样率) 采样率 - 32 字节整数。常见值为 44100 (CD)、48000 (DAT)。采样率 = 每秒采样数,或赫兹。
29-32 176400(每秒数据字节数) (采样率 * 采样位数 * 通道数) / 8。
33-34 4(数据块对齐) (采样位数 * 通道 )/ 8。1 - 8 位单声道;2 - 8 位立体声/16 位单声道;4 - 16 位立体声
35-36 16( 采样位数) 每个样本的位数
37-40 “data” “data” 块头。标记数据部分的开头。
41-44 文件大小(数据) 数据部分的大小。
44+ 音频数据 对于Data块,根据声道数和采样率的不同情况。
  1. 8 Bit 单声道:
采样1 采样2
数据1 数据2
  1. 8 Bit 双声道
采样1 采样2
声道1数据1 声道2数据1 声道1数据2 声道2数据2
  1. 16 Bit 单声道:
采样1 采样2
数据1低字节 数据1高字节 数据2低字节 数据2高字节
  1. 16 Bit 双声道
采样1
声道1数据1低字节 声道1数据1高字节 声道2数据1低字节 声道2数据1高字节
采样2
声道1数据2低字节 声道1数据2高字节 声道2数据2低字节 声道2数据2高字节

1.2 原始PCM打包WAV文件

#include <stdio.h>
#include <stdint.h>
#include <string.h>

// WAV 文件头结构体
typedef struct {
    char chunkID[4];       // "RIFF"
    uint32_t chunkSize;    // 文件大小 - 8
    char format[4];        // "WAVE"
    char subChunk1ID[4];   // "fmt "
    uint32_t subChunk1Size;// 16 for PCM
    uint16_t audioFormat;  // PCM = 1
    uint16_t numChannels;  // 声道数量
    uint32_t sampleRate;   // 采样率
    uint32_t byteRate;     // 采样率 * 声道数 * 每个样本的字节数
    uint16_t blockAlign;   // 声道数 * 每个样本的字节数
    uint16_t bitsPerSample;// 每个样本的位数
    char subChunk2ID[4];   // "data"
    uint32_t subChunk2Size;// 采样数据的大小
} WavHeader;

void writeWavHeader(FILE *file, int numChannels, int sampleRate, int bitsPerSample, int dataSize) {
    WavHeader header;

    // RIFF chunk
    memcpy(header.chunkID, "RIFF", 4);
    header.chunkSize = 36 + dataSize; // 36 + 数据块的大小
    memcpy(header.format, "WAVE", 4);

    // fmt sub-chunk
    memcpy(header.subChunk1ID, "fmt ", 4);
    header.subChunk1Size = 16;        // PCM
    header.audioFormat = 1;           // PCM格式
    header.numChannels = numChannels;
    header.sampleRate = sampleRate;
    header.byteRate = sampleRate * numChannels * (bitsPerSample / 8);
    header.blockAlign = numChannels * (bitsPerSample / 8);
    header.bitsPerSample = bitsPerSample;

    // data sub-chunk
    memcpy(header.subChunk2ID, "data", 4);
    header.subChunk2Size = dataSize;

    // 写入 WAV 头
    fwrite(&header, sizeof(WavHeader), 1, file);
}

int main() {
    // 配置 PCM 数据的参数
    int numChannels = 2;          // 双声道
    int sampleRate = 44100;       // 采样率
    int bitsPerSample = 16;       // 每个样本16位

    // 打开输入的 PCM 文件
    FILE *pcmFile = fopen("input.pcm", "rb");
    if (pcmFile == NULL) {
        printf("无法打开 input.pcm 文件\n");
        return 1;
    }

    // 获取 PCM 文件大小
    fseek(pcmFile, 0, SEEK_END);
    long dataSize = ftell(pcmFile);
    fseek(pcmFile, 0, SEEK_SET);

    // 打开输出的 WAV 文件
    FILE *wavFile = fopen("output.wav", "wb");
    if (wavFile == NULL) {
        printf("无法创建 output.wav 文件\n");
        fclose(pcmFile);
        return 1;
    }

    // 写 WAV 文件头
    writeWavHeader(wavFile, numChannels, sampleRate, bitsPerSample, dataSize);

    // 读取 PCM 数据并写入 WAV 文件
    uint8_t buffer[1024];
    size_t bytesRead;
    while ((bytesRead = fread(buffer, 1, sizeof(buffer), pcmFile)) > 0) {
        fwrite(buffer, 1, bytesRead, wavFile);
    }

    // 关闭文件
    fclose(pcmFile);
    fclose(wavFile);

    printf("WAV 文件已生成\n");
    return 0;
}
  1. WAV 文件头:和之前一样,WavHeader 结构体定义了 WAV 文件的头部。
  2. writeWavHeader 函数:用于生成 WAV 文件的头信息。
  3. 主函数
    • 通过 fopen 打开 input.pcm 文件,并通过 ftell 获取 PCM 文件的大小。
    • 使用 fopen 创建并打开 output.wav 文件。
    • 调用 writeWavHeader 写入 WAV 文件头,指定 PCM 的采样率、声道数和位深。
    • 通过缓冲区 buffer 循环读取 PCM 数据,并将其写入到输出的 WAV 文件中。
    • 最后,关闭所有文件。

2.MP3文件格式

MP3是一个数据压缩格式。它舍弃脉冲编码调制(PCM)音频数据中,对人类听觉不重要的数据(类似于JPEG,是一个有损图像的压缩格式),从而达到了压缩成小得多的文件大小。MP3文件大体上分为三个部分:ID3V2+音频数据+ID3V1

2.1 ID3V2

​ ID3v2是 MP3 文件的元数据标签之一,通常位于文件的开头。它可以存储有关音频文件的丰富信息,包括标题、艺术家、专辑、年份、封面图片等。每个ID3V2的标签都一个标签头和若干个标签帧或一个扩展标签头组成。

2.1.1 标签头

​ 在文件的首部顺序记录 10 个字节的 ID3V2的头部。

名称 大小 样本值 描述
ID3标识符 3 ID3 头部标识,由字符ID3组成,表示这是一个ID3v2的标签。
主版本 1 3 版本号, ID3V2.3 就记录 03,ID3V2.4就记录为4。
副版本 1 0 副版本号。通常为 00,即 ID3v2.3.0 或 ID3v2.4.0
标志位 1 0x00 第 7 位(0x80):Unsynchronisation(同步安全)标志。如果设置了此位,表示标签中的所有数据都进行了“反同步”处理,以避免某些音频解码器误解同步字。
第 6 位(0x40):Extended header(扩展头)标志。如果设置了此位,标签包含一个扩展头,提供附加的控制信息。
第 5 位(0x20):Experimental indicator(实验性标签)标志。如果设置了此位,表明标签使用了实验性的功能。
第 4 位(0x10):Footer present(仅适用于 ID3v2.4)。如果设置了此位,表明标签在结尾部分还附加了一个标签尾部(footer),结构与标签头部相似。
标签大小 4 size 该字段表示标签头之后的标签数据部分的大小,不包括前面的标签头(10 字节)。 这是一个 28 位的同步安全整数,使用 7 位字节编码。这意味着每个字节的最高位(第 8 位)不使用,仅使用低 7 位。这种编码方式使得 ID3 标签能够与某些音频流中的同步字节不发生冲突。 标签大小不包括标签头的 10 个字节,也不包括标签尾(如果存在)。

扩展头(可选)

如果在标志位中启用了扩展头,则标签头之后会出现扩展头。扩展头的结构如下:

  1. 扩展头大小(4 字节):
    • 指定扩展头的总大小。
  2. 扩展标志(2 字节):
    • 用于指示扩展头的某些特性。
  3. 扩展标志数据(可选):
    • 扩展标志中提到的具体数据。

扩展头用于在标签中提供更多控制信息,但并不总是存在,通常在高级应用中才使用。

2.1.2 标签帧

ID3v2 标签的核心部分是帧(frames)。每个帧存储一种特定类型的元数据(如歌曲标题、专辑名称、艺术家等)。ID3v2 的帧结构主要分为以下部分:

  1. 帧标识符 (Frame ID):标识帧的类型(4 个字节)。
  2. 帧大小 (Frame Size):描述帧中数据的大小(不包括标识符和标志)。(4 个字节)
  3. 帧标志 (Frame Flags):表示帧的特定处理要求或行为的标志位。(2 个字节)
  4. 帧内容 (Frame Data):包含具体的元数据信息,例如歌曲标题或艺术家名称。

1.帧标识符

  • 长度:4 个字节。
  • 作用:标识帧的类型,即帧中存储的是什么类型的数据。
  • 常见的帧标识符
    • TIT2: 标题(Title/Songname/Content description)
    • TPE1: 主要艺术家/表演者(Lead performer(s)/Soloist(s))
    • TALB: 专辑(Album/Movie/Show title)
    • TCON: 音乐风格(Content type)
    • COMM: 注释(Comments)

2.帧大小 (Frame Size)

  • 长度:4 个字节。
  • 作用:表示帧内容的字节大小,不包括帧标识符和帧标志。
  • 编码方式:使用同步安全整数(synchsafe integer),即每个字节的最高位必须为 0。这意味着每个字节只能使用 7 位存储数据(有效位),并且确保没有同步错误。

3.帧标志 (Frame Flags)

  • 长度:2 个字节。
  • 作用:控制帧的处理方式和属性,如压缩、加密或是否需要保留帧。
  • 帧标志位结构:
    • 第 1 个字节:表示帧的标签存储选项。
      • bit 7:保留位(未使用)。
      • bit 6:Tag alter preservation(标签修改时是否保留此帧)。
      • bit 5:File alter preservation(文件修改时是否保留此帧)。
      • bit 4:Read only(只读标志,表示此帧不可修改)。
    • 第 2 个字节:与帧解码相关的选项。
      • bit 7:压缩标志(Compression)。
      • bit 6:加密标志(Encryption)。
      • bit 5:分组标志(Grouping Identity)。

3.帧内容 (Frame Data)

  • 长度:可变长,由帧的大小字段定义。
  • 作用:存储帧的实际数据,即元信息。不同的帧类型有不同的数据格式,通常会包含编码信息、语言信息等。
  • 常见的帧内容格式:
    • 文本帧(例如 TIT2, TALB):通常以一个字符编码字节开始(表示文本编码方式),然后是实际的文本内容。
    • 注释帧(例如 COMM):包含语言、描述符和实际的注释文本。
    • 图像帧(例如 APIC):用于存储嵌入的专辑封面,包含 MIME 类型、图片类型和图片数据。

下面为示例帧结构,以TIT2(歌曲标题)帧为例:

+----------------+----------------+----------------+----------------+
| Frame ID ('TIT2')  | Frame Size (4 bytes) | Frame Flags (2 bytes) |
+----------------+----------------+----------------+----------------+
| 0x01 (Encoding) | Song title in UTF-8 (variable length)            |
+----------------+---------------------------------------------------+

2.2 音频数据帧

在 MP3 文件格式中,ID3v2 标签之后紧随其后的是实际的音频数据部分。这部分数据由 MP3 音频帧 组成。每个 MP3 音频帧包含了音频的压缩数据,并且可以独立解码。这些音频帧紧密排列,构成了整个音频数据部分。MP3 音频帧由以下几个部分组成:

名称 大小 描述
帧同步 11位 帧的同步标记,用于标识每个 MP3 音频帧的起始位置。其值为 0x7FF,即 11 个连续的 1(11111111111)
帧头 4字节(32位) 帧头紧随帧同步标志,用于描述 MP3 帧的格式和音频编码参数。
附加信息 16位 如果 帧头中的Protection bit 为 0,则这个部分包含 CRC 校验数据,用于检测帧的完整性。如果帧头中的 Protection bit 为 1,则不存在这个部分。
音频数据 可变 音频数据是帧的主要部分,包含实际的压缩音频样本。根据帧头中的信息,音频数据部分使用 MPEG Audio Layer III(MP3)的编码方式存储音频数据。
帧末尾填充 1 个字节,视情况而定 填充是可选的,如果帧的长度不够标准的帧长度,则可能会加入填充字节以保持帧的大小一致。

对于帧头,它的具体结构如下:

+--------------------+---------------------+---------------------+----------------------+
| Sync (11 bits)     | MPEG Audio version (2 bits) | Layer (2 bits)          | Protection bit (1 bit) |
+--------------------+---------------------+---------------------+----------------------+
| Bitrate index (4 bits) | Sampling rate (2 bits) | Padding bit (1 bit) | Private bit (1 bit)  |
+--------------------+---------------------+---------------------+----------------------+
| Channel mode (2 bits) | Mode extension (2 bits) | Copy bit (1 bit)   | Original bit (1 bit) |
+--------------------+---------------------+---------------------+----------------------+
| Emphasis (2 bits)  |
+--------------------+
  • MPEG Audio version

    :表示使用的 MPEG 音频版本(例如,MPEG-1,MPEG-2,MPEG-2.5)。

    • 00: MPEG 2.5
    • 01: 保留
    • 10: MPEG 2
    • 11: MPEG 1
  • Layer

    :表示使用的层(Layer),决定了编码的复杂度。

    • 00: 保留
    • 01: Layer III (MP3)
    • 10: Layer II
    • 11: Layer I
  • Protection bit

    :表示是否存在 CRC 校验。

    • 0: 有 CRC 校验。
    • 1: 没有 CRC 校验。
  • Bitrate index

    :表示音频的比特率(kbps)。

    • 根据表格,4 个比特可以表示 16 种比特率(如 128 kbps、192 kbps 等)。
  • Sampling rate

    :表示采样率的索引,典型的采样率有 44.1 kHz、48 kHz、32 kHz 等。

    • 00: 44.1 kHz
    • 01: 48 kHz
    • 10: 32 kHz
    • 11: 保留
  • Padding bit

    :表示帧末尾是否添加填充。

    • 0: 没有填充。
    • 1: 有填充。
  • Channel mode

    :表示音频通道模式。

    • 00: 立体声(Stereo)
    • 01: 联合立体声(Joint Stereo)
    • 10: 双通道(Dual Channel)
    • 11: 单声道(Mono)
  • Emphasis:表示是否应用了去加重技术,用于某些高频部分的修正。

2.3 ID3V1

在 MP3 文件中,ID3v1 是一种早期的元数据标签格式,用于存储歌曲的基本信息,例如标题、艺术家、专辑等。与后来的 ID3v2 不同,ID3v1 格式较为简洁,数据固定为 128 字节,并且存储在 MP3 文件的末尾。固定分配了几个字段来保存不同的元数据信息如下所示:

名称 大小(字节) 描述
Tag (标签标识符) 3 固定为字符串 "TAG",表示这是一个 ID3v1 标签。
Title (歌曲标题) 30 存储歌曲的标题。如果标题长度不足 30 字节,使用空字符(\0)填充。由于长度有限,标题可能会被截断。
Artist (艺术家/表演者) 30 存储艺术家或表演者的名称,规则与标题类似,长度不足则补充空字符。
Album (专辑名称) 30 存储专辑名称,同样是定长字符串,超过 30 字节则会被截断。
Year 4 存储歌曲或专辑的发行年份,例如 2000。这部分也是一个定长字符串。
Comment (注释) 28 存储歌曲的注释或额外信息。如果使用 ID3v1.1 格式,则最后两个字节用于存储曲目号。
Zero (保留位) 1 在 ID3v1.1 中,这个字节必须为 0,用于标识这是 ID3v1.1 格式。
Track (曲目号,ID3v1.1) 1 存储歌曲在专辑中的曲目编号。如果不使用曲目号,这个字节为 0。
Genre (音乐类型) 1 使用一个整数来表示歌曲的音乐类型。

假设歌曲的标题为 "Song Title",艺术家为 "Artist Name",专辑为 "Album Name",年份为 "2000",音乐类型为 "Rock"。对应的 ID3v1 标签会是:

TAG  Song Title              Artist Name               Album Name                2000  Comment                        0  01  17

2.4 原始PCM打包MP3文件

2.4.1 编码PCM原始文件为MP3

使用FFMPEG将原始的pcm文件编码为MP3音频帧:

ffmpeg -f s16le -ar 44100 -ac 2 -i input.pcm -b:a 192k -write_xing 0 -id3v2_version 0 -metadata title="" output.mp3

参数解释:

  1. -f s16le:指定输入文件的格式为

    PCM 16位 小端序

    (signed 16-bit little-endian)。你可以根据 PCM 文件的格式调整此参数。

    • 如果你的 PCM 文件是 8 位:使用 -f u8
    • 如果是 24 位:使用 -f s24le
  2. -ar 44100:指定输入文件的采样率(例如 44100 Hz)。根据你的文件调整采样率。

  3. -ac 2:指定输入文件的声道数(例如立体声 2 声道)。如果是单声道,则设置为 -ac 1

  4. -i input.pcm:指定输入文件是 PCM 编码的音频数据。

  5. -b:a 192k:设置音频比特率为 192 kbps,这是一个常用的 MP3 比特率。

  6. -write_xing 0:禁用 Xing 头,Xing 头通常用于 VBR 文件,禁用它可以避免 FFmpeg 在文件开头插入相关的头部信息。

  7. -id3v2_version 0:禁用 ID3v2 标签,确保不会在文件开头写入 ID3v2 标签。

  8. -metadata title="":清除任何元数据(如标题等),避免生成 ID3v1 标签。

  9. output.mp3:输出的文件名,将生成的 MP3 音频帧保存为文件。

2.4.2 分析音频帧信息

此时生成的MP3文件不包含ID3V2和ID3V1信息,仅包含音频数据帧信息。下面演示分析音频帧信息:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

// ID3v2 标签头结构
typedef struct {
    char id[3];         // "ID3" 标识符
    uint8_t version[2]; // 版本号
    uint8_t flags;      // 标志位
    uint8_t size[4];    // 同步安全整数表示的标签大小
} ID3v2Header;

// ID3v1 标签结构
typedef struct {
    char tag[3];        // "TAG"
    char title[30];     // 标题
    char artist[30];    // 艺术家
    char album[30];     // 专辑
    char year[4];       // 年份
    char comment[30];   // 评论
    uint8_t genre;      // 音乐类型编号
} ID3v1Tag;

// 读取同步安全整数(用于 ID3v2 的大小字段)
uint32_t syncSafeToInt(uint8_t *bytes) {
    return (bytes[0] << 21) | (bytes[1] << 14) | (bytes[2] << 7) | bytes[3];
}

// 解析并打印 ID3v2 标签
void parseID3v2(FILE *file) {
    ID3v2Header header;
    fread(&header, sizeof(ID3v2Header), 1, file);

    if (strncmp(header.id, "ID3", 3) != 0) {
        printf("没有找到 ID3v2 标签\n");
        fseek(file, 0, SEEK_SET); // 复位到文件开头
        return;
    }

    // 解析版本信息
    printf("ID3v2 版本: %d.%d\n", header.version[0], header.version[1]);
    
    // 解析标志位
    printf("标志位:\n");
    if (header.flags & 0x80) printf(" - Unsynchronisation\n");
    if (header.flags & 0x40) printf(" - Extended header\n");
    if (header.flags & 0x20) printf(" - Experimental indicator\n");

    // 解析标签大小
    uint32_t tagSize = syncSafeToInt(header.size) + 10; // 加上标签头 10 字节
    printf("ID3v2 标签大小: %u 字节\n", tagSize);

    // 解析各帧内容
    while (ftell(file) < tagSize) {
        char frameID[5] = {0};
        uint8_t frameSizeBytes[4];
        uint8_t frameFlags[2];

        fread(frameID, 1, 4, file); // 读取帧 ID
        fread(frameSizeBytes, 1, 4, file); // 读取帧大小
        fread(frameFlags, 1, 2, file); // 读取帧标志

        uint32_t frameSize = syncSafeToInt(frameSizeBytes);
        if (frameSize == 0) break; // 遇到结束帧

        printf("帧 ID: %s\n", frameID);
        printf("帧大小: %u 字节\n", frameSize);

        // 读取帧内容
        char *frameContent = malloc(frameSize + 1);
        fread(frameContent, 1, frameSize, file);
        frameContent[frameSize] = '\0';

        // 输出帧内容(仅适用于文本帧)
        if (frameID[0] == 'T') {
            printf("内容: %s\n", frameContent + 1); // 跳过编码字节
        }

        free(frameContent);
    }
}

// 解析 MP3 音频帧头
void parseMP3FrameHeader(FILE *file) {
    uint8_t header[4];
    fread(header, 1, 4, file);

    if (header[0] != 0xFF || (header[1] & 0xE0) != 0xE0) {
        printf("无效的 MP3 帧头\n");
        return;
    }

    // 解析 MPEG 版本
    uint8_t versionID = (header[1] >> 3) & 0x03;
    const char *version;
    int versionIndex = 0;
    if (versionID == 3) {
        version = "MPEG-1";
        versionIndex = 0;
    } else if (versionID == 2) {
        version = "MPEG-2";
        versionIndex = 1;
    } else {
        version = "MPEG-2.5";
        versionIndex = 2;
    }

    // 解析层
    uint8_t layer = (header[1] >> 1) & 0x03;
    const char *layerStr;
    if (layer == 1) layerStr = "Layer III";
    else if (layer == 2) layerStr = "Layer II";
    else if (layer == 3) layerStr = "Layer I";
    else layerStr = "Reserved";

    // 解析比特率索引
    uint8_t bitrateIndex = (header[2] >> 4) & 0x0F;
    static const int bitrates[3][16] = {
        {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1}, // MPEG-1, Layer III
        {0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1}, // MPEG-1, Layer II
        {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256}  // MPEG-1, Layer I
    };
    int bitrate = bitrates[layer-1][bitrateIndex];

    // 解析采样率索引
    uint8_t sampleRateIndex = (header[2] >> 2) & 0x03;
    static const int sampleRates[3][4] = {
        {44100, 48000, 32000, -1}, // MPEG-1
        {22050, 24000, 16000, -1}, // MPEG-2
        {11025, 12000, 8000, -1}   // MPEG-2.5
    };
    int sampleRate = sampleRates[versionIndex][sampleRateIndex];

    // 解析声道模式
    uint8_t channelMode = (header[3] >> 6) & 0x03;
    const char *channelStr;
    switch (channelMode) {
        case 0: channelStr = "Stereo"; break;
        case 1: channelStr = "Joint Stereo"; break;
        case 2: channelStr = "Dual Channel"; break;
        case 3: channelStr = "Mono"; break;
        default: channelStr = "Unknown"; break;
    }

    printf("MP3 音频帧:\n");
    printf(" - 版本: %s\n", version);
    printf(" - 层: %s\n", layerStr);
    printf(" - 比特率: %d kbps\n", bitrate);
    printf(" - 采样率: %d Hz\n", sampleRate);
    printf(" - 声道模式: %s\n", channelStr);
}

// 解析并打印 ID3v1 标签
void parseID3v1(FILE *file) {
    ID3v1Tag tag;
    fseek(file, -128, SEEK_END);  // 跳转到文件末尾的 128 字节处
    fread(&tag, sizeof(ID3v1Tag), 1, file);

    if (strncmp(tag.tag, "TAG", 3) != 0) {
        printf("没有找到 ID3v1 标签\n");
        return;
    }

    printf("ID3v1 标签:\n");
    printf(" - 标题: %.30s\n", tag.title);
    printf(" - 艺术家: %.30s\n", tag.artist);
    printf(" - 专辑: %.30s\n", tag.album);
    printf(" - 年份: %.4s\n", tag.year);
    printf(" - 评论: %.30s\n", tag.comment);
    printf(" - 类型编号: %u\n", tag.genre);
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        printf("用法: %s <mp3 文件>\n", argv[0]);
        return 1;
    }

    FILE *file = fopen(argv[1], "rb");
    if (!file) {
        printf("无法打开文件: %s\n", argv[1]);
        return 1;
    }

    // 解析 ID3v2 标签
    parseID3v2(file);

    // 解析第一个音频帧头
    parseMP3FrameHeader(file);

    // 解析 ID3v1 标签
    parseID3v1(file);

    fclose(file);
    return 0;
}

执行结果如下所示:

2.4.3 增加ID3V2和ID3V1

为音频数据帧增加标签信息,代码如下所示:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

// ID3v2 标签头结构
typedef struct {
    char id[3];         // "ID3" 标识符
    uint8_t version[2]; // 版本号
    uint8_t flags;      // 标志位
    uint8_t size[4];    // 同步安全整数表示的标签大小
} ID3v2Header;

// 写入同步安全整数
void writeSyncSafe(uint32_t size, uint8_t out[4]) {
    out[0] = (size >> 21) & 0x7F;
    out[1] = (size >> 14) & 0x7F;
    out[2] = (size >> 7)  & 0x7F;
    out[3] = size & 0x7F;
}

// 写入文本帧
void writeTextFrame(FILE *file, const char *frameID, const char *text) {
    uint32_t frameSize = 1 + strlen(text); // 1 字节的编码类型 + 文本内容大小
    fwrite(frameID, 1, 4, file);           // 写入帧 ID,如 "TIT2"
    
    uint8_t size[4];
    writeSyncSafe(frameSize, size);        // 写入同步安全的大小
    fwrite(size, 1, 4, file);
    
    uint16_t flags = 0;                    // 无特殊标志
    fwrite(&flags, 1, 2, file);
    
    uint8_t encoding = 0;                  // ISO-8859-1 编码
    fwrite(&encoding, 1, 1, file);
    
    fwrite(text, 1, strlen(text), file);   // 写入实际文本内容
}

// 写入 ID3v1 标签
void writeID3v1(FILE *file) {
    char tag[128] = {0};
    
    // 填写 ID3v1 固定头 "TAG"
    memcpy(tag, "TAG", 3);
    
    // 填写元数据
    strncpy(tag + 3, "test-beat", 30);      // 标题
    strncpy(tag + 33, "100ASK-test", 30);   // 艺术家
    strncpy(tag + 63, "free beat", 30);     // 专辑
    strncpy(tag + 93, "2024", 4);           // 年份
    
    // 写入 ID3v1 标签到文件末尾
    fseek(file, 0, SEEK_END);
    fwrite(tag, 1, 128, file);
}

int main(int argc, char *argv[]) {
    if (argc < 3) {
        printf("用法: %s <input.mp3> <output.mp3>\n", argv[0]);
        return 1;
    }

    FILE *inFile = fopen(argv[1], "rb");
    FILE *outFile = fopen(argv[2], "wb");

    if (!inFile || !outFile) {
        printf("无法打开文件\n");
        return 1;
    }

    // 复制原始 MP3 音频数据到输出文件
    uint8_t buffer[1024];
    size_t bytesRead;
    while ((bytesRead = fread(buffer, 1, sizeof(buffer), inFile)) > 0) {
        fwrite(buffer, 1, bytesRead, outFile);
    }
    
    fclose(inFile);

    // 写入 ID3v2 标签
    ID3v2Header header = { "ID3", { 0x03, 0x00 }, 0x00, {0} };
    
    // 计算 ID3v2 标签大小 (头 + 每个帧)
    uint32_t tagSize = 10 + (10 + 10 + strlen("100ASK-test")) + (10 + strlen("free beat")) + (10 + strlen("2024")) + (10 + strlen("test-beat"));
    writeSyncSafe(tagSize, header.size);
    fseek(outFile, 0, SEEK_SET);  // 确保在文件头部写入标签
    fwrite(&header, 1, sizeof(ID3v2Header), outFile);

    // 写入帧 (艺术家、专辑、年份、标题)
    writeTextFrame(outFile, "TPE1", "100ASK-test");  // 艺术家
    writeTextFrame(outFile, "TALB", "free beat");    // 专辑
    writeTextFrame(outFile, "TYER", "2024");         // 年份
    writeTextFrame(outFile, "TIT2", "test-beat");    // 标题

    // 写入 ID3v1 标签
    writeID3v1(outFile);

    fclose(outFile);
    printf("MP3 文件已成功添加 ID3v2 和 ID3v1 标签\n");
    return 0;
}

3.AAC文件格式

3.1 封装格式解析

高级音频编码 (Advanced Audio Coding) 是一种用于有损数字音频压缩的音频编码标准。它被设计为 MP3 格式的继承者,在相同比特率下通常可以获得比 MP3 更高的音质。AAC有两种封装格式:

  • ADIF(Audio Data Interchange Format),音频数据交换格式,这种格式的特点是只在文件头部存储用于音频解码播放的头信息(例如采样率,通道数等),它的解码播放必须从文件头部开始,一般用于存储在本地磁盘中播放。
  • ADTS(Audio Data Transport Stream),音频数据传输流,这种格式的特点是可以将数据看做一个个的音频帧,而每帧都存储了用于音频解码播放的头信息(例如采样率,通道数等),即可以从任何帧位置解码播放,更适用于流媒体传输。

目前在网络传输中常用的就是ADTS格式的封装,所以我们常见的AAC原始码流是由一个一个的ADTS frame(音频数据传输流帧)组成,每一帧由ADTS的帧头和原始数据块(MEPG2 TS),也就是说每个ADTS frame都可以单独去解码。

对于AAC的头部由7/9个字节组成,包含以下信息:

序号 长度(位) 字段 描述
A 12 syncword 同步标志位,所有位都必须设置为1,固定为0xFF,表示ADTS的帧开始
B 1 id MPEG版本,0:MPEG-4;1:MPEG-2
C 2 layer 图层,始终设置为0。
D 1 Protection absence CRC校验标识,0:使用CRC校验;1:不使用CRC校验
E 2 Profile 音频对象类型,按照MPEG-4的音频对象类型序号减1
F 4 sampling frequency index 音频采样频率索引,如下所示:
0: 96000 Hz
1: 88200 Hz
2 : 64000 Hz
3 : 48000 Hz
4: 44100 Hz
...
G 1 private bit 私有位,编码设置为0,解码时忽略
H 3 channel configuration 声道配置
0: Defined in AOT Specifc Config
1: 1 channel : front - center
2 : 2 channels : front - left, front - right
3 : 3 channels : front - center, front - left, front - right
...
I 1 originality 设置为1表示音频的原创性,否则设置为0
J 1 home 编码是设置为0,解码时忽略
K 1 copyright id bit 版权ID位
L 1 copyright id start 通过设置1和0来表示此帧的版权ID位的第一位
M 13 frame length 一个ADTS帧的⻓度,包括ADTS头和AAC原始流
O 11 buffer fullness 缓冲区充满度,0x7FF说明是码率可变的码流,不需要此字段。CBR可能需要此字段,不同编码器使用情况不同
P 2 num raw data blocks ADTS帧的AAC帧数(原始数据块)减1.为了获取最大的兼容性,始终为每个ADTS帧使用一个AAC帧
Q 16 crc 如果CRC校验标识为0,进行CRC检查。

3.2 解析文件的报头信息

下面的程序会使用ffmpeg库解析AAC文件的报头信息并打印出来

#include <stdio.h>
#include <libavformat/avformat.h>

void print_adts_header(const uint8_t *header) {
    int syncword = (header[0] << 4) | (header[1] >> 4);
    int id = (header[1] >> 3) & 0x01;
    int layer = (header[1] >> 1) & 0x03;
    int protection_absent = header[1] & 0x01;
    int profile = (header[2] >> 6) & 0x03;
    int sampling_frequency_index = (header[2] >> 2) & 0x0F;
    int private_bit = (header[2] >> 1) & 0x01;
    int channel_configuration = ((header[2] & 0x01) << 2) | (header[3] >> 6);
    int original_copy = (header[3] >> 5) & 0x01;
    int home = (header[3] >> 4) & 0x01;
    int copyright_identification_bit = (header[3] >> 3) & 0x01;
    int copyright_identification_start = (header[3] >> 2) & 0x01;
    int frame_length = ((header[3] & 0x03) << 11) | (header[4] << 3) | (header[5] >> 5);
    int adts_buffer_fullness = ((header[5] & 0x1F) << 6) | (header[6] >> 2);
    int number_of_raw_data_blocks_in_frame = header[6] & 0x03;

    printf("Syncword: 0x%X\n", syncword);
    printf("ID: %d\n", id);
    printf("Layer: %d\n", layer);
    printf("Protection absent: %d\n", protection_absent);
    printf("Profile: %d\n", profile);
    printf("Sampling frequency index: %d\n", sampling_frequency_index);
    printf("Private bit: %d\n", private_bit);
    printf("Channel configuration: %d\n", channel_configuration);
    printf("Original/copy: %d\n", original_copy);
    printf("Home: %d\n", home);
    printf("Copyright identification bit: %d\n", copyright_identification_bit);
    printf("Copyright identification start: %d\n", copyright_identification_start);
    printf("Frame length: %d\n", frame_length);
    printf("ADTS buffer fullness: %d\n", adts_buffer_fullness);
    printf("Number of raw data blocks in frame: %d\n", number_of_raw_data_blocks_in_frame);
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <input.aac>\n", argv[0]);
        return -1;
    }

    av_register_all();

    AVFormatContext *fmt_ctx = NULL;
    if (avformat_open_input(&fmt_ctx, argv[1], NULL, NULL) < 0) {
        fprintf(stderr, "Could not open input file '%s'\n", argv[1]);
        return -1;
    }

    if (avformat_find_stream_info(fmt_ctx, NULL) < 0) {
        fprintf(stderr, "Could not find stream information\n");
        avformat_close_input(&fmt_ctx);
        return -1;
    }

    AVPacket pkt;
    av_init_packet(&pkt);

    while (av_read_frame(fmt_ctx, &pkt) >= 0) {
        if (pkt.stream_index == 0) { // Assuming the first stream is audio
            print_adts_header(pkt.data);
            break;
        }
        av_packet_unref(&pkt);
    }

    avformat_close_input(&fmt_ctx);
    return 0;
}

执行效果为:

3.2 读取文件的ADTS帧数

下面的程序会使用FFmpeg库读取AAC文件中所有ADTS的帧,并打印其数量和帧率。

#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        printf("Usage: %s <input file>\n", argv[0]);
        return -1;
    }

    const char *input_filename = argv[1];
    AVFormatContext *format_ctx = NULL;
    AVCodecContext *codec_ctx = NULL;
    AVPacket packet;
    int ret, stream_index;

    av_register_all();

    // Open input file and allocate format context
    if (avformat_open_input(&format_ctx, input_filename, NULL, NULL) < 0) {
        fprintf(stderr, "Could not open input file '%s'\n", input_filename);
        return -1;
    }

    // Retrieve stream information
    if (avformat_find_stream_info(format_ctx, NULL) < 0) {
        fprintf(stderr, "Could not find stream information\n");
        return -1;
    }

    // Find the first audio stream
    stream_index = av_find_best_stream(format_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);
    if (stream_index < 0) {
        fprintf(stderr, "Could not find audio stream in the input file\n");
        return -1;
    }

    codec_ctx = format_ctx->streams[stream_index]->codec;

    // Initialize packet
    av_init_packet(&packet);
    packet.data = NULL;
    packet.size = 0;

    // Read frames and print information
    int frame_count = 0;
    while (av_read_frame(format_ctx, &packet) >= 0) {
        if (packet.stream_index == stream_index) {
            frame_count++;
            printf("Frame %d: size=%d, sample_rate=%d\n", frame_count, packet.size, codec_ctx->sample_rate);
        }
        av_packet_unref(&packet);
    }

    // Clean up
    avcodec_close(codec_ctx);
    avformat_close_input(&format_ctx);

    return 0;
}

4.OGG文件格式

参考链接:

OGG 是由 Xiph.Org Foundation 维护的免费开放容器格式。OGG 格式的作者表示,它不受软件专利的限制,旨在提供高质量的数字多媒体的高效流式处理和操作。它用途广泛且灵活,旨在容纳不同类型的媒体数据,如音频、视频、文本和元数据。OGG 用于流式传输和本地播放,并以其开源性质而闻名。对于音频而言,OGG支持的音频格式有:

有损

  • Speex:以低比特率 (~2.1–32 kbit/s/通道) 处理语音数据
  • Vorbis:以中高级可变比特率(每通道 ≈16–500 kbit/s)处理一般音频数据
  • Opus:以低和高可变比特率(每通道 ≈6–510 kbit/s)处理语音、音乐和通用音频

无损

  • FLAC 处理存档和高保真音频数据。
  • OggPCM 允许在 Ogg 容器中存储标准未压缩的 PCM 音频。

当然OGG是支持视频格式的封装,这里暂时不探讨。

4.1 文件格式

OGG文件是由一个个大小可变的页(Page)组成,页的大小通常为4-8KB,最大为65307字节。每个页中包含被拆分的数据包,由于数据包可能会很大,所以可能一个数据包被拆分到不同的页中存储。所以每个页是由页的头部(page header)和被拆分的包数据组成。

下面我们参考官方文档了解其封装过程:

  1. 编码后的逻辑码流会分包给OGG,具体分包的大小由编码格式而定
  2. 每个包会被分割为若干段,每段通常为255字节,最后一段可能小于255字节。
  3. 将一组连续的段打包成一个页(Page)。每个页都有一个页头(Page Header),包含页序号、版本号、标志位等。
  4. 将多个页按照顺序组合成一个物理比特流。

4.2 页面结构

类型 长度(bits) 描述
Capture pattern (捕获模式) 32 捕获模式或同步代码,表示页面开始,用于确保在解析 Ogg 文件时同步。每个页面都以四个 ASCII 字符序列 “OggS” 开头。这有助于在数据丢失或损坏的情况下重新同步解析器,并且是在开始解析页面结构之前进行健全性检查。
Version(版本) 8 此字段表示 Ogg 文件格式的版本,以允许将来扩展。目前强制要求为 0。
Header Type (头部类型) 8 标志位,用于表示页面的类型。
位 值 标志位 页面类型
0 0x0 延续 此页面的第一个packet是前一个packet的延续
1 0x2 BOS 比特流的开始,表示此页面位比特流的第一页
2 0x4 EOS 比特流的结束,表示此页面位比特流的最后一页
Granule position(位置信息) 64 位置信息字段,对于音频流保存此页面的编码后PCM样本数;对于视频流保存此页面的编码后视频帧的总数。
Bitstream serial number(比特流序列号) 32 比特流序列号,包含唯一序列号的4字节字段,用于将 page 标识为属于特定逻辑比特流
Page sequence number(页面序列号) 32 页面序列号,包含唯一序列号的4字节字段,每个序列号随着逻辑比特流单调递增,假设第一页位0,第二页为1,依次类推。
Checksum(校验和) 32 校验和,此字段提供整个页面(包括页眉,在校验和字段设置为 0 的情况下计算)中数据的 CRC32校验和。这允许验证数据自创作以来是否未损坏。未通过校验和的页面应被丢弃。校验和是使用多项式值 0x04C11DB7 生成的。
Page Segments(页段) 8 页段,表示此页面存在的区段数量。它还指示此字段后面的区段表中有多少字节。任何一个页面中最多可以有 255 个区段。
Segment table(段表) n 段表是一个 8 位值的数组,每个值指示页面正文中相应段的长度。区段数由 preceding page segments 字段确定。

4.3 解析OGG格式的报头信息

使用ffmpeg将pcm文件进行opus编码并使用ogg文件进行封装:

ffmpeg -f s16le -ar 44100 -ac 2 -i input.pcm -c:a libopus output.ogg
  • -f s16le:指定输入格式为16位小端PCM。
  • -ar 44100:指定采样率为44100Hz。
  • -ac 2:指定音频通道数为2(立体声)。
  • -i input.pcm:指定输入文件为input.pcm
  • -c:a libopus:指定音频编解码器为Opus。
  • output.ogg:指定输出文件为output.ogg

下面的程序将会读取OGG文件的前5页,并打印前5页的报头信息。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define ReadframNum 5

// 将8字节数组转换为unsigned long long
unsigned long long convertToULL(unsigned char num[8], int len) {
    unsigned long long result = 0;
    if (len == 8) {
        for (int i = 0; i < len; i++) {
            result |= ((unsigned long long)num[i] << (i * 8));
        }
    }
    return result;
}

// 将4字节数组转换为unsigned int
unsigned int convertToUInt(unsigned char num[4], int len) {
    unsigned int result = 0;
    if (len == 4) {
        for (int i = 0; i < len; i++) {
            result |= ((unsigned int)num[i] << (i * 8));
        }
    }
    return result;
}

// 读取OGG文件的页面头部信息
int readOggPageHeaders(const char *oggFile) {
    // 定义页面头部结构
    typedef struct {
        char capturePattern[4];          // 捕获模式
        unsigned char version;           // 版本
        unsigned char headerType;        // 头部类型
        unsigned char granulePosition[8];// 粒度位置
        unsigned char serialNumber[4];   // 比特流序列号
        unsigned char pageSequence[4];   // 页面序列号
        unsigned char checksum[4];       // 校验和
        unsigned char segmentCount;      // 页面段数
        unsigned char segmentTable[];    // 段表
    } OggPageHeader;

    // 打开OGG文件
    FILE *file = fopen(oggFile, "rb");
    if (!file) {
        perror("Failed to open file");
        return 1;
    }

    int frameCount = 0;
    // 循环读取前5帧的页面头部信息
    while (!feof(file) && frameCount < ReadframNum) {
        // 读取页面头部
        OggPageHeader pageHeader;
        if (1 != fread(&pageHeader, sizeof(pageHeader), 1, file))
            break;

        // 打印页面头部信息
        printf("Capture pattern: %c%c%c%c\n", pageHeader.capturePattern[0], pageHeader.capturePattern[1], pageHeader.capturePattern[2], pageHeader.capturePattern[3]);
        printf("Version: %d\n", pageHeader.version);
        printf("Header Type: %d\n", pageHeader.headerType);
        printf("Granule position: %llu\n", convertToULL(pageHeader.granulePosition, 8));
        printf("Bitstream serial number: %u\n", convertToUInt(pageHeader.serialNumber, 4));
        printf("Page sequence number: %u\n", convertToUInt(pageHeader.pageSequence, 4));
        printf("Checksum: %u\n", convertToUInt(pageHeader.checksum, 4));
        printf("Page Segments: %d\n", pageHeader.segmentCount);

        // 读取段表
        unsigned char *segmentTable = (unsigned char *)malloc(pageHeader.segmentCount);
        fread(segmentTable, sizeof(unsigned char), pageHeader.segmentCount, file);

        // 打印段表
        printf("Segment table: ");
        for (int i = 0; i < pageHeader.segmentCount; i++) {
            printf("%d ", segmentTable[i]);
        }
        printf("\n");

        // 计算段数据总大小
        unsigned int totalSegmentSize = 0;
        for (int i = 0; i < pageHeader.segmentCount; i++) {
            totalSegmentSize += segmentTable[i];
        }
        printf("Total Segment Size: %d\n", totalSegmentSize);

        // 读取段数据
        unsigned char *segmentData = (unsigned char *)malloc(totalSegmentSize);
        fread(segmentData, sizeof(unsigned char), totalSegmentSize, file);

        // 如果头部类型标志为4,打印最后4个字节
        if (pageHeader.headerType == 4)
            printf("Last 4 Bytes: %x %x %x %x\n", segmentData[totalSegmentSize - 4], segmentData[totalSegmentSize - 3], segmentData[totalSegmentSize - 2], segmentData[totalSegmentSize - 1]);

        // 释放内存
        free(segmentData);
        free(segmentTable);

        frameCount++;
    }

    // 关闭文件
    fclose(file);
    return 0;
}

int main(int argc, char *argv[]) {
    // 检查命令行参数
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <input.ogg>\n", argv[0]);
        return 1;
    }

    // 读取OGG文件的页面头部信息
    return readOggPageHeaders(argv[1]);
}

5.FLAC文件格式

参考资料:

FLAC,全称为 Free Lossless Audio Codec,是一种无损音频压缩格式。它的主要特点是能够在保持原始音频质量的同时大幅减少文件大小,通常可以达到原始文件大小的 50% 至 70% 左右。

其编码算法流程:

  1. 输入音频被分割成块。如果音频包含多个声道,则每个声道将单独编码为一个子块。
  2. 编码器尝试通过拟合简单的多项式或通过一般线性预测编码来找到块的良好数学近似值。然后编写近似值的描述,其长度仅为几个字节。
  3. 近似值和输入值之间的差异(称为残差)使用 Rice 编码进行编码。在许多情况下,与使用脉冲编码调制相比,近似值和编码残差的描述占用的空间更少。

编码后的音频被划分为多个帧,每个帧由一个标头、一个元数据块和一个音频编码数据和组成。每个帧都是彼此独立的编码。帧标头以同步字开头,用于标识有效帧的开头。标头的其余部分包含样本数、帧位置、通道分配以及可选的采样率和位深度。数据块包含音频信息。

文件头(File Header)
标志(Magic Number):FLAC文件的开头是一个4字节的标志“fLaC”,用于标识文件类型。

元数据块区域(Metadata Block Area)
元数据块区域包含描述FLAC音频流的信息以及一些附加信息。每个元数据块都有一个类型标识符和长度字段,以下是常见的元数据块类型:

  • 流信息块(STREAMINFO):包含整个音频流的基本信息,如采样率、声道数、总采样数等。这是FLAC文件中必须存在的第一个元数据块。
  • 填充块(PADDING):用于在文件中预留空间,以便以后添加元数据。
  • 应用程序数据块(APPLICATION):包含特定应用程序的信息。
  • 定位表块(SEEKTABLE):保存快速定位点,用于快速跳转到文件中的特定位置。
  • 标签信息块(VORBIS_COMMENT):存储一系列可读的“名/值”键值对,通常用于存储标签信息。
  • 索引表块(CUESHEET):存储用于CD刻录的索引信息。
  • 图片块(PICTURE):保存相关图片信息,如专辑封面。

音频帧区域(Audio Frame Area)
音频帧区域包含实际的音频数据,每个音频帧由帧头、子帧和帧尾组成:

  • 帧头(Frame Header):记录帧的相关信息,如同步码、采样率、声道数、采样深度等。
  • 子帧(Subframe):每个子帧对应一个声道的数据,包含子帧头和编码后的音频数据。
  • 帧尾(Frame Footer):包含CRC-16校验码,用于校验帧数据的完整性。

参考链接:FLAC - Format