使用 DirectSound 播放 WAV 文件

使用 DirectSound 播放 WAV 文件

本文需要的前置知识可以在之前的这两篇文章找到。

基于本文介绍的方法,我们也可以用DirectSound来播放其他格式的文件,不过前提是要解码该格式的文件。

本系列所有代码都可以在以下链接找到

https://gitcode.net/PeaZomboss/learnaudios

本文代码在demo/dsplay目录下。

DirectSound基本介绍

这里只简单介绍一下后面需要用到的,关于DirectSound的具体细节可以在MSDN找到,其中的Reference页面应该是经常需要查阅的。

DirectSound使用COM接口技术,因此要用到的函数都封装在一个个接口中,而COM又是一个复杂的内容,这里需要注意的就是AddRef()Release()的调用,一般Release对于用户用的多一点。

本文还涉及到了QueryInterface()函数,具体用法看后面的代码就行了,记得调用该函数后需要调用Release()减少引用计数。

创建一个DirectSound接口需要调用

DirectSoundCreate(NULL, &ds, NULL);

其中变量dsIDirectSound的指针。

这个函数一般不会失败,创建成功后就可以使用ds调用相关函数进行进一步操作。

用完了记得调用一下Release()释放资源哦。

第一个需要调用的函数就是

SetCooperativeLevel

必须要先设置这个协作级别才可以调用播放声音最为关键的

CreateSoundBuffer

对这两个函数,我们按顺序来介绍一下。

首先是SetCooperativeLevel,其第一个参数为窗口句柄,一般都比较容易获得,即使是控制台,也是可以想办法获得的;第二个参数就是真正的协作级别了,可选的参数有4个,但一般用DSSCL_PRIORITY是最好的,也是官方推荐的。关于这个函数的其他细节,可以在文档中查看。

然后是CreateSoundBuffer函数,这个是用来创建播放缓冲区的,所有的播放操作就是依赖这个函数创建的IDirectSoundBuffer接口,关于这个接口的介绍,我们放到后面再说;CreateSoundBuffer函数有三个参数,除去最后一个参数固定为NULL,以及第二个参数为IDirectSoundBuffer指针的地址外,仅有第一个参数需要讲解。

第一个参数是结构体DSBUFFERDESC的地址,该结构体的定义如下:

typedef struct _DSBUFFERDESC
{
    DWORD           dwSize;
    DWORD           dwFlags;
    DWORD           dwBufferBytes;
    DWORD           dwReserved;
    LPWAVEFORMATEX  lpwfxFormat;
#if DIRECTSOUND_VERSION >= 0x0700
    GUID            guid3DAlgorithm;
#endif
} DSBUFFERDESC, *LPDSBUFFERDESC;

其中DIRECTSOUND_VERSION是DirectSound的版本,值为0x0900,但是不论如何,我们都是不会使用guid3DAlgorithm字段的,而且dwReserved也要置0,所以使用前要用memset函数清零。

  • dwSize,设置为sizeof(DSBUFFERDESC)即可
  • dwFlags,具体可以在这里看到,我们只需重点关注DSBCAPS_CTRLPOSITIONNOTIFYDSBCAPS_GLOBALFOCUS即可
  • dwBufferBytes,实际分配的缓冲区大小,一般需要最小播放延迟[1]的2倍及以上,本文用了3倍
  • lpwfxFormatWAVEFORMATEX结构体指针

接下来说说IDirectSoundBuffer接口的相关方法。

首先是一系列以Get或者Set为前缀的方法,这个本文不关心,读者可以查阅文档了解相关功能,简要提一下就是例如SetVolume这样的函数需要提前在设置DSBUFFERDESC的flags时设定相关标志位,不建议设置太多,这样可以减少资源使用[2]

关键是以下四个方法:

Lock
Unlock
Play
Stop

容易看出来他们是成对使用的。

Lock就是锁定缓冲区内一定大小的内存,返回一个地址和大小,之后这两个值需要调用Unlock解锁,关于具体的说明需要到后面实际使用的时候再介绍。

而Play和Stop就比较简单了,不过要注意的是这个Stop相当于暂停,并不是一般意义上的停止。


前置内容差不多就这么多,下面来讲一下实际的用法。

封装一个简单的播放器类

由于DirectSound内容较多,考虑到代码复用,就将基础功能封装到一个类中,可以方便后续的使用。

为了能够允许使用者播放想要的内容,我们需要留下一个填充缓冲区的接口,可以使用的方法有两个:

  • 使用虚函数,由使用者继承该类重写填充代码实现
  • 使用回调函数,由使用者实现填充代码并将函数地址传入

这里用了第二种方法,定义了一个回调函数如下:

typedef int (*FillBufferCallback)(void *, int, void *);

其中,第一个参数是缓冲区指针,第二个参数是缓冲区大小,第三个参数是使用者提前设置的指针。

第三个参数我们可以用来传递前文所封装的WaveReader对象指针,这样可以不断从文件读取数据然后播放。

为了防止播放代码阻塞主线程,我们需要手动建立一个线程,建立线程的方法有很多,但在Windows下最基础的就是调用CreateThread这个API,其有多个参数,最关键的是第三个和第四个。第三个参数是一个回调函数,第四个参数是一个由用户自定义的指针。

编写回调函数如下:

DWord WINAPI fill_thread(void *parameter);

马上想到了吧,第四个参数就会在这里被使用,那这个参数肯定就是类对象的指针了。

所以这个播放器类就可以这样定义:

class Player
{
private:
    IDirectSound *ds;
    IDirectSoundBuffer *dsb;
    void *filler_ctx; // 调用fill_buf_func时的第三个参数
    FillBufferCallback fill_buf_func; // 初始化的时候传入
    WAVEFORMATEX fmtex; // Direct Sound需要的wave格式
    DWord block_size;   // 缓冲区音频帧的数量
    DWord buf_size;     // 根据缓冲区音频帧数计算的实际字节数
    HANDLE events[4];   // 用于播放线程的事件
    DWord fill_thread_id; // 线程id
    bool playing; // 播放中的标志,严格来说是工作中的意思
public:
    friend DWord WINAPI fill_thread(void *parameter);
    Player();
    ~Player();
    void set_handle(HWND hwnd); // 设置一个窗口句柄
    void set_buf_filler(FillBufferCallback filler, void *ctx); // 设置缓冲区填充函数
    void set_block_size(DWord bs); // 设置缓冲区大小
    void set_wavfmt(const WAVEFORMATEX &fmt); // 设置WAVEFORMATEX格式
    void set_wavfmt(const WaveFormatExtensible &fmtext); // 设置扩展的格式
    bool is_playing();
    void start();
    void stop();
    void pause();
    void resume();
};

类成员变量意义都在上面标出,类方法除去各种set_xxx,主要就是最后的5个,用来控制实际的播放。

下面来详细说明Player类方法以及线程函数的具体实现。

构造析构函数比较简单,如下初始化和释放必要资源即可。

Player::Player()
{
    DirectSoundCreate(NULL, &ds, NULL);
    block_size = 4096; // 设置默认缓冲区大小
    for (int i = 0; i < 4; i++)
        events[i] = CreateEvent(NULL, FALSE, FALSE, NULL);
    playing = false;
    dsb = NULL;
}

Player::~Player()
{
    ds->Release();
    for (int i = 0; i < 4; i++)
        CloseHandle(events[i]);
}

几个基础的set_xxx系列函数也比较简单:

void Player::set_handle(HWND hwnd)
{
    ds->SetCooperativeLevel(hwnd, DSSCL_PRIORITY); // 一般都是这个
}

void Player::set_buf_filler(FillBufferCallback filler, void *ctx)
{
    fill_buf_func = filler;
    filler_ctx = ctx;
}

void Player::set_block_size(DWord bs)
{
    block_size = bs;
}

void Player::set_wavfmt(const WAVEFORMATEX &fmt)
{
    fmtex = fmt;
}

void Player::set_wavfmt(const WaveFormatExtensible &fmtext)
{
    fmtex.nChannels = fmtext.Channels;
    fmtex.nBlockAlign = fmtext.BlockAlign;
    fmtex.nSamplesPerSec = fmtext.SampleRate;
    fmtex.wBitsPerSample = fmtext.BitsPerSample;
    fmtex.nAvgBytesPerSec = fmtext.BytesRate;
    if (fmtext.FormatTag == 0xFFFE)
        fmtex.wFormatTag = fmtext.SubFormat.D1;
    else
        fmtex.wFormatTag = fmtext.FormatTag;
    fmtex.cbSize = 0;
}

接下来是本文的重头戏之一,start函数:

void Player::start()
{
    DSBUFFERDESC dsbd;
    DSBPOSITIONNOTIFY dsbpn[3];
    void *ptr;
    DWord len;
    HRESULT hr;
    if (playing)
        return;
    buf_size = block_size * fmtex.nBlockAlign; // 计算缓冲区字节大小
    memset(&dsbd, 0, sizeof(DSBUFFERDESC));
    dsbd.dwSize = sizeof(DSBUFFERDESC);
    // DSBCAPS_GLOBALFOCUS是为了防止窗口失去焦点导致声音消失
    // DSBCAPS_CTRLPOSITIONNOTIFY用来实现缓冲区的同步
    dsbd.dwFlags = DSBCAPS_GLOBALFOCUS | DSBCAPS_CTRLPOSITIONNOTIFY; // 全局播放、位置通知
    dsbd.dwBufferBytes = buf_size * 3; // 使用三倍大小缓冲区
    dsbd.lpwfxFormat = &fmtex;
    hr = ds->CreateSoundBuffer(&dsbd, &dsb, NULL);
    if (FAILED(hr)) {
        printf("Can not create sound buffer %x\n", hr);
        return;
    }
    dsb->Lock(0, buf_size, &ptr, &len, NULL, NULL, 0);
    fill_buf_func(ptr, len, filler_ctx); // 播放前填充第一块内容
    dsb->Unlock(ptr, len, NULL, 0);
    IDirectSoundNotify *dsn;
    // 关于_iid_IDirectSoundNotify,是因为g++链接不到IID_IDirectSoundNotify
    hr = dsb->QueryInterface(_iid_IDirectSoundNotify, (void **)&dsn);
    if (FAILED(hr)) {
        printf("Can not query IDirectSoundNotify %x\n", hr);
        return;
    }
    // 给三块缓冲区分段
    dsbpn[0].dwOffset = 0; // 第一段
    dsbpn[0].hEventNotify = events[0];
    dsbpn[1].dwOffset = buf_size; // 第二段
    dsbpn[1].hEventNotify = events[1];
    dsbpn[2].dwOffset = buf_size * 2;
    dsbpn[2].hEventNotify = events[2]; // 第三段
    dsn->SetNotificationPositions(3, &dsbpn[0]); // 设置提醒
    dsn->Release(); // 用完减少引用计数
    playing = true;
    CreateThread(NULL, 0, fill_thread, this, 0, &fill_thread_id); // 创建线程
    printf("Play thread id: %d\n", fill_thread_id);
    dsb->Play(0, 0, DSBPLAY_LOOPING); // 循环播放
    printf("Now playing...\n");
}

start函数的重点都在注释里了,严格按照顺序写就可以了。

接着是停止、暂停、恢复的代码:

void Player::stop()
{
    if (playing && dsb != NULL) {
        printf("Stopping...\n");
        playing = false;
        SetEvent(events[3]); // 用来发送停止信号
        Sleep(100); // 等一小段时间再完全关闭
        dsb->Stop();
        dsb->Release();
        dsb = NULL;
    }
}

void Player::pause()
{
    if (dsb) {
        dsb->Stop();
    }
}

void Player::resume()
{
    if (dsb) {
        dsb->Play(0, 0, DSBPLAY_LOOPING);
    }
}

注意因为只有调用了start函数dsb才会被设置,我们只需检测dsb是不是NULL就行了。

这里再次指出,变量playing并非指当前正在播放声音,而是说这个类正在工作中,即调用了start进入工作状态。

然后是第二个重头戏,线程函数的实现:

DWord WINAPI fill_thread(void *parameter)
{
    Player *player = (Player *)parameter;
    void *ptr; // 缓冲区指针
    DWord len; // 缓冲区长度(字节)
    long filled; // fill_buf_func返回的实际填充字节
    while (TRUE) {
        DWord res = WaitForMultipleObjects(4, &player->events[0], FALSE, INFINITE); // 等待信号
        if (res == 0) { // 此时开始播放缓冲区的第一块,需要填充第二块,清零第三块
            player->dsb->Lock(player->buf_size, player->buf_size, &ptr, &len, NULL, NULL, 0);
            filled = player->fill_buf_func(ptr, len, player->filler_ctx);
            player->dsb->Unlock(ptr, len, NULL, 0);
            player->dsb->Lock(player->buf_size * 2, player->buf_size, &ptr, &len, NULL, NULL, 0);
            memset(ptr, 0, len);
            player->dsb->Unlock(ptr, len, NULL, 0);
            if (filled <= 0) // 填充不了就播放结束
                break;
        }
        else if (res == 1) { // 同理,开始播放第二块
            player->dsb->Lock(player->buf_size * 2, player->buf_size, &ptr, &len, NULL, NULL, 0);
            filled = player->fill_buf_func(ptr, len, player->filler_ctx);
            player->dsb->Unlock(ptr, len, NULL, 0);
            player->dsb->Lock(0, player->buf_size, &ptr, &len, NULL, NULL, 0);
            memset(ptr, 0, len);
            player->dsb->Unlock(ptr, len, NULL, 0);
            if (filled <= 0)
                break;
        }
        else if (res == 2) { // 第三块
            player->dsb->Lock(0, player->buf_size, &ptr, &len, NULL, NULL, 0);
            filled = player->fill_buf_func(ptr, len, player->filler_ctx);
            player->dsb->Unlock(ptr, len, NULL, 0);
            player->dsb->Lock(player->buf_size, player->buf_size, &ptr, &len, NULL, NULL, 0);
            memset(ptr, 0, len);
            player->dsb->Unlock(ptr, len, NULL, 0);
            if (filled <= 0)
                break;
        }
        else // 一般说明res==3,但也有可能出错了是其他返回值,我们就当作播放结束
            break;
    }
    player->stop(); // 播放完了自动停止,由于判断了playing,不会有重复调用的问题
    printf("Play thread end...\n");
    return 0;
}

其实线程要做的事也很简单,就是循环等待DirectSound的播放通知,然后提前填充下一段的内容,至于为什么要将已经播放完的那一段清零,这是因为我在之前的测试中发现如果不这样会出现播放结束时最后一段缓冲区的内容会重复播放一次的问题,于是就加入了清零的代码。

主程序调用

现在来看看主程序如何调用这个类进行播放。

首先利用先前的WaveReader类,写如下回调函数:

int fill_buffer(void *buffer, int size, void *ctx)
{
    WaveReader *reader = (WaveReader *)ctx;
    return reader->read_data(buffer, size);
}

如此就实现了基本的从文件读取数据。

接着就是main函数了:

int main(int argc, char **argv)
{
    if (argc <= 1) {
        printf("Please input a wav file\n");
        exit(0);
    }
    WaveReader reader;
    if (!reader.open_file(argv[1])) {
        printf("Not a wave file or not support\n");
        exit(0);
    };
    SetConsoleTitleA("DirectSoundPlay"); // MSDN上的方法
    Sleep(40); // 等待确保标题已经更改
    HWND hwnd = FindWindowA(NULL, "DirectSoundPlay"); // 这样控制台程序就有句柄了
    printf("The handle: %zd\n", (size_t)hwnd); // 打印输出看看
    Player player;
    // 必要的初始化工作
    player.set_wavfmt(reader.get_fmtext());
    player.set_handle(hwnd);
    player.set_buf_filler(fill_buffer, &reader); // 将回调函数和reader对象指针传入
    // 此处计算方法是采样率*缓冲区毫秒数/1000,可以算出该时间下需要的缓冲区大小
    player.set_block_size(reader.get_fmtext().SampleRate * 60 / 1000); // 60 ms
    player.start(); // 开始播放
    int command = 0; // 阻塞主线程防止程序退出,同时实现控制播放暂停退出功能
    printf("Input 'q' to quit, 'p' to pause, 'r' to resume\n");
    do {
        command = getchar();
        if (command == 'p')
            player.pause();
        else if (command == 'r')
            player.resume();
        if (!player.is_playing())
            break;
    } while (command != 'q');
    player.stop();
    reader.close_file();
    printf("End...\n");
}

用法就是从命令行接收参数,第一个就是我们要播放的文件名。

总结

以上就是关于利用DirectSound播放wav文件方法的介绍了,如果有兴趣可以进一步查看官方文档了解更多信息,尽管现在DirectSound已经逐步淘汰,但在一般延迟要求不高的场合下是一个简单高效的实现,不过在一些对延迟要求较高的情形下就需要用WASAPI了。关于WASAPI未来可能会进行一个介绍,具体什么时候就不知道了,不过可以关注本文开头的代码仓库提前了解。


  1. 这个延迟一般大于40ms,具体视格式而定。 ↩︎

  2. 原文是"By specifying only the flags you need, you cut down on unnecessary resource usage." ↩︎

posted @ 2022-12-15 17:53  PeaZomboss  阅读(384)  评论(0编辑  收藏  举报