05-📝音视频技术核心知识|音频播放【播放PCM、WAV、PCM转WAV、PCM转WAV、播放WAV】

1,379 阅读13分钟

一、前言

本系列文章是对音视频技术入门知识的整理和复习,为进一步深入系统研究音视频技术巩固基础。文章列表:

二、播放PCM

2.1 ffplay

可以使用ffplay播放《音频录制02_编程》中录制好的PCM文件,测试一下是否录制成功。

播放PCM需要指定相关参数:

  • ar:采样率
  • ac:声道数
  • f:采样格式
    • s16le:PCM signed 16-bit little-endian
    • 更多PCM的采样格式可以使用命令查看
      • Windows:ffmpeg -formats | findstr PCM
      • Mac:ffmpeg -formats | grep PCM
ffplay -ar 44100 -ac 2 -f s16le out.pcm

接下来演示一下,如何通过编程的方式播放PCM数据。

2.2 SDL

ffplay是基于FFmpeg、SDL两个库实现的。通过编程的方式播放音视频,也是需要用到这2个库。FFmpeg大家都已经清楚了,比较陌生的是SDL。

SDL Logo

2.2.1 简介

SDL(Simple DirectMedia Layer),是一个跨平台的C语言多媒体开发库。

  • 支持Windows、Mac OS X、Linux、iOS、Android
  • 提供对音频、键盘、鼠标、游戏操纵杆、图形硬件的底层访问
  • 很多的视频播放软件、模拟器、受欢迎的游戏都在使用它
  • 目前最新的稳定版是:2.0.14
  • API文档:wiki

2.2.2 下载

SDL官网下载地址:download-sdl2

SDL下载

2.2.2.1 Windows

由于我们使用的是MinGW编译器,所以选择下载SDL2-devel-2.0.14-mingw.tar.gz

解压后的目录结构如下图所示,跟FFmpeg的目录结构类似,因此就不再赘述每个文件夹的作用。

Windows目录结构

2.2.2.2 Mac

brew官网可以看得出来:之前执行brew install ffmpeg时,已经顺带安装了SDL,安装目录是:/usr/local/Cellar/sdl2

Mac目录结构

如果没有这个目录,就执行brew install sdl2进行安装即可。

2.3 HelloWorld

来个简单的SDL HelloWorld吧,打印一下SDL的版本号。

2.3.1 .pro文件

win32 {
    FFMPEG_HOME = F:/Dev/ffmpeg-4.3.2
    SDL_HOME = F:/Dev/SDL2-2.0.14/x86_64-w64-mingw32
}

macx {
    FFMPEG_HOME = /usr/local/Cellar/ffmpeg/4.3.2
    SDL_HOME = /usr/local/Cellar/sdl2/2.0.14_1
}

INCLUDEPATH += $${FFMPEG_HOME}/include
LIBS += -L$${FFMPEG_HOME}/lib \
        -lavdevice \
        -lavcodec \
        -lavformat \
        -lavutil

INCLUDEPATH += $${SDL_HOME}/include
LIBS += -L$${SDL_HOME}/lib \
        -lSDL2

在Windows环境中,还需要处理一下dll文件,参考:《dll文件处理》

2.3.2 cpp代码

#include <SDL2/SDL.h>

SDL_version v;
SDL_VERSION(&v);
// 2 0 14
qDebug() << v.major << v.minor << v.patch;

2.4 播放PCM

2.4.1 初始化子系统

SDL分成好多个子系统(subsystem):

  • Video:显示和窗口管理
  • Audio:音频设备管理
  • Joystick:游戏摇杆控制
  • Timers:定时器
  • ...

目前只用到了音频功能,所以只需要通过SDL_init函数初始化Audio子系统即可。

// 初始化Audio子系统
if (SDL_Init(SDL_INIT_AUDIO)) {
    // 返回值不是0,就代表失败
    qDebug() << "SDL_Init Error" << SDL_GetError();
    return;
}

2.4.2 打开音频设备

/* 一些宏定义 */
// 采样率
#define SAMPLE_RATE 44100
// 采样格式
#define SAMPLE_FORMAT AUDIO_S16LSB
// 采样大小
#define SAMPLE_SIZE SDL_AUDIO_BITSIZE(SAMPLE_FORMAT)
// 声道数
#define CHANNELS 2
// 音频缓冲区的样本数量
#define SAMPLES 1024

// 用于存储读取的音频数据和长度
typedef struct {
    int len = 0;
    int pullLen = 0;
    Uint8 *data = nullptr;
} AudioBuffer;

// 音频参数
SDL_AudioSpec spec;
// 采样率
spec.freq = SAMPLE_RATE;
// 采样格式(s16le)
spec.format = SAMPLE_FORMAT;
// 声道数
spec.channels = CHANNELS;
// 音频缓冲区的样本数量(这个值必须是2的幂)
spec.samples = SAMPLES;
// 回调
spec.callback = pull_audio_data;
// 传递给回调的参数
AudioBuffer buffer;
spec.userdata = &buffer;

// 打开音频设备
if (SDL_OpenAudio(&spec, nullptr)) {
    qDebug() << "SDL_OpenAudio Error" << SDL_GetError();
    // 清除所有初始化的子系统
    SDL_Quit();
    return;
}

2.4.3 打开文件

#define FILENAME "F:/in.pcm"

// 打开文件
QFile file(FILENAME);
if (!file.open(QFile::ReadOnly)) {
    qDebug() << "文件打开失败" << FILENAME;
    // 关闭音频设备
    SDL_CloseAudio();
    // 清除所有初始化的子系统
    SDL_Quit();
    return;
}

2.4.4 开始播放

// 每个样本占用多少个字节
#define BYTES_PER_SAMPLE ((SAMPLE_SIZE * CHANNELS) / 8)
// 文件缓冲区的大小
#define BUFFER_SIZE (SAMPLES * BYTES_PER_SAMPLE)

// 开始播放
SDL_PauseAudio(0);

// 存放文件数据
Uint8 data[BUFFER_LEN];

while (!isInterruptionRequested()) {
    // 只要从文件中读取的音频数据,还没有填充完毕,就跳过
    if (buffer.len > 0) continue;

    buffer.len = file.read((char *) data, BUFFER_SIZE);

    // 文件数据已经读取完毕
    if (buffer.len <= 0) {
        // 剩余的样本数量
        int samples = buffer.pullLen / BYTES_PER_SAMPLE;
        int ms = samples * 1000 / SAMPLE_RATE;
        SDL_Delay(ms);
        break;
    }

    // 读取到了文件数据
    buffer.data = data;
}

2.4.5 回调函数

// userdata:SDL_AudioSpec.userdata
// stream:音频缓冲区(需要将音频数据填充到这个缓冲区)
// len:音频缓冲区的大小(SDL_AudioSpec.samples * 每个样本的大小)
void pull_audio_data(void *userdata, Uint8 *stream, int len) {
    // 清空stream
    SDL_memset(stream, 0, len);

    // 取出缓冲信息
    AudioBuffer *buffer = (AudioBuffer *) userdata;
    if (buffer->len == 0) return;

    // 取len、bufferLen的最小值(为了保证数据安全,防止指针越界)
    buffer->pullLen = (len > buffer->len) ? buffer->len : len;
    
    // 填充数据
    SDL_MixAudio(stream,
                 buffer->data,
                 buffer->pullLen,
                 SDL_MIX_MAXVOLUME);
    buffer->data += buffer->pullLen;
    buffer->len -= buffer->pullLen;
}

2.4.6 释放资源

// 关闭文件
file.close();
// 关闭音频设备
SDL_CloseAudio();
// 清理所有初始化的子系统
SDL_Quit();

三、PCM转WAV

播放器是无法直接播放PCM的,因为播放器并不知道PCM的采样率、声道数、位深度等参数。当PCM转成某种特定的音频文件格式后(比如转成WAV),就能够被播放器识别播放了。

本文通过2种方式(命令行、编程)演示一下:如何将PCM转成WAV。

1. WAV文件格式

在进行PCM转WAV之前,先再来认识一下WAV的文件格式

  • WAV、AVI文件都是基于RIFF标准的文件格式
  • RIFF(Resource Interchange File Format,资源交换文件格式)由Microsoft和IBM提出
  • 所以WAV、AVI文件的最前面4个字节都是RIFF四个字符

找遍了全网,并没有找到令我十分满意的WAV文件格式图,于是按照自己的理解画了一张表格,个人觉得还是极其通俗易懂的。

WAV文件格式

每一个chunk(数据块)都由3部分组成:

  • id:chunk的标识
  • data size:chunk的数据部分大小,字节为单位
  • data,chunk的数据部分

整个WAV文件是一个RIFF chunk,它的data由3部分组成:

  • format:文件类型
  • fmt chunk
    • 音频参数相关的chunk
    • 它的data里面有采样率、声道数、位深度等参数信息
  • data chunk
    • 音频数据相关的chunk
    • 它的data就是真正的音频数据(比如PCM数据)

RIFF chunk除去data chunk的data(音频数据)后,剩下的内容可以称为:WAV文件头,一般是44字节。

四、PCM转WAV

1. 命令行

通过下面的命令可以将PCM转成WAV。

ffmpeg -ar 44100 -ac 2 -f s16le -i out.pcm out.wav

需要注意的是:上面命令生成的WAV文件头有78字节。对比44字节的文件头,它多增加了一个34字节大小的LIST chunk。

关于LIST chunk的参考资料:

加上一个输出文件参数*-bitexact*可以去掉LIST Chunk。

ffmpeg -ar 44100 -ac 2 -f s16le -i out.pcm -bitexact out2.wav

2. 编程

在PCM数据的前面插入一个44字节的WAV文件头,就可以将PCM转成WAV。

2.1 WAV的文件头结构

WAV的文件头结构大概如下所示:

#define AUDIO_FORMAT_PCM 1
#define AUDIO_FORMAT_FLOAT 3

// WAV文件头(44字节)
typedef struct {
    // RIFF chunk的id
    uint8_t riffChunkId[4] = {'R', 'I', 'F', 'F'};
    // RIFF chunk的data大小,即文件总长度减去8字节
    uint32_t riffChunkDataSize;

    // "WAVE"
    uint8_t format[4] = {'W', 'A', 'V', 'E'};

    /* fmt chunk */
    // fmt chunk的id
    uint8_t fmtChunkId[4] = {'f', 'm', 't', ' '};
    // fmt chunk的data大小:存储PCM数据时,是16
    uint32_t fmtChunkDataSize = 16;
    // 音频编码,1表示PCM,3表示Floating Point
    uint16_t audioFormat = AUDIO_FORMAT_PCM;
    // 声道数
    uint16_t numChannels;
    // 采样率
    uint32_t sampleRate;
    // 字节率 = sampleRate * blockAlign
    uint32_t byteRate;
    // 一个样本的字节数 = bitsPerSample * numChannels >> 3
    uint16_t blockAlign;
    // 位深度
    uint16_t bitsPerSample;

    /* data chunk */
    // data chunk的id
    uint8_t dataChunkId[4] = {'d', 'a', 't', 'a'};
    // data chunk的data大小:音频数据的总长度,即文件总长度减去文件头的长度(一般是44)
    uint32_t dataChunkDataSize;
} WAVHeader;

2.2 PCM转WAV核心实现

封装到了FFmpegs类的pcm2wav函数中。

#include <QFile>
#include <QDebug>

class FFmpegs {
public:
    FFmpegs();
    static void pcm2wav(WAVHeader &header,
                        const char *pcmFilename,
                        const char *wavFilename);
};

void FFmpegs::pcm2wav(WAVHeader &header,
                      const char *pcmFilename,
                      const char *wavFilename) {
    header.blockAlign = header.bitsPerSample * header.numChannels >> 3;
    header.byteRate = header.sampleRate * header.blockAlign;

    // 打开pcm文件
    QFile pcmFile(pcmFilename);
    if (!pcmFile.open(QFile::ReadOnly)) {
        qDebug() << "文件打开失败" << pcmFilename;
        return;
    }
    header.dataChunkDataSize = pcmFile.size();
    header.riffChunkDataSize = header.dataChunkDataSize
                               + sizeof (WAVHeader) - 8;

    // 打开wav文件
    QFile wavFile(wavFilename);
    if (!wavFile.open(QFile::WriteOnly)) {
        qDebug() << "文件打开失败" << wavFilename;

        pcmFile.close();
        return;
    }

    // 写入头部
    wavFile.write((const char *) &header, sizeof (WAVHeader));

    // 写入pcm数据
    char buf[1024];
    int size;
    while ((size = pcmFile.read(buf, sizeof (buf))) > 0) {
        wavFile.write(buf, size);
    }

    // 关闭文件
    pcmFile.close();
    wavFile.close();
}

2.3 调用函数

// 封装WAV的头部
WAVHeader header;
header.numChannels = 2;
header.sampleRate = 44100;
header.bitsPerSample = 16;

// 调用函数
FFmpegs::pcm2wav(header, "F:/in.pcm", "F:/out.wav");

五、播放WAV

对于WAV文件来说,可以直接使用ffplay命令播放,而且不用像PCM那样增加额外的参数。因为WAV的文件头中已经包含了相关的音频参数信息。

ffplay in.wav

接下来演示一下如何使用SDL播放WAV文件。

1. 初始化子系统

// 初始化Audio子系统
if (SDL_Init(SDL_INIT_AUDIO)) {
    qDebug() << "SDL_Init error:" << SDL_GetError();
    return;
}

2. 加载WAV文件

// 存放WAV的PCM数据和数据长度
typedef struct {
    Uint32 len = 0;
    int pullLen = 0;
    Uint8 *data = nullptr;
} AudioBuffer;

// WAV中的PCM数据
Uint8 *data;
// WAV中的PCM数据大小(字节)
Uint32 len;
// 音频参数
SDL_AudioSpec spec;

// 加载wav文件
if (!SDL_LoadWAV(FILENAME, &spec, &data, &len)) {
    qDebug() << "SDL_LoadWAV error:" << SDL_GetError();
    // 清除所有的子系统
    SDL_Quit();
    return;
}

// 回调
spec.callback = pull_audio_data;
// 传递给回调函数的userdata
AudioBuffer buffer;
buffer.len = len;
buffer.data = data;
spec.userdata = &buffer;

如果想要轻松加载MP3、Ogg、FLAC等格式的音频文件,可以使用第三方库:SDL_mixer

3. 打开音频设备

// 打开设备
if (SDL_OpenAudio(&spec, nullptr)) {
    qDebug() << "SDL_OpenAudio error:" << SDL_GetError();
    // 释放文件数据
    SDL_FreeWAV(data);
    // 清除所有的子系统
    SDL_Quit();
    return;
}

开始播放

// 开始播放(0是取消暂停)
SDL_PauseAudio(0);

while (!isInterruptionRequested()) {
    if (buffer.len > 0) continue;
    // 每一个样本的大小
    int size = spec.channels * SDL_AUDIO_BITSIZE(spec.format) / 8;
    // 最后一次播放的样本数量
    int samples = buffer.pullLen / size;
    // 最后一次播放的时长
    int ms = samples * 1000 / spec.freq;
    SDL_Delay(ms);
    break;
}

4. 回调函数

// 等待音频设备回调(会回调多次)
void pull_audio_data(void *userdata,
                     // 需要往stream中填充PCM数据
                     Uint8 *stream,
                     // 希望填充的大小(samples * format * channels / 8)
                     int len
                    ) {
    // 清空stream
    SDL_memset(stream, 0, len);

    AudioBuffer *buffer = (AudioBuffer *) userdata;

    // 文件数据还没准备好
    if (buffer->len <= 0) return;

    // 取len、bufferLen的最小值
    buffer->pullLen = (len > (int) buffer->len) ? buffer->len : len;

    // 填充数据
    SDL_MixAudio(stream,
                 buffer->data,
                 buffer->pullLen,
                 SDL_MIX_MAXVOLUME);
    buffer->data += buffer->pullLen;
    buffer->len -= buffer->pullLen;
}

5. 释放资源

// 释放WAV文件数据
SDL_FreeWAV(data);

// 关闭设备
SDL_CloseAudio();

// 清除所有的子系统
SDL_Quit();

专题系列文章

1. 前知识

2. 基于OC语言探索iOS底层原理

3. 基于Swift语言探索iOS底层原理

关于函数枚举可选项结构体闭包属性方法swift多态原理StringArrayDictionary引用计数MetaData等Swift基本语法和相关的底层原理文章有如下几篇:

4. C++核心语法

5. Vue全家桶

6. 音视频技术核心知识

其它底层原理专题

1. 底层原理相关专题

2. iOS相关专题

3. webApp相关专题

4. 跨平台开发方案相关专题

5. 阶段性总结:Native、WebApp、跨平台开发三种方案性能比较

6. Android、HarmonyOS页面渲染专题

7. 小程序页面渲染专题