ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DirectSound播放器实战:VC++实现WAV播放、暂停与音量控制

DirectSound播放器实战:VC++实现WAV播放、暂停与音量控制 简介面向VC开发者与多媒体编程初学者的DirectSound音乐播放器示例工程基于DirectSound8接口完成音频设备的初始化、主次缓冲区创建、WAV等音频数据加载、循环播放、音量调节与停止控制。资源包共23个文件包含5个头文件、4个C源文件以及wav音频素材、图标资源和VC工程配置文件压缩包大小仅1.07MB结构紧凑、便于对照学习。已有257人学习下载代码按初始化、缓冲区管理、播放控制和资源释放分步实现并附有HRESULT错误检查示范。对希望掌握DirectSound API调用流程、理解声卡缓冲机制或快速搭建音乐播放器原型的开发者颇具参考价值。 DirectSound做音乐播放器放在当下看确实有点“老古董”的味道但它依然是理解Windows音频底层原理的一条捷径。我前几年在维护一个老旧的工业控制上位机项目客户要求给设备加上本地音频播放功能当时手里的技术栈正好就是VCVisual Studio 2017 MFC音频部分我最终选了DirectSound来完成实现了WAV播放、循环播放、暂停恢复、音量调节和播放进度获取。整套代码不复杂但调试过程中踩了不少文档里不会写明白的坑。这篇文章就把这套DirectSound音乐播放器的VC代码完整梳理一遍适合刚接触Windows音频编程的初学者也适合需要在老项目里快速嵌入播放功能的朋友参考。1. 技术选型为什么还用DirectSound写播放器1.1 DirectSound到底能做什么DirectSound是DirectX家族里负责音频播放的一套COM接口它统治了Windows平台很长一段时间从DirectX 3一路走到DirectX 8接口形态基本稳定下来。它的核心工作是管理声卡发声把PCM音频数据发送到混音设备同时对外提供音量调节、声像控制、播放频率修改、循环播放这些能力。用大白话说你给它一段“未经压缩的原始声音数据”它就能按你设定的采样率、声道数、位深把这些数据变成你能听见的声音。它还支持多个音源同时混音也就是游戏里比较典型的使用场景背景音乐一路音源开枪音效一路音源脚步声一路音源最后混在一起输出。在音乐播放器这个项目里我需要的东西很明确一个能放WAV文件的播放器支持播放、暂停、停止、音量调节最好还能拿到当前播放位置来做进度条。DirectSound基本都能满足而且它不像MCI那样把整个播放流程包成一个黑盒你直接拿到缓冲区读写权限这对理解音频播放链路非常有帮助。1.2 备选方案对比与选型结论Windows下播放音频的方案很多简单做一张对比表大家按自己场景选。方案原理优点缺点适合场景MCI播放器命令接口底层由系统完成使用简单支持格式多黑盒式控制无法精确到字节快速播放MP3/视频WaveOut经典波形输出函数支持PCM可控性强延迟较高扩展功能少学习音频原理DirectSoundDirectX 8音频接口缓冲区混音文档多资料丰富支持混音老接口新项目不推荐教育、老项目兼容WASAPIWindows新一代音频接口音质好延迟低代码复杂度高专业播放器XAudio2DirectSound继任者性能强支持多格式主要面向游戏配置繁琐游戏音效我在这个项目里选DirectSound原因很简单一是项目本身就是VC技术栈用MFC做界面DirectSound和MFC配合没有额外的运行时依赖二是DirectSound的错误机制很直白做调试比WASAPI那种层层封装的方式好定位问题三是这套代码以后要迁移到XAudio2概念上也有不少相通的地方不算白学。注意如果你是从零开始做新项目我更推荐直接用WASAPI或XAudio2。但DirectSound的“缓冲区 播放控制”这套思想不会白学很多后续API都沿用类似模型。2. 播放器整体设计从WAV文件到声卡的完整链路2.1 播放器模块划分我在写代码之前把播放器拆成了几个清晰的模块这个习惯对后续调试帮助很大。整个程序分为三层界面层、控制层、设备层。界面层就是MFC对话框放“播放”“暂停”“停止”“音量”按钮和进度条控制层是核心类CDirectSoundPlayer它封装DirectSound所有操作对外暴露Init、LoadWaveFile、Play、Pause、Resume、Stop、SetVolume这些方法设备层就是DirectSound API本身。控制层内部再细化又分成三个子模块设备初始化模块负责创建DirectSound对象、设置协作级别、配置主缓冲区数据加载模块负责读取WAV文件、解析RIFF块、提取PCM数据播放控制模块负责创建次缓冲区、写入数据、播放和暂停恢复。这样分层的好处是界面层完全不知道DirectSound的存在哪怕以后把播放核心换成XAudio2界面代码一行都不用改。我这次在复盘时强烈建议大家也这样分层直接在一个窗口过程中写播放逻辑后期维护真的很痛苦。2.2 WAV文件的解析要点DirectSound不负责解析音频格式它只认PCM原始数据。所以播放器必须自己从WAV文件中把PCM数据和格式信息解析出来。WAV文件遵循RIFF结构整体是一个大块里面嵌套多个子块。我只需要两个子块一个是fmt块记录采样率、声道数、位深等格式参数另一个是data块存放真正的PCM音频数据。有个细节容易翻车有些人习惯直接按固定偏移读取WAV默认“fmt在12字节data在后面固定位置”。但实际很多WAV文件还会带上LIST块、fact块、JUNK块data块的位置并不是固定的。正确做法是遍历所有子块根据每个块的FOURCC标识决定处理方式。我用的解析思路是这样的从文件偏移12开始每次读取一个子块头8字节前4字节是块标识后4字节是块大小然后跳到下一个子块。找到fmt块就拷贝格式信息找到data块就拷贝PCM数据然后就可以结束遍历。DWORD dwPos 12; while (dwPos 8 dwFileLen) { DWORD dwFcc *(DWORD*)(pRaw dwPos); // 4字节块标识 DWORD dwSize *(DWORD*)(pRaw dwPos 4); // 4字节块大小 if (dwFcc 0x20746D66) // 小端字节序的 fmt { // 拷贝WAVEFORMATEX结构 memcpy(m_Wfx, pRaw dwPos 8, min(dwSize, sizeof(WAVEFORMATEX))); m_Wfx.cbSize 0; } else if (dwFcc 0x61746164) // 小端字节序的 data { m_pWaveData new BYTE[dwSize]; memcpy(m_pWaveData, pRaw dwPos 8, dwSize); m_dwDataSize dwSize; break; } dwPos 8 dwSize (dwSize 1); // 子块按偶数对齐 }块标识的十六进制值看起来很奇怪因为我在小端机器上用DWORD直接比较实际内存顺序是fmt 转成整数就是0x20746D66。这里我不建议用memcmp去比较字符串速度慢且容易写错直接比DWORD最稳妥。还需要注意一个兼容性问题fmt块里的wFormatTag如果是1表示标准PCM但有些软件生成的WAV会用WAVE_FORMAT_EXTENSIBLE值为0xFFFE真实格式保存在扩展部分中。如果解析到这种文件需要进一步读取扩展字段否则DirectSound创建缓冲区会失败。我做的简单处理是如果wFormatTag不是PCM就弹窗提示用户转换格式先把功能跑通再说。3. VC代码实现核心函数逐段拆解3.1 初始化DirectSound设备与主缓冲区初始化的第一步是创建DirectSound对象。DirectSoundCreate8这个全局函数会返回一个IDirectSound8接口指针这是后面所有操作的基础。BOOL CDirectSoundPlayer::Init(HWND hWnd) { HRESULT hr DirectSoundCreate8(NULL, m_pDS, NULL); if (FAILED(hr)) { SetLastError(hr); return FALSE; } hr m_pDS-SetCooperativeLevel(hWnd, DSSCL_PRIORITY); if (FAILED(hr)) { m_pDS-Release(); m_pDS NULL; return FALSE; } return TRUE; }这里有两个细节值得展开讲。第一DirectSoundCreate8的第一个参数传NULL表示使用系统默认音频设备。如果你的项目有多个音频设备需要枚举后再指定GUID选择设备但调用方传入的设备GUID必须和实际声卡硬件匹配否则初始化会失败。第二SetCooperativeLevel的协作级别参数很关键。我一开始用的DSSCL_NORMAL结果发现主缓冲区格式怎么都设置不成功。后来查资料才知道DSSCL_NORMAL下DirectSound会用比较保守的混音模式不允许修改主缓冲区格式改成DSSCL_PRIORITY后才有权限调整主缓冲区采样率、位深这些参数。如果你用DSSCL_EXCLUSIVE还可以独占设备但单机播放器没必要反而可能和其他程序抢声音。主缓冲区创建的目的其实是为了“定基准”告诉声卡以什么采样率、位深混音。次缓冲区里的数据格式如果不一样DirectSound在播放时会自动做转换。为了让转换开销最小我让主缓冲区和WAV文件保持一致通常都是44.1kHz、16位、双声道。LPDIRECTSOUNDBUFFER pPrimary NULL; DSBUFFERDESC dsbdesc; ZeroMemory(dsbdesc, sizeof(DSBUFFERDESC)); dsbdesc.dwSize sizeof(DSBUFFERDESC); dsbdesc.dwFlags DSBCAPS_PRIMARYBUFFER; hr m_pDS-CreateSoundBuffer(dsbdesc, pPrimary, NULL); if (FAILED(hr)) return FALSE; WAVEFORMATEX wfx; ZeroMemory(wfx, sizeof(wfx)); wfx.wFormatTag WAVE_FORMAT_PCM; wfx.nChannels 2; wfx.nSamplesPerSec 44100; wfx.wBitsPerSample 16; wfx.nBlockAlign (WORD)((wfx.nChannels * wfx.wBitsPerSample) / 8); wfx.nAvgBytesPerSec wfx.nSamplesPerSec * wfx.nBlockAlign; hr pPrimary-SetFormat(wfx); pPrimary-Release();注意nBlockAlign和nAvgBytesPerSec这两个字段必须正确否则后面创建次缓冲区和计算播放时间都会出问题。nBlockAlign表示一个采样帧的字节数双声道16位就是4字节nAvgBytesPerSec表示每秒音频数据的字节数等于采样率乘以nBlockAlign。3.2 加载音频数据到次缓冲区解析完WAV文件后数据还在本地BYTE数组里需要把它搬到DirectSound的次缓冲区。次缓冲区相当于音频数据的暂存仓库DirectSound播放时会持续从这个仓库读数据。创建次缓冲区前先声明DSBUFFERDESC结构关键字段有三个dwFlags、dwBufferBytes、lpwfxFormat。DSBUFFERDESC desc; ZeroMemory(desc, sizeof(desc)); desc.dwSize sizeof(DSBUFFERDESC); desc.dwFlags DSBCAPS_GLOBALFOCUS | DSBCAPS_CTRLVOLUME; desc.dwBufferBytes m_dwDataSize; desc.lpwfxFormat m_Wfx; LPDIRECTSOUNDBUFFER pBuffer NULL; hr m_pDS-CreateSoundBuffer(desc, pBuffer, NULL); if (FAILED(hr)) return FALSE; m_pBuffer pBuffer;dwFlags这里我加了两个DSBCAPS_GLOBALFOCUS让程序即使不在前台也能继续播放声音这个在工业控制软件里很重要因为界面焦点可能一直在别的窗口上DSBCAPS_CTRLVOLUME是音量调节的开关没有这个标志SetVolume调用直接返回错误。缓冲区创建成功后用Lock函数锁定内存把PCM数据拷贝进去再Unlock解锁。Lock可能返回两段地址这是因为DirectSound底层缓冲区是环形的跨过缓冲区末尾时会分成两段。很多初学者只处理第一段导致缓冲区后半部分没数据播放到一半就没声了。LPVOID pData1 NULL, pData2 NULL; DWORD dwLen1 0, dwLen2 0; hr m_pBuffer-Lock(0, m_dwDataSize, pData1, dwLen1, pData2, dwLen2, 0); if (SUCCEEDED(hr)) { memcpy(pData1, m_pWaveData, dwLen1); if (pData2 dwLen2 0) memcpy(pData2, m_pWaveData dwLen1, dwLen2); m_pBuffer-Unlock(pData1, dwLen1, pData2, dwLen2); }这里再补充一个性能方面的理解当前方案是一次性把整首WAV载入次缓冲区内存占用比较大。一首4分钟的44.1kHz 16位双声道WAV大约40MBWin32环境下还过得去但如果是长音频就撑不住了。更专业的做法是分块加载每块几百KB播放到缓冲区末尾时再加载下一块这种流式播放是通过通知位置实现的以后有机会单独写一篇。3.3 播放、暂停、恢复与停止的实现细节播放和停止都很简单核心就是Play和Stop两个接口。void CDirectSoundPlayer::Play(BOOL bLoop) { m_bLoop bLoop; if (m_pBuffer) m_pBuffer-Play(0, 0, m_bLoop ? DSBPLAY_LOOPING : 0); } void CDirectSoundPlayer::Stop() { if (m_pBuffer) m_pBuffer-Stop(); m_dwPausePos 0; }Play的第三个参数传DSBPLAY_LOOPING就是循环播放传0就是单次播放。这个标志位直接配套我们常见的“单曲循环”按钮。真正的坑在暂停和恢复。DirectSound没有像WaveOut那样直接提供pause/resume接口调用Stop后再次Play会从头播放因为播放位置在Stop时被重置。要实现“暂停后从原位置恢复”我的做法是暂停时先GetCurrentPosition拿到当前播放到的字节偏移然后Stop把这个偏移存下来恢复时把缓冲区里的PCM数据做一次环形搬运让保存的偏移变成缓冲区的头部再Play。void CDirectSoundPlayer::Pause() { DWORD dwPlayPos 0; m_pBuffer-GetCurrentPosition(dwPlayPos, NULL); m_dwPausePos dwPlayPos; m_pBuffer-Stop(); } void CDirectSoundPlayer::Resume() { if (m_dwPausePos 0) { m_pBuffer-Play(0, 0, m_bLoop ? DSBPLAY_LOOPING : 0); return; } // 先锁定整个缓冲区把数据读出来 DWORD dwBufSize m_dwDataSize; BYTE* pTemp new BYTE[dwBufSize]; BYTE* pNew new BYTE[dwBufSize]; LPVOID p1 NULL, p2 NULL; DWORD l1 0, l2 0; m_pBuffer-Lock(0, 0, p1, l1, p2, l2, DSBLOCK_ENTIREBUFFER); memcpy(pTemp, p1, l1); if (p2) memcpy(pTemp l1, p2, l2); m_pBuffer-Unlock(p1, l1, p2, l2); // 环形搬移把暂停位置之后的数据放到头部之前的数据放到尾部 memcpy(pNew, pTemp m_dwPausePos, dwBufSize - m_dwPausePos); memcpy(pNew dwBufSize - m_dwPausePos, pTemp, m_dwPausePos); // 写回缓冲区 m_pBuffer-Lock(0, 0, p1, l1, p2, l2, DSBLOCK_ENTIREBUFFER); memcpy(p1, pNew, l1); if (p2) memcpy(p2, pNew l1, l2); m_pBuffer-Unlock(p1, l1, p2, l2); m_dwPausePos 0; m_pBuffer-Play(0, 0, m_bLoop ? DSBPLAY_LOOPING : 0); delete[] pTemp; delete[] pNew; }这个实现需要做两次整块内存搬运时间复杂度O(n)。在一首40MB的WAV上执行会有几十毫秒的卡顿对桌面播放器来说完全可以接受但如果做专业音乐播放器就不太优雅了。更优的方案是流式分块加载暂停时记录的是块索引和块内偏移恢复时从那个精确位置重新填充缓冲区这个思路大家可以自己延伸一下。音量控制这块也比较直观。DirectSound的音量单位是百分之一分贝1/100 dB取值范围从-10000到0-10000表示静音0表示原始音量。调用SetVolume传入一个介于这两个值之间的整数即可。void CDirectSoundPlayer::SetVolume(LONG lVolume) { if (m_pBuffer NULL) return; if (lVolume -10000) lVolume -10000; if (lVolume 0) lVolume 0; m_pBuffer-SetVolume(lVolume); }这里提醒一句SetVolume本身不会失败但如果创建次缓冲区时没有加DSBCAPS_CTRLVOLUME标志这个调用会返回错误码而且你很难察觉。我建议调试阶段在SetVolume后面用ASSERT或OutputDebugString输出返回值能少走很多弯路。播放位置的计算公式也很简单DWORD dwPos 0; m_pBuffer-GetCurrentPosition(dwPos, NULL); double dSeconds dwPos / (double)m_Wfx.nAvgBytesPerSec;注意GetCurrentPosition拿到的播放位置是“已经播放过的字节数”除以nAvgBytesPerSec就能得到秒数。做进度条时拿这个值和总时长做比例运算即可。4. Visual Studio工程配置与常见问题排查4.1 Visual Studio中的工程配置步骤很多人在编码阶段就被工程配置卡住了其实DirectSound在Visual Studio 2017/2019里配置非常简单因为相关头文件和库已经集成到Windows SDK里不需要额外安装完整的DirectX SDK。我创建的工程类型是MFC对话框应用用x86平台编译项目里可能有老版本DLL依赖这个根据你自己的情况来。需要做的事情只有三件第一在stdafx.h或pch.h里引入DirectSound头文件#include dsound.h第二告诉链接器去链接dsound.lib和dxguid.lib。有两种方式简单一点是代码里直接用预处理指令#pragma comment(lib, dsound.lib) #pragma comment(lib, dxguid.lib)也可以在项目属性 - 链接器 - 输入 - 附加依赖项里加这两个lib。我习惯用#pragma的方式这样代码拷贝到哪里都不会丢依赖。第三确认目标平台是x86还是x64。DirectSound在64位系统上也能用但库路径不同。如果你的程序要调用某些32位的第三方库平台就得选x86。我遇到的坑是用x64编译一切正常但我那个老项目的某个COM组件只有32位版最后被迫改成x86编译DirectSound本身没有受影响。4.2 常见现象与排查清单我把调试过程中遇到的典型问题整理成一张表方便大家直接对号入座。现象可能原因解决方法播放按钮按下去没有声音协作级别设置错误或主缓冲区格式设置失败检查SetCooperativeLevel是否用DSSCL_PRIORITY检查主缓冲区SetFormat返回值播到一半声音消失Lock写入只处理了第一段地址正确判断并处理两段缓冲地址循环播放时中间有停顿缓冲区数据在播放过程中被修改或者Play标志没传DSBPLAY_LOOPING确认PLAY的flags正确确认没有在播放时往缓冲区写数据SetVolume不起作用创建次缓冲区没加DSBCAPS_CTRLVOLUME重建缓冲区并补上该标志播放WAV失败提示格式不支持WAV是WAVE_FORMAT_EXTENSIBLE或其他压缩格式统一转成标准PCM或用解码器转码后再播放暂停再恢复后从头播放直接调用Stop后Play没有处理播放位置按文中Resume方案做环形搬移程序切到后台就没声音次缓冲区没加DSBCAPS_GLOBALFOCUS创建缓冲区时补上该标志还有一个让我印象比较深的问题如果同时播放多个声音比如背景音乐加提示音DirectSound的主缓冲区混音会要求次缓冲区采样率尽量一致。我最初没统一采样率提示音是8kHz采样率的WAV背景音乐是44.1kHz播放时提示音明显变调。后来我用SetFrequency把次缓冲区频率调成和主缓冲区一致才正常。所以如果你的播放器要做多音源提前统一格式能少很多麻烦。播放进度这一块也补充一句GetCurrentPosition返回的是环形缓冲区当前读取位置如果你的缓冲区是流式分块加载的这个位置需要你自己加上已经加载过的数据块大小才能换算成真实时间。一次整文件加载的方案就没有这个问题位置即时间。关于这套代码的几句总结说实话DirectSound这套接口放到今天确实老了微软官方也早就不推荐新项目使用但它把音频播放的底层原理展示得非常清楚设备初始化、格式协商、缓冲区写入、播放控制、位置获取每个环节都透明可见。我在做完这个播放器之后再回头看WASAPI和XAudio2感觉理解起来快了很多因为它们本质上还是“缓冲区 播放控制”那套模型只是换了接口和数据流方式。如果你自己动手实现我建议先从一个简化的控制台版本开始只演奏一个WAV跑通后再加暂停、进度条、音量这些功能。这样出现问题时排查范围更小。调试的时候多利用返回值每一处HRESULT都检查一下我踩过的坑大部分都是忽略了某些接口返回的警告码导致的。希望这篇文章能帮你顺着这条路少走一点弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进