简要介绍WASAPI播放音频的方法
正文
填一下之前挖的坑,这回就说说怎么用WASAPI播放声音吧。
本文完整代码可以在以下链接找到
https://gitcode.net/PeaZomboss/learnaudios
目录是demo/wasplay。
WASAPI介绍
参考链接https://learn.microsoft.com/en-us/windows/win32/coreaudio/wasapi,这个是英文原版的,建议阅读,https://learn.microsoft.com/zh-cn/windows/win32/coreaudio/wasapi是机翻的,有些地方不太好,当然也可以两份对照着阅读。
WASAPI是Windows Core Audio的一部分,是从Vista开始引入的,作为应用层最底层的音频API。所以现在用的DirectSound系列也好,waveXxx(也就是MME)也好,他们都是基于Core Audio的,而音频流的管理,则是通过WASAPI。
WASAPI是一个比较复杂的API,但是其方便了许多偏底层的音频开发,因为在这之前有WDM音频驱动和Kernel Streaming(内核流)这种模式,开发难度极高,甚至为了降低延迟就有厂商搞出了ASIO这玩意。
延迟对于音频开发来说非常重要,因为早期Windows没有一套像样的低延迟音频API,所以开发起来要另辟蹊径,费时费力,而现在有了WASAPI就不一样了,一套API就可以实现低延迟、高品质的音频输入输出了。
可以说微软推出WASAPI就是为了一统Windows音频开发的江湖。
WASAPI使用
现在我们看看WASAPI应该怎么用吧。
首先说明,WASAPI(或者说整个Core Audio)是基于Windows经典的COM组件对象模型,使用一系列接口来实现各类功能。所以我们要使用它,就必须先了解一些基础的COM知识。
其实之前讲DirectSound的时候也说到了这个,但是因为DirectSound有DirectSoundCreate
这个函数,所以操作简单了不少,而用Core Audio就复杂一些了。
一些有关COM概念的介绍网上有不少不错的资料,这里就不多说了,比如
官方文档 https://learn.microsoft.com/zh-cn/windows/win32/com/component-object-model--com--portal
或者 https://blog.csdn.net/qq_40628925/article/details/118097146
还有 https://blog.csdn.net/wangqiulin123456/article/details/8026270(排版差了点)
COM在我们的使用中就是一个接口,这个接口类似于Java或者C#里的接口,在C++就是一个纯虚类,用C语言表示就是一个虚函数表,利用这个特性实现了跨编程语言、跨操作系统的功能。每个接口都继承自IUnknown
类,有三个基本方法,一般最需要关注的就是Release
的调用了,因为这涉及到内存释放,如果不调用就会造成泄漏。
使用COM的函数需要注意返回值,类型为HRESULT
,实际上就是一个int,其中S_OK
代表成功,其他不少返回值都有其特定的含义,具体在查API的时候可以看到,不过呢一般情况下都是会返回S_OK
的,只有少部分会失败,我们只需关注容易失败的就行了。
在使用COM之前先要初始化,这个过程比较简单,调用CoInitializeEx
函数即可,当然使用完了一会要调用CoUninitialize
来撤销初始化。
其中CoInitializeEx
有两个参数,第一个固定为NULL
,第二个填0即可,默认就是多线程的,返回值一般不用管,基本上不会出错的;CoUninitialize
没有参数也没有返回值。
然后需要调用CoCreateInstance
来创建一个对象,这个函数比较复杂,这里是官方的介绍。
贴一下官方的函数原型
HRESULT CoCreateInstance(
[in] REFCLSID rclsid,
[in] LPUNKNOWN pUnkOuter,
[in] DWORD dwClsContext,
[in] REFIID riid,
[out] LPVOID *ppv
);
第一个是CLSID,我们只要知道这是一个GUID常量就行了
第二个一般用NULL就行了
第三个一般用CLSCTX_ALL就行了
第四个一是要创建的对象的类型IID,也是一个GUID常量
第五个就是创建的对象指针的地址
下面贴出创建的例子
const GUID IID_IMMDeviceEnumerator = __uuidof(IMMDeviceEnumerator);
const CLSID CLSID_MMDeviceEnumerator = __uuidof(MMDeviceEnumerator);
IMMDeviceEnumerator *enumerator = NULL;
CoCreateInstance(CLSID_MMDeviceEnumerator, NULL, CLSCTX_ALL, IID_IMMDeviceEnumerator, (void **)&enumerator);
这样我们就有了一个IMMDeviceEnumerator对象指针了。
接着再
IMMDevice *device = NULL;
enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device);
enumerator->Release();
就能创建一个IMMDevice对象了,这里创建的是默认设备对象,也可以枚举所有设备选择其中一个,选择的过程一般交给用户来处理,具体可以去看看IMMDeviceEnumerator的介绍。
拿到了IMMDevice对象之后就可以创建IAudioClient对象了。
const GUID IID_IAudioClient = __uuidof(IAudioClient);
IAudioClient *aclient = NULL;
device->Activate(IID_IAudioClient, CLSCTX_ALL, NULL, (void **)&aclient);
前面说了这么多,终于到了我们需要的IAudioClient了。这是我们的核心部分,接口文档链接:https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nn-audioclient-iaudioclient
IAudioClient后面还有IAudioClient2和IAudioClient3,不过都是一些扩展功能,本文内容用不上。不过如果要用的话,就得用IUnknown的QueryInterface方法了,这个在之前讲DirectSound事件驱动模式的时候已经用过了。
这里是一个官方示例代码https://learn.microsoft.com/zh-cn/windows/win32/coreaudio/rendering-a-stream,不过呢虽然官方的代码很详细,但是不能直接跑,而且用的方法也不能体现WASAPI的优势(居然在循环里调用Sleep来等,而且这个时间是500ms)。而我们采用的是事件驱动机制,和之前用DirectSound是一样的,好处在于其延迟可以低至10ms左右,如果用独占流配合更好的设备的话,理论上可以更低(据说Win10共享流也可以更低),这对于录音来说是极其重要的。当然我们用共享流就行了,不然其他程序的声音就没了,独占流更适合专业性强的软件。
IAudioClient有若干方法,其中大部分都会用到,具体细节可以看文档说明。
首先我们要调用的是Initialize
方法,不过在调用之前可以用IsFormatSupported
来确认选定的格式是否受支持。也可以调用GetDevicePeriod
来查看设备支持的处理周期。还可以调用GetMixFormat
来获取系统内部的混音格式直接用,不过这样的话就得自己处理重采样了,对于专业的软件来说还是需要的,毕竟早期Windows自带的重采样质量稍差(其实DirectSound就默认支持重采样了,但是WASAPI一开始不支持,关于重采样的更多内容会在后文讨论)。除了以上三个方法可以在Initialize
之前调用,其他的全部要先调用Initialize
才能用。
官方文档上Initialize
的原型:
HRESULT Initialize(
[in] AUDCLNT_SHAREMODE ShareMode,
[in] DWORD StreamFlags,
[in] REFERENCE_TIME hnsBufferDuration,
[in] REFERENCE_TIME hnsPeriodicity,
[in] const WAVEFORMATEX *pFormat,
[in] LPCGUID AudioSessionGuid
);
第一个参数是共享模式还是独占模式的选项,就AUDCLNT_SHAREMODE_EXCLUSIVE、AUDCLNT_SHAREMODE_SHARED这俩,我们用AUDCLNT_SHAREMODE_SHARED共享模式就行了
第二个参数是控制流的选项,多个参数用按位或运算叠加,我们需要AUDCLNT_STREAMFLAGS_EVENTCALLBACK实现事件驱动,还有AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM和AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY(提供更高的质量)实现自动重采样
第三个参数是缓冲区持续时间长度(单位100纳秒),对于共享模式和事件驱动同时使用的情况,填0即可
第四个参数是设置设备处理周期,独占模式才要设置,填0即可
第五个参数是音频格式,支持WAVEFORMATEX和WAVEFORMATEXTENSIBLE,从要播放的文件获取
第六个参数是AudioSession类型GUID的指针,不用Session填NULL即可
调用完以后检查一下返回值,因为那么多方法里这个出错的概率比较大,当然规范的写法是每个方法都要检测返回值以避免错误。
之后需要调用SetEventHandle
来指定接收事件的句柄,调用GetBufferSize
来确定系统分配的缓冲区大小(单位是音频帧个数),也可以选择调用GetStreamLatency
查看系统安排的延迟时间(单位100纳秒)(不过我发现这个方法似乎不会给出一个有效的值,一直是0,可能是bug)。
现在可以调用GetService
来获取一个IAudioRenderClient
对象,该对象仅提供两个方法:GetBuffer
和ReleaseBuffer
,用来获取和释放需要填充的音频数据缓冲区指针。
在第一次调用Start
之前用IAudioRenderClient
提前获取缓冲区并填充数据可以降低播放的延迟,这样就可以开始播放了。
紧接着我们就要启动一个线程来等待事件的到来然后填充数据了。
要在线程中调用GetCurrentPadding
来获取缓冲区已有的数据,因为缓冲区一般比较大,而实际上每10毫秒就会需要新的数据了,此时缓冲区内还有剩余数据没有播放。
具体代码如下:
device->Activate(IID_IAudioClient, CLSCTX_ALL, NULL, (void **)&aclient);
HRESULT hr = aclient->Initialize(AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
0, 0, fmtex, NULL); // fmtex是要播放的文件的
// 这里最好检测一下hr结果,然后妥善处理
aclient->GetBufferSize(&blocksize);
aclient->SetEventHandle(hevents[0]);
aclient->GetService(IID_IAudioRenderClient, (void **)&arender);
BYTE *pdata;
arender->GetBuffer(blocksize, &pdata);
fill(pdata, blockalign * blocksize); // 注意这个blockalign是文件里获取的,实际数据大小等于帧数*每帧大小
arender->ReleaseBuffer(blocksize, 0);
hthread = CreateThread(0, 0, fill_thread, this, 0, NULL);
SetThreadPriority(hthread, THREAD_PRIORITY_TIME_CRITICAL); // 提高一下线程优先级
aclient->Start();
线程代码如下:
DWORD __stdcall fill_thread(void *obj)
{
WASPlayer *player = (WASPlayer *)obj;
while (1) {
DWORD r = WaitForMultipleObjects(2, player->hevents, FALSE, INFINATE);
BYTE *pdata;
if (r == 0) {
UINT32 padding;
player->aclient->GetCurrentPadding(&padding); // 获取当前缓冲区已经填充的数据大小
UINT32 frames = player->blocksize - padding; // 计算需要填充的大小
UINT32 bytes = frames * player->blockalign; // 计算出需要的字节数
player->arender->GetBuffer(frames, &pdata);
int filled = 0; // 实际填充的大小
if (pdata) // 一般不会为空
filled = player->fill(pdata, bytes);
player->arender->ReleaseBuffer(frames, 0);
if (filled < 0) {
printf("[debug] No buffer\n");
break;
}
}
else if (r == 1) {
printf("[debug] Set stop\n");
break;
}
else {
printf("[debug] Unknown\n");
break;
}
}
player->stop();
printf("[debug] Thread end\n");
return 0;
}
以上就是一些简单的说明和示例代码了,具体可以查看本文前面的完整代码。
录音说明
本文代码适当修改就可以实现录音功能了,还可以实现环回(Loopback)录音,不过Loopback和事件驱动模式下还有一些bug,据说win10修复了但是没有具体测试过。
这里贴一下官方文档:
麦克风录制 https://learn.microsoft.com/en-us/windows/win32/coreaudio/capturing-a-stream
Loopback录制 https://learn.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording
有兴趣可以去参阅一下。
关于重采样
这个单独拎出来讲,是因为内容比较多,几句话是说不完的,有关资料可以提供一个斯坦福大学CCRMA的Julius O. Smith教授的网站,介绍了关于数字音频重采样的理论和方法等内容。
链接在此:https://ccrma.stanford.edu/~jos/resample/resample.html
简单说的话,音频方面的重采样包括采样率转换、量化方式的转换和声道数等的转换(尽管大部分时候采样率转换可能是很多人说的意思)。打个比方,系统内部的混音格式是48000Hz、32位浮点数,而我要播放的文件是48000Hz、16位定点整数,那么只需要把每一帧的数据按量化最大值(16位整数是32767)进行除法转换到-1~1范围的浮点数就行了,比较容易;但如果我要播放的文件是44100Hz、16位的标准CD格式(这也是主流的格式),那就不仅仅是转换浮点数这么简单了,涉及到采样率转换这个比较棘手的问题。
还有比如32位浮点数或者24位整数转16位整数就涉及到抖动(Dither)这个概念,这是因为16位整数的量化误差相对较大,人为加入一些小噪音可以减少量化误差带来的影响。关于抖动,可以看看这篇文章:https://www.bilibili.com/read/cv13718097/。
高采样率转低采样率会造成混叠的现象(如96kHz到44.1kHz),理想情况下高于22.05kHz的频率应该被低通滤波器过滤掉,但实际上不存在这样的滤波器,所以在这种情况下多少会有混叠,如何减少这个现象也是一个重要的因素;反过来低采样率到高采样率还有产生镜像频率的问题。
关于采样率转换算法CCRMA网站上有详细的描述,网上也有不少开源代码,当然不同厂商也有自己的独家改进算法,不同软件采样率转换质量的比较,可以看这个:http://src.infinitewave.ca/。
如果把采样率转换和量化的转换放一起,那难度就更高了,不过一般情况下,Windows内部的混音器都是采用32位浮点数的,不同的是采样率,所以对Windows来说采样率转换可能才是重点。
当然不知到什么时候开始WASAPI有了AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM和AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY这俩,一个是自动重采样,同时使用另一个可以获得高质量的采样率转换,我们姑且认定其质量应该不会太差,但实际不敢保证,因为http://src.infinitewave.ca/并没有关于WASAPI的采样率转换质量对比,只有DirectSound的。
重采样这块可以挖个大坑,什么时候能填就不知道了(非专业,没怎么学过数字信号处理😅)。
总结
WASAPI看起来麻烦,其实并不难,实现简单的功能甚至比DirectSound还方便,但是关于WASAPI的说明介绍还是太少了,导致相关资料不好找,除了官方那份庞大的文档,而没有一份简单的入门介绍。于是在研究一段时间WASAPI后写了一些代码,并写出本文,希望起到抛砖引玉的效果。