09_使用SDL播放PCM
通过命令ffpay播放PCM
可以使用ffplay播放《08_音频录制02_编程》中录制好的PCM文件,测试一下是否录制成功。
播放PCM需要指定相关参数:
- ar:采样率
- ac:声道数
- f:采样格式,sample_fmts + le(小端)或者 be(大端)
sample_fmts可以通过ffplay -sample_fmts来查询
// 查看pcm文件 ffprobe -ar 44100 -ac 2 -f f32le out.pcm // 播放pcm文件 ffplay -ar 44100 -ac 2 -f f32le out.pcm -ar:采样率 -ac:声道数 -f:表示pcm格式,sample_fmts + le(小端)或者 be(大端) sample_fmts可以通过ffplay -sample_fmts来查询
其中电脑里支持-f的值可以通过下面命令查询格式
// win ffmpeg -formats | findstr PCM //mac ffmpeg -formats | grep PCM

虽然知道了电脑里支持的pcm格式,但是支持的那么多,我们用那个呢?其实还有一种方法可以确定:就是使用ffmpeg
命令录制一个wav
文件
// win ffmpeg -f dshow -i audio="麦克风 (Realtek(R) Audio)" out.wav // mac ffmpeg -f avfoundation -i :0 out.wav

我们只需要看Input
这里,因为Input
是录音设备的一些信息,而Output
是wav
文件输出的信息,所以可以从Input
这里看到pcm格式是f32le
注意:如果pcm格式设置的不对,播放pcm文件就会出现嗤嗤的声音
使用SDL播放PCM
ffplay是基于FFmpeg、SDL两个库实现的。通过编程的方式播放音视频,也是需要用到这2个库。FFmpeg大家都已经清楚了,比较陌生的是SDL。
简介
SDL(Simple DirectMedia Layer),是一个跨平台的C语言多媒体开发库。
- 支持Windows、Mac OS X、Linux、iOS、Android
- 提供对音频、键盘、鼠标、游戏操纵杆、图形硬件的底层访问
- 很多的视频播放软件、模拟器、受欢迎的游戏都在使用它
- 目前最新的稳定版是:2.0.14
- API文档:wiki
下载
SDL官网下载地址:download-sdl2。
Windows
由于我们使用的是MinGW编译器,所以选择下载SDL2-devel-2.0.14-mingw.tar.gz。
解压后的目录结构如下图所示,跟FFmpeg的目录结构类似,因此就不再赘述每个文件夹的作用。
Mac
从brew官网可以看得出来:之前执行brew install ffmpeg时,已经顺带安装了SDL,安装目录是:/usr/local/Cellar/sdl2。
如果没有这个目录,就执行brew install sdl2进行安装即可。
HelloWorld
来个简单的SDL HelloWorld吧,打印一下SDL的版本号。
.pro文件
win32 { FFMPEG_HOME = F:/Dev/ffmpeg-4.3.2 SDL_HOME = D:/SoftwareInstall/SDL2-devel-2.0.14-mingw/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文件,需要讲SDL的bin目录配置成系统环境变量,或者将SDL的bin目录下的SDL2.dll
文件考入到项目生成的可执行文件目录下:
cpp代码
#include <SDL2/SDL.h> SDL_version v; SDL_VERSION(&v); // 2 0 14 qDebug() << v.major << v.minor << v.patch;
播放PCM
多线程
playthread.h
#include <QThread> class PlayThread : public QThread { Q_OBJECT private: void run(); public: explicit PlayThread(QObject *parent = nullptr); ~PlayThread(); };
playthread.cpp
PlayThread::PlayThread(QObject *parent) : QThread(parent){ // 在线程结束时自动回收线程的内存 connect(this, &PlayThread::finished, this, &PlayThread::deleteLater); } PlayThread::~PlayThread() { disconnect(); // 线程对象的内存回收时,正常结束线程 requestInterruption(); quit(); wait(); } void PlayThread::run() { // 播放音频操作 // ... }
初始化子系统
SDL分成好多个子系统(subsystem):
- Video:显示和窗口管理
- Audio:音频设备管理
- Joystick:游戏摇杆控制
- Timers:定时器
- ...
目前只用到了音频功能,所以只需要通过SDL_init函数初始化Audio子系统即可。
// 初始化Audio子系统 if (SDL_Init(SDL_INIT_AUDIO)) { // 返回值不是0,就代表失败 qDebug() << "SDL_Init Error" << SDL_GetError(); return; }
flags 参数取值:
// 定时器 #define SDL_INIT_TIMER 0x00000001u // 音频 #define SDL_INIT_AUDIO 0x00000010u // 视频 #define SDL_INIT_VIDEO 0x00000020u /**< SDL_INIT_VIDEO implies SDL_INIT_EVENTS */ // 游戏控制杆 #define SDL_INIT_JOYSTICK 0x00000200u /**< SDL_INIT_JOYSTICK implies SDL_INIT_EVENTS */ // 触摸屏 #define SDL_INIT_HAPTIC 0x00001000u // 游戏控制器 #define SDL_INIT_GAMECONTROLLER 0x00002000u /**< SDL_INIT_GAMECONTROLLER implies SDL_INIT_JOYSTICK */ // 事件 #define SDL_INIT_EVENTS 0x00004000u // 传感器 #define SDL_INIT_SENSOR 0x00008000u // 错误捕获 #define SDL_INIT_NOPARACHUTE 0x00100000u /**< compatibility; this flag is ignored. */ // 全部子系统 #define SDL_INIT_EVERYTHING ( \ SDL_INIT_TIMER | SDL_INIT_AUDIO | SDL_INIT_VIDEO | SDL_INIT_EVENTS | \ SDL_INIT_JOYSTICK | SDL_INIT_HAPTIC | SDL_INIT_GAMECONTROLLER | SDL_INIT_SENSOR \ )
初始化成功返回 0,初始化失败函数返回值为 -1,函数只接受各个子系统的常量作为参数。初始化音频子系统,传入参数SDL_INIT_AUDIO
;初始化视频子系统传入SDL_INIT_VIDEO
;并且可初始化一个或者多个子系统,例如同时初始化音频和视频子系统,传入SDL_INIT_AUDIO | SDL_INIT_VIDEO
。
打开音频设备
/* 一些宏定义 */ // 采样率 #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 = pullAudioData; // 传递给回调的参数 AudioBuffer buffer; spec.userdata = &buffer; // 打开音频设备 if (SDL_OpenAudio(&spec, nullptr)) { qDebug() << "SDL_OpenAudio Error" << SDL_GetError(); // 清除所有初始化的子系统 SDL_Quit(); return; }
SDL_OpenAudio 有两个参数:
- desired:期望参数,播放的音频对应的参数;
- obtained:实际硬件设备参数,可传 nullptr;
SDL_AudioSpec 结构体:
typedef struct SDL_AudioSpec { // 采样率 int freq; /**< DSP frequency -- samples per second */ // 音频数据格式 SDL_AudioFormat format; /**< Audio data format */ // 声道数 Uint8 channels; /**< Number of channels: 1 mono, 2 stereo */ // 音频缓冲区静音值 Uint8 silence; /**< Audio buffer silence value (calculated) */ // 采样帧大小 Uint16 samples; /**< Audio buffer size in sample FRAMES (total samples divided by channel count) */ // 兼容性参数 Uint16 padding; /**< Necessary for some compile environments */ // 音频缓冲区大小 Uint32 size; /**< Audio buffer size in bytes (calculated) */ // 填充音频缓冲区回调函数 SDL_AudioCallback callback; /**< Callback that feeds the audio device (NULL to use SDL_QueueAudio()). */ // 用户自定义数据, void *userdata; /**< Userdata passed to callback (ignored for NULL callbacks). */ } SDL_AudioSpec;
回调函数:
typedef void (SDLCALL * SDL_AudioCallback) (void *userdata, Uint8 * stream, int len);
当音频设备需要更多数据时会调用该函数;
打开文件
#define FILENAME "F:/in.pcm" // 打开文件 QFile file(FILENAME); if (!file.open(QFile::ReadOnly)) { qDebug() << "文件打开失败" << FILENAME; // 关闭音频设备 SDL_CloseAudio(); // 清除所有初始化的子系统 SDL_Quit(); return; }
开始播放
// 每个样本占用多少个字节 #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); /* * SDL_Delay(剩余时间); * * 采样率(每秒采样的样本次数)用SAMPLE_RATE表示, * 每个样本的大小,用BYTES_PER_SAMPLE表示, * * 剩余的样本数量 = buffer.pullLen / BYTES_PER_SAMPLE, * 剩余时间 = 剩余的样本数量 / 采样率 */ // 文件数据已经读取完毕 if (buffer.len <= 0) { // 下面三句代码作用是:推迟线程结束的时间,否则在音频快播放结束时会出现突然停止 // 剩余的样本数量 int samples = buffer.pullLen / BYTES_PER_SAMPLE; int ms = samples * 1000 / SAMPLE_RATE; SDL_Delay(ms); break; } // 读取到了文件数据 buffer.data = data; }
上面代码中的if (buffer.len <= 0)
里面的三句话主要作用是推迟线程结束的时间,因为这里代码的线程和回调函数pullAudioData
中的线程不是同一个,如果在音频快播放结束时,回调函数中的音频缓冲区数据处理没有这里的线程处理快,就会导致这里的线程走释放资源的方法,有时就会导致音频还未完全播放完就结束了。
回调函数
// userdata:SDL_AudioSpec.userdata // stream:音频缓冲区(需要将音频数据填充到这个缓冲区) // len:音频缓冲区的大小(SDL_AudioSpec.samples * 每个样本的大小) void pullAudioData(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; }
回调函数:
typedef void (SDLCALL * SDL_AudioCallback) (void *userdata, Uint8 * stream, int len);
- userdata:SDL_AudioSpec 结构体中用户自定义的数据,可不用;
- stream:指向音频缓冲区的指针;
- len:音频缓冲区大小;
混音函数:
extern DECLSPEC void SDLCALL SDL_MixAudio(Uint8 * dst, const Uint8 * src, Uint32 len, int volume);
- dst:目标数据,这里传入音频缓冲区指针 stream;
- src:音频数据,这里传入我们读出的 PCM 数据;
- len:音频数据长度,这里传入音频缓冲区大小 len;
- volume:音量,范围 0~128,这里我们传入 SDL_MIX_MAXVOLUME,注意此参数并不会修改硬件音量;
释放资源
// 关闭文件 file.close(); // 关闭音频设备 SDL_CloseAudio(); // 清理所有初始化的子系统 SDL_Quit();
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· NetPad:一个.NET开源、跨平台的C#编辑器
· PowerShell开发游戏 · 打蜜蜂
· 凌晨三点救火实录:Java内存泄漏的七个神坑,你至少踩过三个!