0%

音频播放

音频播放

前其准备

我以思维导图的方式写了相关的原理和所需要的知识点。

https://mubu.com/app/edit/home/6T6aswkdt2I#m

代码解读

audioplay.c

  • 全局变量
1
2
3
4
5
6
7
8
bool play_initialized = false;              // 播放是否初始化完成
​static u8 *current_song = NULL; // 当前播放的歌曲路径
​static u32 last_fillnum = WAV_I2S_TX_DMA_BUFSIZE; // 上次填充的缓冲区大小
​static DIR wavdir; // 音乐目录句柄
​static u16 *wavindextbl; // WAV 文件索引表
​static u16 totwavnum; // 路径下的文件总数
​static u16 curindex; // 当前播放的歌曲索引
​static char song_name[256] = "Unknown"; // 当前歌曲名称
  • audio_start开始播放函数、audio_pause暂停播放函数、audio_resume从暂停状态到播放状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 开始音频播放
    void audio_start(void)
    {
    audiodev.status = 3 << 0; // 开始播放+非暂停
    I2S_Play_Start();
    }
    // 暂停音频播放(仅停止硬件,不释放资源)
    void audio_pause(void)
    {
    audiodev.status &= ~(1 << 0); // 清除bit0,标记暂停
    I2S_Play_Stop(); // 停止I2S硬件播放
    }
    // 继续播放(从暂停状态恢复)
    void audio_resume(void)
    {
    audiodev.status |= (1 << 0); // 设置bit0,继续播放
    I2S_Play_Start(); // 恢复I2S硬件播放
    }
    • 结构体的内容如下:

      1
      2
      3
      4
      5
      6
      7
      8
      typedef __packed struct
      {
      u8 *i2sbuf1;
      u8 *i2sbuf2;
      u8 *tbuf;
      FIL *file;
      u8 status;
      }__audiodev;
  • audio_stop:停止播放并清理空间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    void audio_stop(void)
    {
    audiodev.status = 0;
    I2S_Play_Stop();
    play_initialized = false;
    if (current_song)
    {
    myfree(SRAMIN, current_song);
    current_song = NULL;
    }
    if (audiodev.file)
    {
    f_close(audiodev.file);
    myfree(SRAMIN, audiodev.file);
    audiodev.file = NULL;
    }
    if (wavindextbl)
    {
    myfree(SRAMIN, wavindextbl);
    wavindextbl = NULL;
    }
    strcpy(song_name, "Unknown");
    }
    • 清零status。关闭I2S。
    • 重置音乐播放标志位为false。
    • 释放申请的内存。
    • 将音乐界面的标签重置为”Unknown”。
  • audio_get_tnum:获取目录中的WAV文件总数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    u16 audio_get_tnum(u8 *path)
    {
    u8 res;
    u16 rval = 0;
    DIR tdir;
    FILINFO tfileinfo;
    u8 *fn;
    res = f_opendir(&tdir, (const TCHAR*)path);
    tfileinfo.lfsize = _MAX_LFN * 2 + 1;
    tfileinfo.lfname = mymalloc(SRAMIN, tfileinfo.lfsize);
    if (res == FR_OK && tfileinfo.lfname != NULL)
    {
    while (1)
    {
    res = f_readdir(&tdir, &tfileinfo);
    if (res != FR_OK || tfileinfo.fname[0] == 0) break;
    fn = (u8*)(*tfileinfo.lfname ? tfileinfo.lfname : tfileinfo.fname);
    res = f_typetell(fn);
    if ((res & 0XF0) == 0X40) rval++;
    }
    }
    myfree(SRAMIN, tfileinfo.lfname);
    return rval;
    }
    • 使用f_opendir打开目录。
      • f_opendir(DIR *dp, const TCHAR *path)
        • 第一参数dp:指向DIR结构体的指针,用于存储目录信息。、
        • 返回值:FR—OK表示读取成功。
    • 动态分配文件名缓冲区(lfname)。
      • _MAX_LFN * 2 + 1
        • _MAX_LFN :表示最大的文件名长度。
        • 乘2:再FATFS文件系统中LFN采用UTF-16,每个字符需要2个字节。
        • +1:表示在字符的结尾加上字符串结束符“\0”。
    • 使用f_readdir依次读取目录中的文件。
    • 使用f_typetell 判断文件类型,0x40表示WAV文件。
      • f_typetell 用于判断文件类型。返回值res用于表示文件类型。
      • 低8位:大类(音频、视频、文档等)
      • 高8位:具体类型(MP3、WAV、TXT)
    • 返回WAV文件总数rval。
  • audio_play_init:初始化播放器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    u8 audio_play_init(void)
    {
    // 配置 WM8978 音频编解码器
    WM8978_ADDA_Cfg(1, 0); // 使能 DAC,关闭 ADC
    WM8978_Input_Cfg(0, 0, 0); // 关闭输入路径
    WM8978_Output_Cfg(1, 0); // 使能 DAC 输出

    // 打开音乐目录
    while (f_opendir(&wavdir, "0:/MUSIC")) { ... }

    // 获取 WAV 文件总数
    totwavnum = audio_get_tnum("0:/MUSIC");
    if (totwavnum == 0) { ... }

    // 分配内存
    wavfileinfo.lfsize = _MAX_LFN * 2 + 1;
    wavfileinfo.lfname = mymalloc(SRAMIN, wavfileinfo.lfsize);
    current_song = mymalloc(SRAMIN, wavfileinfo.lfsize);
    wavindextbl = mymalloc(SRAMIN, 2 * totwavnum);

    // 构建 WAV 文件索引表
    res = f_opendir(&wavdir, "0:/MUSIC");
    if (res == FR_OK)
    {
    curindex = 0;
    while (1)
    {
    u16 temp = wavdir.index;
    res = f_readdir(&wavdir, &wavfileinfo);
    if (res != FR_OK || wavfileinfo.fname[0] == 0) break;
    fn = (u8*)(*wavfileinfo.lfname ? wavfileinfo.lfname : wavfileinfo.fname);
    res = f_typetell(fn);
    if ((res & 0XF0) == 0X40)
    {
    wavindextbl[curindex] = temp;
    curindex++;
    }
    }
    }
    myfree(SRAMIN, wavfileinfo.lfname);
    curindex = 0; // 从第一首开始
    return audio_play_next();
    }
    • 配置WM8978:启用DAC输出,关闭输入和ADC。

      • 使能DAC,即打开音频播放功能。
      • 关闭ADC,不使用音频录制。
      • 输入数据:关闭了ADC,音频数据不会从MIC或者LIN IN进入,而是通过I2S总线输入。
      • 使能DAC输出,时音频信号能够经过耳机或者喇叭输出。
    • 打开0:/MUSIC目录并统计WAV文件数。

    • 分配内存:歌曲路径长文件名缓冲区和索引表

    • 构建索引表:记录每个WAV文件的目录索引

      • temp:存储这个当前遍历到的文件在目录中索引(从1开始计数)

      • 例如:0:/MUSIC目录下有四个文件,分别为one.wav、two.text、screen.wav、four.mp3。

      • wavdir.index (temp) 文件名 res = f_typetell(fn) (res & 0XF0) == 0X40 wavindextbl[curindex] curindex
        1 one.wav 0x41 ✅(是 WAV) wavindextbl[0] = 1 1
        2 two.txt 0x30 ❌(不是 WAV) 不存入数组 1
        3 screen.wav 0x41 ✅(是 WAV) wavindextbl[1] = 3 2
        4 four.mp3 0x50 ❌(不是 WAV) 不存入数组 2
    • 调用audio_play_next函数,播放第一首歌。

  • audio_play_next:播放下一首

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    u8 audio_play_next(void)
    {
    // 如果已有文件打开,先关闭
    if (audiodev.file) { f_close(audiodev.file); myfree(SRAMIN, audiodev.file); audiodev.file = NULL; }

    // 循环播放
    if (curindex >= totwavnum) curindex = 0;

    // 获取下一首歌信息
    res = f_opendir(&wavdir, "0:/MUSIC");
    dir_sdi(&wavdir, wavindextbl[curindex]);
    res = f_readdir(&wavdir, &wavfileinfo);

    //构建完整的路径
    strcpy((char*)current_song, "0:/MUSIC/");
    strcat((char*)current_song, (const char*)fn);
    strncpy(song_name, (char*)fn, sizeof(song_name) - 1);

    // 初始化 WAV 解码
    res = wav_decode_init(current_song, &wavctrl);
    if (res == 0)
    {
    // 配置 I2S 根据 WAV 参数
    if (wavctrl.bps == 16) { ... }
    else if (wavctrl.bps == 24) { ... }
    I2S2_SampleRate_Set(wavctrl.samplerate);
    I2S2_TX_DMA_Init(audiodev.i2sbuf1, audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE / 2);
    i2s_tx_callback = wav_i2s_dma_tx_callback;

    // 打开文件并填充缓冲区
    audiodev.file = mymalloc(SRAMIN, sizeof(FIL));
    res = f_open(audiodev.file, (TCHAR*)current_song, FA_READ);
    f_lseek(audiodev.file, wavctrl.datastart);
    last_fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
    last_fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
    play_initialized = true;
    audio_start();
    curindex++;
    }
    return res;
    }
    • 关闭当前文件
    • 检查索引,并实现循环播放。
    • 打开目录,定位到下一首歌,构建完整的路劲,并提取歌曲名称。
      • dir_sdi():用于设置目录读取索引,相当于告诉f_readdir()从某个索引位置开始读取。这样就能直接跳到指定的 WAV 文件,而不是重新遍历整个目录。
      • 构建完整的路径:
        • strcpy((char*)current_song, “0:/MUSIC/“):将字符串”0:/MUSIC/“复制到current_song。
        • strcat((char*)current_song, (const char*)fn);:将fn凭借到current_song的末尾。
        • strncpy(song_name, (char*)fn, sizeof(song_name) - 1);:将fn复制到song_name,去除\0。
    • 调用wav_decode_init 解析WAV头文件,获取参数(采样率、位深、通道数等)
      • i2s_tx_callback = wav_i2s_dma_tx_callback;:当 I2S 通过 DMA 发送完一部分数据时,会触发 wav_i2s_dma_tx_callback
    • 配置I2S和DMA,根据WAV文件的位深设置格式。
      • f_lseek(audiodev.file, wavctrl.datastart);:
        • f_lseek() 用于移动文件指针
        • wavctrl.datastart 代表WAV 音频数据起始地址
      • last_fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
        last_fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
        • wav_buffill() 用于填充 I2S DMA 缓冲区,确保音频数据连续传输。
    • 更行curindex索引,为下一首歌做准备。
  • audio_play_prev:播放上一首歌

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    u8 audio_seek(u32 position)
    {
    if (!play_initialized || !audiodev.file) return 1;

    // 计算目标文件位置
    u32 file_pos = wavctrl.datastart + (position * (wavctrl.datasize / 100));
    if (file_pos > (wavctrl.datastart + wavctrl.datasize))
    file_pos = wavctrl.datastart + wavctrl.datasize;

    // 暂停播放
    audio_pause();

    // 调整文件指针
    f_lseek(audiodev.file, file_pos);
    wavctrl.cursec = (position * wavctrl.totsec) / 100;

    // 重新填充缓冲区
    last_fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
    last_fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);

    // 恢复播放
    audio_resume();

    return 0;
    }

    // 非阻塞播放任务
    void audio_play_task(void)
    {
    if (play_initialized && (audiodev.status & (1 << 1))) // 检查是否在播放状态
    {
    if (wavtransferend)
    {
    wavtransferend = 0;
    if (last_fillnum != WAV_I2S_TX_DMA_BUFSIZE)
    {
    audio_pause(); // 先暂停当前播放
    if (audio_play_next() != 0) // 切换到下一首
    {
    audio_stop();
    printf("Music list finished\n");
    }
    }
    else
    {
    if (wavwitchbuf)
    last_fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
    else
    last_fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
    wav_get_curtime(audiodev.file, &wavctrl);
    }
    }
    }
    }
    • 与 audio_play_next() 类似,但将 curindex 减 1,并在边界时循环到最后一首。
  • audio_play_task:非阻塞播放任务

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    void audio_play_task(void)
    {
    if (play_initialized && (audiodev.status & (1 << 1)))
    {
    if (wavtransferend)
    {
    wavtransferend = 0;
    if (last_fillnum != WAV_I2S_TX_DMA_BUFSIZE)
    {
    audio_pause();
    if (audio_play_next() != 0) audio_stop();
    }
    else
    {
    if (wavwitchbuf)
    last_fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
    else
    last_fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
    wav_get_curtime(audiodev.file, &wavctrl);
    }
    }
    }
    }
    • 检查播放状态和DMA传输完成标志。
      • 只有 播放初始化完成当前处于播放状态,任务才会继续执行。
    • 如果缓冲区未满,切换下一首。
      • last_fillnum != WAV_I2S_TX_DMA_BUFSIZE
        • 如果上一次的缓冲区未满,则当前的肯定为0,则表示读取结束,开始下一首。
    • 否则,根据当前缓冲区填充数据,并更新播放时间。

wavplay.c

  • 全局变量

    1
    2
    3
    __wavctrl wavctrl;       // WAV 文件控制结构体
    vu8 wavtransferend = 0; // I2S DMA 传输完成标志
    vu8 wavwitchbuf = 0; // 当前处理的缓冲区指示(0 = i2sbuf1,1 = i2sbuf2)
  • wav_decode_init:初始化WAV文件解析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    u8 wav_decode_init(u8* fname, __wavctrl* wavx)
    {
    FIL* ftemp;
    u8 *buf;
    u32 br = 0;
    u8 res = 0;

    ChunkRIFF *riff;
    ChunkFMT *fmt;
    ChunkFACT *fact;
    ChunkDATA *data;

    ftemp = (FIL*)mymalloc(SRAMIN, sizeof(FIL));
    buf = mymalloc(SRAMIN, 512);
    if (ftemp && buf) // 内存分配成功
    {
    res = f_open(ftemp, (TCHAR*)fname, FA_READ); // 打开文件
    if (res == FR_OK)
    {
    f_read(ftemp, buf, 512, &br); // 读取前 512 字节
    riff = (ChunkRIFF *)buf; // RIFF 块
    if (riff->Format == 0X45564157) // "WAVE" (WAV 文件标识)
    {
    fmt = (ChunkFMT *)(buf + 12); // FMT 块
    fact = (ChunkFACT *)(buf + 12 + 8 + fmt->ChunkSize); // FACT 块
    if (fact->ChunkID == 0X74636166 || fact->ChunkID == 0X5453494C) // "fact" 或 "LIST"
    wavx->datastart = 12 + 8 + fmt->ChunkSize + 8 + fact->ChunkSize;
    else
    wavx->datastart = 12 + 8 + fmt->ChunkSize; // 无 FACT/LIST 块
    data = (ChunkDATA *)(buf + wavx->datastart); // DATA 块
    if (data->ChunkID == 0X61746164) // "data"
    {
    // 提取 WAV 文件信息
    wavx->audioformat = fmt->AudioFormat; // 音频格式 (1 = PCM)
    wavx->nchannels = fmt->NumOfChannels; // 通道数
    wavx->samplerate = fmt->SampleRate; // 采样率
    wavx->bitrate = fmt->ByteRate * 8; // 位速 (bps)
    wavx->blockalign = fmt->BlockAlign; // 块对齐
    wavx->bps = fmt->BitsPerSample; // 位深 (16/24/32)
    wavx->datasize = data->ChunkSize; // 数据大小
    wavx->datastart = wavx->datastart + 8; // 数据起始位置

    // 调试输出
    printf("wavx->audioformat:%d\r\n", wavx->audioformat);
    printf("wavx->nchannels:%d\r\n", wavx->nchannels);
    printf("wavx->samplerate:%d\r\n", wavx->samplerate);
    printf("wavx->bitrate:%d\r\n", wavx->bitrate);
    printf("wavx->blockalign:%d\r\n", wavx->blockalign);
    printf("wavx->bps:%d\r\n", wavx->bps);
    printf("wavx->datasize:%d\r\n", wavx->datasize);
    printf("wavx->datastart:%d\r\n", wavx->datastart);
    } else res = 3; // 未找到 DATA 块
    } else res = 2; // 非 WAV 文件
    } else res = 1; // 打开文件失败
    }
    f_close(ftemp);
    myfree(SRAMIN, ftemp);
    myfree(SRAMIN, buf);
    return res;
    }
    • 分配内存:文件对象ftmp和缓冲区buf。
    • 打开文件并读取前512字节。
    • 检查RIFF块,确认文件格式为WAVE。
    • 解析FMT块,提取音乐格式、通道数、采样率等信息。
    • 检查是否FACT或者LIST块,并计算数据的起始位置datastart。
    • 解析DATA块,获取数据大小,并更新datastart。
  • wav_buffill:填充音频缓冲区

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    u32 wav_buffill(u8 *buf, u16 size, u8 bits)
    {
    u16 readlen = 0;
    u32 bread;
    u16 i;
    u8 *p;
    if (bits == 24) // 24 位音频
    {
    readlen = (size / 4) * 3; // 计算读取字节数
    f_read(audiodev.file, audiodev.tbuf, readlen, (UINT*)&bread);
    p = audiodev.tbuf;
    for (i = 0; i < size;)
    {
    buf[i++] = p[1]; // 中间字节
    buf[i] = p[2]; // 高字节
    i += 2;
    buf[i++] = p[0]; // 低字节
    p += 3;
    }
    bread = (bread * 4) / 3; // 调整填充后的大小
    } else { // 16 位音频
    f_read(audiodev.file, buf, size, (UINT*)&bread);
    if (bread < size) // 数据不足,补 0
    for (i = bread; i < size - bread; i++) buf[i] = 0;
    }
    return bread;
    }
    • 参数
      • buf:目标缓冲区
      • size:要填充的字节数
      • bits:音频位深
    • 24位:没三个字节数据拓展为4字节,调整字节顺序
      • 在I2S传输时要么传输16位,要么传输32位数据。24位数据要补充一个字节的数据。
      • 不能补充在最低位,也不能补充在最高位。最低为决定了信号的精度、最高决定以了信号的振幅,也就是音量。所以补充在中间。
    • 16位:直接读取数据,不足为0.
  • wav_i2s_dma_tx_callback:DMA回调和时间计算

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    void wav_i2s_dma_tx_callback(void)
    {
    u16 i;
    if (DMA1_Stream4->CR & (1 << 19)) // DMA 传输 i2sbuf1
    {
    wavwitchbuf = 0;
    if ((audiodev.status & 0X01) == 0) // 暂停状态
    for (i = 0; i < WAV_I2S_TX_DMA_BUFSIZE; i++) audiodev.i2sbuf1[i] = 0; // 填充 0
    } else { // DMA 传输 i2sbuf2
    wavwitchbuf = 1;
    if ((audiodev.status & 0X01) == 0) // 暂停状态
    for (i = 0; i < WAV_I2S_TX_DMA_BUFSIZE; i++) audiodev.i2sbuf2[i] = 0; // 填充 0
    }
    wavtransferend = 1; // 标记传输完成
    }
    • 功能:I2S DMA 传输完成时的回调函数。
    • 检查DMA流状态,判断当前传输的是buf1还是buf2.
    • 更新wavwitchbuf 指示标志。
    • 如果处于暂停状态,填充缓冲区为0,静音。
    • 设置wavwitchbuf = 1,通知传输完成。
  • wav_get_curtime:计算当前播放时间

    1
    2
    3
    4
    5
    6
    7
    void wav_get_curtime(FIL* fx, __wavctrl *wavx)
    {
    long long fpos;
    wavx->totsec = wavx->datasize / (wavx->bitrate / 8); // 总时长 (秒)
    fpos = fx->fptr - wavx->datastart; // 当前位置
    wavx->cursec = fpos * wavx->totsec / wavx->datasize; // 当前播放时间 (秒)
    }
    • 计算:总时常 = 数据大小 / 字节速率。
      • 字节速率bitrate = 采样率 * 通道数 * 位深。单位(每秒初始多少位)。
      • 除8:转化为每秒传输多少字节。
    • 当前时间 = (当前偏移量 / 数据大小) * 总时长。

MP3解码

MP3格式音乐文件普遍存在我们的生活中,实际上MP3本身就是一种音频编码格式,全称为Moving Picture Experts Group Audio Layer III(MPEG Audio Layer 3)。

MP3的主要目标是通过压缩音频数据,在尽量保持音质的情况下大幅减少文件大小,从而方便存储和传输。MPEG音频文件时MPEG标准中声音部分,根据压缩质量和编码复杂程度划分为三层,即Layer-1、Layer2、Layer3,且分别对应MP1、MP2、MP3这三种声音文件,其中MP3压缩率为10:1至12:1,可以大大减少文件占用的存储空间大小。MPEG音频编码的层次越高,编码器越复杂,压缩率越高。

Mp3时利用人耳对高频声音信号的不敏感的特性,将时域波形信号转化为频率信号,并划分为多个频段,对不同频段使用不同的压缩率,加大高频的压缩比,对低频使用小压缩比,保证信号不失真。这样一来相当于抛弃人儿基本听不到的高频声音,只保留能听到的低频部分,这样可得到很高的压缩率。

简单来说,MP3就像一个聪明的“音频裁缝”,它知道哪些部分可以“剪掉”而不影响你听歌的体验。

MP3文件结构

MP3文件大致分为3个部分:TAG-V2(ID3V2),音频数据,TAG-v1(ID3V1).

ID3时MP3文件中附加的关于MP3的歌手信息,标题、专辑名称、年代、风格等信息,有两个版本,ID3V2和ID3V1.

ID3V1固定存放在MP3的尾部,固定字节长度为128字节,以TAG三个字符开头,后面跟上歌曲的信息。因为ID3V1长度有限,所以多添加了一个ID3V2,如果存在则在MP3的开头部分。它实际就是ID3V1的补充。

ID3V2

ID3V2以灵活管理的方法MP3文件附件信息,比ID3V1可以存储很多的信息,同时也比ID3V1在结构上更复杂。

常用是ID3V2.3版本, 一个MP3文件最多就一个ID3V2.3标签。ID3V2.3标签由一个标签头和若干个标签帧或一个扩展标签头组成。关于曲目的信息如标题、 作者等都存放在不同的标签帧中,扩展标签头和标签帧并不是必要的,但每个标签至少要有一个标签帧。标签头和标签帧一起顺序存放在MP3文件的首部。

标签头

其标签大小计算如下:

Total_size=(Size[0]&0x7F)*0x200000+(Size[1]&0x7F)*0x400+(Size[2]&0x7F)*0x80+(Size[3]&0x7F)

每个标签帧都有一个10个字节的帧头和至少一个字节的不定长度内容,在文件中是连续存放的。标签帧头由三个部分组成,即Frame[4]、Size[4]和Flags[2]。 Frame是用四个字符表示帧内容含义,比如TIT2为标题、TPE1为作者、TALB为专辑、TRCK为音轨、TYER为年代等等信息。 Size用四个字节组成32bit数表示帧大小。Flags是标签帧的标志位,一般为0即可。

  • 位置:文件开头
  • 大小:可变化
  • 结构:
    • 10字节头部:
      • 3字节:ID3
      • 2字节:版本
      • 1字节:标志
      • 4字节:大小
    • 多个帧:如标题、艺术家、封面图片。
ID3V1

ID3V1是早期的版本,可以存放的信息有限,但是编程比ID3V2简单。ID3V1是固定在MP3文件末尾的128字节。

ID3V1结构

其中,歌名是固定分配30个字节,如果歌名太短则以0填充完整,太长则被截断,其他信息存储类似。MP3的音乐类别总共147种,每一种对应着一个数组,比如0对应“Blues”、1对应“Classic Rock”、2对应“Country”等等。

  • 位置:文件末尾
  • 大小:固定128字节
  • 结构:
    • 3字节标识:TAG
    • 30字节:标题
    • 30字节:作家
    • 30字节:专辑
    • 4字节:年份
    • 30字节:注释
    • 1字节:流派
MP3数据帧

音频数据是MP3文件的主体部分,它由一系列数据帧组成,每个数据帧包含一段音频的压缩数据,通过解码库解码即可得到对应的PCM音频数据,就可以通过I2S发送给WM8978芯片播放音乐,按照顺序解码所有的数据帧就可得到整个MP3文件的音轨。

每个数据帧由两个部分组成,帧头和数据实体,数据帧的长度可能不同,由位率决定。

  • 帧头记录着MP3数据帧的位率、采样率、版本等等信息。总共四个字节。

  • 【可选】CRC:2字节,用于错误检测。

  • 边信息:存储解码所需要的辅助信息。

  • 主数据:压缩后的音频数据。

数据帧头结构

位率在不同的版本和层都有不同的定义,具体的参考表中的数据,单位kbps。其中V1对应MPEG-1,V2对应MPEG-2和MPEG-2.5;L1对应Layer1, L2对应Layer2,L3对应Layer3,free表示位率可变,bad表示该定义不合法。

位率选择

例如,当Version=11,Layer=01,Bitrate_incdex=0101时,描述为MPEG-1 Layer3(MP3)位率为64kbps。

数据帧长度取决于位率(Bitrate)和采样频率(Sampling_freq),

公式为:帧大小(字节) = (样本数 × 比特率 ÷ 采样率 ÷ 8) + 填充字节

具体计算如下:

对于MPEG-1标准,Layer1时:

Size=(48000*Bitrate)/Sampling_freq + Padding;

Layer2或Layer3时:

Size=(144000*Bitrate)/Sampling_freq + Padding;

对于MPEG-2或MPEG-2.5标准,Layer1时:

Size=(24000*Bitrate)/Sampling_freq + Padding;

Layer2或Layer3时:

Size=(72000*Bitrate)/Sampling_freq + Padding;

如果有CRC校验,则存放在在帧头后两个字节。接下来就是帧主数据(MAIN_DATA)。一般来说一个MP3文件每个帧的Bitrate是固定不变的, 所以每个帧都有相同的长度,称为CBR,还有小部分MP3文件的Bitrate是可变,称之为VBR,它是XING公司推出的算法。

MAIN_DATA保存的是经过压缩算法压缩后得到的数据,为得到大的压缩率会损失部分源声音,属于失真压缩。

MP3解码库

MP3文件是经过压缩算法压缩而存在的,为得到PCM信号,需要对MP3文件进行解码,解码过程大致为:比特流分析、霍夫曼编码、逆量化处理、立体声处理、 频谱重排列、抗锯齿处理、IMDCT变换、子带合成、PCM输出。整个过程涉及很多算法计算,要自己编程实现不是一件现实的事情,还好有很多公司经过长期努力实现了解码库编程。

现在合适在小型嵌入式控制器移植运行的有两个版本的开源MP3解码库,分别为Libmad解码库和Helix解码库,Libmad是一个高精度MPEG音频解码库, 支持MPEG-1、MPEG-2以及MPEG-2.5标准,它可以提供24bitPCM输出,完全是定点计算,更多信息可参考网站:http://www.underbit.com/。

Helix解码库支持浮点和定点计算实现,将该算法移植到STM32控制器运行使用定点计算实现,它支持MPEG-1、MPEG-2以及MPEG-2.5标准的Layer3解码。 Helix解码库支持可变位速率、恒定位速率,以及立体声和单声道音频格式。更多信息可参考网站:https://datatype.helixcommunity.org/Mp3dec。

因为Helix解码库需要占用的资源比Libmad解码库更少,特别是RAM空间的使用,这对STM32控制器来说是比较重要的, 所以在实验工程中我们选择Helix解码库实现MP3文件解码。这两个解码库都是一帧为解码单位的,一次解码一帧,这在应用解码库时是需要着重注意的。

Helix解码库涉及算法计算,整个界面过程复杂,有兴趣可以深入探究,这里我们着重讲解Helix移植和使用方法。

Helix网站有提供解码库代码,经过整理,移植Helix解码库需要用到的的文件如图 Helix解码库文件结构 。有优化解码速度,部分解码过程使用汇编实现。

Helix解码库文件结构

Helix解码库移植

现在我们可以移植Helix解码库工程中,实现MP3文件解码,将解码输出的PCM数据通过I2S接口发送到WM8978芯片实现音乐播放。

首先将需要用到的文件添加到工程中, 如图 添加Helix解码库文件到工程 。MP3文件夹下文件是Helix解码库源码,工程移植中是不需要修改文件夹下代码的, 我们只需直接调用相关解码函数即可。建议自己移植时直接使用例程中mp3文件夹内文件。 我们是在mp3Player.c文件中调用Helix解码库相关函数实现MP3文件解码的,该文件是我们自己创建的。

添加Helix解码库文件到工程

功能实际上就是从SD卡内读取WAV格式文件数据,然后提取里边音频数据通过I2S传输到WM8978芯片内实现声音播放。 MP3播放器的功能也是类似的,只不过现在音频数据提取方法不同,MP3需要先经过解码库解码后才可得到“可直接”播放的音频数据。由此可以看到, MP3播放器只是添加了MP3解码库实现代码,在硬件设计上并没有任何改变,即这里直接使用“录音与回放实验”中硬件设计即可。

解码过程可能用到的Helix解码库函数有:

  • MP3InitDecoder:初始化解码器函数
  • MP3FreeDecoder:关闭解码器函数
  • MP3FindSyncWord:寻找帧同步函数
  • MP3Decode:解码MP3帧函数
  • MP3GetLastFrameInfo:获取帧信息函数

MP3InitDecoder函数初始化解码器,它会申请分配一个存储空间用于存放解码器状态的一个数据结构并将其初始化,该数据结构由MP3DecInfo结构体定义, 它封装了解码器内部运算数据信息。MP3InitDecoder函数会返回指向该数据结构的指针。

MP3FreeDecoder函数用于关闭解码器,释放由MP3InitDecoder函数申请的存储空间,所以一个MP3InitDecoder函数都需要有一个MP3FreeDecoder函数与之对应。 它有一个形参,一般由MP3InitDecoder函数的返回指针赋值。

MP3FindSyncWord函数用于寻址数据帧同步信息,实际上就是寻址数据帧开始的11bit都为“1”的同步信息。它有两个形参,第一个为源数据缓冲区指针,第二个为缓冲区大小, 它会返回一个int类型变量,用于指示同步字较缓冲区起始地址的偏移量,如果在缓冲区中找不到同步字,则直接返回-1。

MP3Decode函数用于解码数据帧,它有五个形参,第一个为解码器数据结构指针,一般由MP3InitDecoder函数返回值赋值;第二个参数为指向解码源数据缓冲区开始地址的一个指针, 注意这里是地址的指针,即是指针的指针;第三个参数是一个指向存放解码源数据缓冲区有效数据量的变量指针;第四个参数是解码后输出PCM数据的指针, 一般由我们定义的缓冲区地址赋值,对于双声道输出数据缓冲区以LRLRLR…顺序排列;第五个参数是数据格式选择,一般设置为0表示标准的MPEG格式。函数还有一个返回值, 用于返回解码错误,返回ERR_MP3_NONE说明解码正常。

MP3GetLastFrameInfo函数用于获取数据帧信息,它有两个形参,第一个为解码器数据结构指针,一般由MP3InitDecoder函数返回值赋值; 第二个参数为数据帧信息结构体指针,该结构体定义见 代码清单:I2S-25

1
2
3
4
5
6
7
8
9
typedef struct _MP3FrameInfo {
int bitrate; //位率
int nChans; //声道数
int samprate; //采样率
int bitsPerSample; //采样位数
int outputSamps; //PCM数据数
int layer; //层级
int version; //版本
} MP3FrameInfo;

该结构体成员包括了该数据帧的位率、声道、采样频率等等信息,它实际上是从数据帧的帧头信息中提取的。

MP3示例

用Yesterday Once More这首歌为示例,来重新学习一下MP3文件结构。

  • MP3文件主要由下面几个主要的组成部分组成

    • ID3V2标签:存储歌曲的元数据
    • 音频帧序列:压缩后的音频数据、核心内容
    • 其他元数据:如 Xing 头(用于 VBR 文件)。
  • 对于Yesterday Once More这首歌的MP3文件,文件结构大致如下

    1
    [ID3v2 标签] + [音频帧序列] + [ID3v1 标签]

1、ID3V2标签(文件开头)

ID3V2标签通常位于文件开头,存储着歌曲的元数据,假设文件内容如下:

  • 标题:Yesterday Once More
  • 艺术家:The Carpenters
  • 专辑:Now & Then
  • 年份:1973

ID3V2结构由10个字节的帧头,和多个帧组成。

  • 10个字节的头部

    1
    49 44 33 03 00 00 00 00 07 D0
    • 49 44 33:标识ID3
    • 03 00:版本号
    • 00:标志位
    • 00 00 07 D0 :标签大小(7位编码,实际大小2000字节)
    • TIT2(标题)

      1
      54 49 54 32 00 00 00 15 00 00 01 59 65 73 74 65 72 64 61 79 20 4F 6E 63 65 20 4D 6F 72 65
      • 54 49 54 32:帧ID(TIT2 = 标题)
      • 00 00 00 15:大小(21字节)
      • 00 00 标志
      • 01 :编码(UTF-16)
      • 剩下的:内容Yesterday Once More
    • TPE1(艺术家)

      1
      54 50 45 31 00 00 00 0F 00 00 00 54 68 65 20 43 61 72 70 65 6E 74 65 72 73
      • 54 50 45 31:帧ID(TPE1 = 艺术家)
      • 00 00 00 0F:大小15字节
      • 00 00 :标志
      • 00 : 编码(ISO-8859-1)
      • 54 68 65 …:内容The Carpenters
    • 类似的还有 TALB(专辑)、TYER(年份)等帧。假设 ID3v2 总大小约为 2000 字节。

音频帧序列(文件主体)

音频帧序列是 MP3 文件的核心,包含压缩后的音频数据。以 128 kbps、44.1 kHz、立体声为例,每帧的特性如下:

  • 帧时长:1152个样本 / 44.1khz = 26.12毫秒。
  • 帧大小:(1152 * 128000 / 44100 /8) = 417字节
  • 总帧数:240 / 0.02612秒/帧 = 9195帧
  • 音频数据大小:417字节 * 9195 = 3。83MB

单帧结构由以下部分组成:

  • 帧头:4字节

    1
    FF FB 90 00
    • FF FB
      • 11111111 1111(11个1):同步字,用来辨别的
      • 10:MPEG-1.
      • 11:Layer-3
      • 1:无CRC
    • 90
      • 1001:比特率(128kbps)
      • 00 :采样率索引(44.1khz)
      • 0:无填充
      • 0:私有位
    • 00
      • 00:立体声
      • 00:模式拓展
      • 0:无版权
      • 0:非原始
      • 00:误强调
  • CRC【可选】:2字节

  • 边信息:32字节

    • 边信息存储解码参数,比如:
      • 比特池指针:知识主数据位置
      • 量化表选择:用于反量化
      • 子带分配:比特分配给32个子带
    1
    01 00 02 ... (32 字节)
    • 具体的由编码器生成,由解码时解析
  • 主数据:压缩音频数据。(381字节)

    • 主数据时经过霍夫曼编码的频率系数,长度未:

      1
      417 - 4(帧头) - 32(边信息) = 381 字节
    • 内容可能是

      1
      4A 3B 12 ... (381 字节)

      这些数据表示Yesterday Once More开头部分的压缩音频。

ID3V1标签(文件末尾)

ID3V1是固定的128字节,假设内容如下:

1
2
3
4
5
6
54 41 47 59 65 73 74 65 72 64 61 79 20 4F 6E 63 65 20 4D 6F 72 65 00 ... [30 字节标题]
54 68 65 20 43 61 72 70 65 6E 74 65 72 73 00 ... [30 字节艺术家]
4E 6F 77 20 26 20 54 68 65 6E 00 ... [30 字节专辑]
31 39 37 33 [4 字节年份]
00 ... [30 字节注释]
03 [1 字节流派,03 = Pop]
  • 54 41 47(TAG):标识 ID3v1。

  • 字段长度固定,填充 00(空字符)。

文件整体布局

  • ID3v2:2000 字节(开头)。

  • 音频帧:3,834,315 字节(9195 × 417)。

  • ID3v1:128 字节(结尾)。

  • 总大小:2000 + 3,834,315 + 128 = 3,836,443 字节(接近 3.84 MB,误差可能是文件系统对齐)。

解码视角

播放器(比如用 Helix 解码库)读取文件时:

  1. 跳过 ID3v2:识别 49 44 33,跳过 2000 字节。
  2. 解析帧:
    • 找到 FF FB,读取帧头。
    • 解析边信息,解码主数据为 PCM(1152 个样本,约 26 毫秒)。
    • 重复 9195 次,生成 240 秒音频。
  3. 读取 ID3v1:显示歌曲信息。