使用 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."

使用 DirectSound 播放 WAV 文件的更多相关文章

  1. python 播放 wav 文件

    未使用其他库, 只是使用 pywin32 调用系统底层 API 播放 wav 文件. # Our raison d'etre - playing sounds import pywintypes im ...

  2. C#播放wav文件

    C#使用HWQPlayer类播放wav文件 类的代码: using System.IO; using System.Runtime.InteropServices; namespace HoverTr ...

  3. WinAPI: sndPlaySound - 播放 wav 文件

    WinAPI: sndPlaySound - 播放 wav 文件 //声明: sndPlaySound(   lpszSoundName: PChar; {声音文件}   uFlags: UINT{播 ...

  4. 如何播放 WAV 文件?

    from http://www.vckbase.com/index.php/wv/434 平时,你在多媒体软件的设计中是怎样处理声音文件的呢?使用Windows 提供的API函数 sndPlaySou ...

  5. 8086汇编语言 调用声卡播放wav文件(sound blaster)

    开更 大概最后做了一个能播放无损音乐(无压缩.不需解码)的播放器 原理是基于dosbox的模拟声卡,通过硬件之间的相互通讯做到的 关于详细内容接下来再讲. 一.从dosbox入手 我们知道cpu可以直 ...

  6. Linux音频编程--使用ALSA库播放wav文件

    在UBUNTU系统上使用alsa库完成了对外播放的wav文件的案例. 案例代码: /** *test.c * *注意:这个例子在Ubuntu 12.04.1环境下编译运行成功. * */ #inclu ...

  7. c++(qt)播放wav文件的四种方式

    //方法一(要符合RIFF规范) 1 QSound::play("E:/Projects/报警声1-1.wav"); //方法二(要符合RIFF规范) 1 QSoundEffect ...

  8. windows下使用waveout函数族播放wav文件

    要使用waveout函数组,族,首先要知道几个数据结构,首先是这个 typedef struct tWAVEFORMATEX { WORD wFormatTag; /* 格式的类型 */ WORD n ...

  9. 【C#学习笔记】播放wav文件

    using System; using System.Media; namespace ConsoleApplication { class Program { static void Main(st ...

  10. java播放wav文件

    import java.io.File; import java.io.IOException; import javax.sound.sampled.AudioFormat; import java ...

随机推荐

  1. 关于C++ find

    #include <bits/stdc++.h> using namespace std; int a[1005]; int main() { for(int i = 0;i < 1 ...

  2. 【Spring系列】- Bean生命周期底层原理

    Bean生命周期底层原理 生命不息,写作不止 继续踏上学习之路,学之分享笔记 总有一天我也能像各位大佬一样 一个有梦有戏的人 @怒放吧德德 分享学习心得,欢迎指正,大家一起学习成长! 前言 上次学到动 ...

  3. 项目上的业务《接收一个xml信息包进行解析,xml中包含base64解析为电子文件》

    我就直接贴代码了,不太会说,附上注释. ps:需要根据系统字段和xml里面的标签字段进行建表,之后把xml标签的值进行添加.创建表的方法就是拼的sql. // 在线接收接口 @Transactiona ...

  4. 第三十节:fillder抓取APP数据之小程序

    1.下载fillder ,fillder官网:https://www.telerik.com/fiddler 2.安装好后设置fillder: 工具->选项,打开设置面板.选择HTTPS选项卡. ...

  5. Kettle:跨库(SQLServer->PostgreSQL)同步多张表数据的详细设计过程

    〇.参考地址 1.多个Excel实现同步 https://www.wangt.cc/2021/05/kettle%E5%A4%9A%E4%B8%AA%E8%A1%A8%E4%B8%80%E8%B5%B ...

  6. 【Java SE】Day05数组

    一.数组的定义和访问 1.初始化 动态new int[10];--默认值 静态new int[]{1,2,3};,省略为{1,2,3}; 2.访问 长度arr.length属性(数组的属性) 打印数组 ...

  7. 【每日一题】【暴力、动态规划、动规优化、贪心】2022年1月21日-NC19 连续子数组的最大和/最大子序和

    同:最大子序和 https://www.cnblogs.com/liujinhui/p/15574312.html 描述输入一个长度为n的整型数组array,数组中的一个或连续多个整数组成一个子数组. ...

  8. uniapp vue3下的代理转发不生效问题,亲测有效解决

    以前配置过vue vite 的代理转发,没想到在uniapp的代理转发下翻车了,其实是一个很小的问题.调试过程中,尝试了webpack.vite 等写法 在根目录下 创建了 vite.config.j ...

  9. VUE 使用md5对用户登录密码进行加密传输

    VUE 使用md5对用户登录密码进行加密传输到数据库 前言 第一步 npm下载js-md5依赖包 第二步 引入js-md5 直接在需要使用md5加密的页面引入 全局挂载,将js-md5添加到vue原型 ...

  10. 用友开发者中心全新升级,YonBuilder移动开发入门指南

    听说用友新上线了全新的开发者中心,有YonBuilder应用开发,集成开发.数据开发.智能与自动化.DevOps 等板块,本人作为用户老客户,对其中的移动开发比较感兴趣,本文重点讲解其中的移动开发平台 ...