简要介绍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对象,该对象仅提供两个方法:GetBufferReleaseBuffer,用来获取和释放需要填充的音频数据缓冲区指针。

在第一次调用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后写了一些代码,并写出本文,希望起到抛砖引玉的效果。

posted @ 2023-01-08 23:44  PeaZomboss  阅读(3103)  评论(1编辑  收藏  举报