核實(shí)現(xiàn):從緩沖區(qū)管理到播放控制)
簡(jiǎn)介一份基于 DirectSound8 的 VC 音樂(lè)播放器示例工程適合需要快速上手 Windows 音頻編程的開(kāi)發(fā)者。資源圍繞 DirectSound 的核心調(diào)用流程展開(kāi)從 CoCreateInstance 初始化接口、設(shè)置協(xié)作等級(jí)到創(chuàng)建主/次緩沖區(qū)、加載并寫(xiě)入音頻數(shù)據(jù)、循環(huán)播放與進(jìn)度監(jiān)控同時(shí)演示了 SetVolume 音量控制和 Stop 停止播放最后提供 HRESULT 錯(cuò)誤檢查與資源釋放示例。壓縮包共 23 個(gè)文件、約 1.07MB包含 5 個(gè) .h 頭文件、4 個(gè) .cpp 源文件、2 個(gè) .wav 測(cè)試音頻以及 .dsw/.dsp 工程文件和可運(yùn)行的 .exe 演示程序。工程結(jié)構(gòu)緊湊便于對(duì)照代碼理解 DirectSound API 的調(diào)用順序與緩沖區(qū)管理邏輯也可作為課程設(shè)計(jì)或小型音樂(lè)播放器項(xiàng)目的改造基礎(chǔ)。已有 257 人下載學(xué)習(xí)。 做Windows桌面音頻播放器繞不開(kāi)DirectSound。這個(gè)項(xiàng)目我用VC寫(xiě)了一個(gè)基于DirectSound的本地音樂(lè)播放器內(nèi)核支持WAV/PCM等波形音頻的播放、暫停、停止、音量控制、進(jìn)度查詢(xún)整套代碼不依賴(lài)MFC也能跑拿它當(dāng)學(xué)習(xí)模板或者改造成自己的播放器底座都非常合適。無(wú)論你是剛學(xué)Win32編程想搞懂音頻播放原理還是需要在項(xiàng)目中集成底層音頻播放能力這篇都值得看完。1. 為什么選DirectSound而不是其他音頻方案1.1 音頻API選型的糾結(jié)與對(duì)比開(kāi)始動(dòng)手之前我先把Windows平臺(tái)可用的音頻API盤(pán)了一遍。API延遲多緩沖音量/平衡控制硬件加速適合場(chǎng)景MCI高不支持弱無(wú)簡(jiǎn)單播放waveOut中差弱無(wú)傳統(tǒng)/兼容性DirectSound低強(qiáng)強(qiáng)支持游戲/播放器WASAPI極低中強(qiáng)無(wú)現(xiàn)代高保真MCI實(shí)現(xiàn)起來(lái)最快幾行代碼就能播放WAV但控制粒度太粗想獲得精確定位、平滑暫停、多聲道混音完全不夠用。waveOut是經(jīng)典方案但緩沖區(qū)管理和回調(diào)機(jī)制比較老寫(xiě)起來(lái)繁瑣遇到高采樣率數(shù)據(jù)容易出亂子。DirectSound的優(yōu)勢(shì)在于延遲低、支持硬件混合、控制能力強(qiáng)而且API設(shè)計(jì)思路很清晰特別適合做播放器內(nèi)核。1.2 DirectSound的核心競(jìng)爭(zhēng)力DirectSound最吸引我的一點(diǎn)是雙緩沖設(shè)計(jì)。它把音頻數(shù)據(jù)放在一個(gè)環(huán)形緩沖區(qū)里你可以一邊往里面寫(xiě)數(shù)據(jù)聲卡一邊從前面讀數(shù)據(jù)播放互不干擾。再加上位置通知notify機(jī)制能在緩沖區(qū)播放到特定位置時(shí)觸發(fā)事件這相當(dāng)于給你一個(gè)“接力棒”告訴你可以繼續(xù)填充數(shù)據(jù)了。這種機(jī)制讓音頻播放非常順滑也不會(huì)因?yàn)閱尉€程讀寫(xiě)沖突產(chǎn)生爆音。對(duì)于我這個(gè)播放器項(xiàng)目來(lái)說(shuō)DirectSound還提供了音量、左右聲道平衡、采樣率轉(zhuǎn)換等控制能力這些功能如果自己實(shí)現(xiàn)工作量不是一般的大。2. 環(huán)境準(zhǔn)備與初始化流程2.1 開(kāi)發(fā)環(huán)境與SDK剛接觸VC的朋友可能會(huì)被環(huán)境配置卡住。我用的是Visual Studio 2017創(chuàng)建的是空項(xiàng)目手動(dòng)添加Win32窗口代碼這樣能避免MFC那堆自動(dòng)生成的模板干擾學(xué)習(xí)。DirectSound的開(kāi)發(fā)依賴(lài)DirectX SDK。老版本DirectX SDKJune 2010已經(jīng)停止更新但在Windows 10/11上配合VS2017仍然能用只需要在項(xiàng)目屬性里把include和lib路徑指正確就行。如果你不想裝完整SDK也可以直接用Windows SDK自帶的dsound.h和dsound.lib只是版本略舊基礎(chǔ)功能完全夠用。鏈接時(shí)需要添加這些庫(kù)dsound.lib、dxguid.lib另外如果是Win32窗口程序還需要winmm.lib。2.2 核心API與數(shù)據(jù)結(jié)構(gòu)DirectSound常用對(duì)象有兩個(gè)IDirectSound8是設(shè)備對(duì)象負(fù)責(zé)創(chuàng)建聲音緩沖區(qū)IDirectSoundBuffer8是緩沖區(qū)對(duì)象對(duì)應(yīng)一個(gè)聲音數(shù)據(jù)容器。創(chuàng)建緩沖區(qū)需要先填一個(gè)DSBUFFERDESC結(jié)構(gòu)體里面的重點(diǎn)字段包括緩沖區(qū)大小、控制標(biāo)志、WAV格式描述。WAVEFORMATEX結(jié)構(gòu)體則用來(lái)描述音頻數(shù)據(jù)的編碼方式、采樣率、位深、聲道數(shù)。這幾個(gè)結(jié)構(gòu)體是DirectSound播放器的地基把它們理解透了后面所有代碼就水到渠成。2.3 DirectSound初始化代碼初始化設(shè)備對(duì)象的核心代碼如下#include windows.h #include dsound.h #include mmsystem.h LPDIRECTSOUND8 g_pDS8 NULL; LPDIRECTSOUNDBUFFER8 g_pDSB8 NULL; BOOL InitDirectSound(HWND hWnd) { HRESULT hr DirectSoundCreate8(NULL, g_pDS8, NULL); if (FAILED(hr)) return FALSE; // 設(shè)置協(xié)作級(jí)別DSSCL_PRIORITY允許我們創(chuàng)建次級(jí)緩沖 hr g_pDS8-SetCooperativeLevel(hWnd, DSSCL_PRIORITY); if (FAILED(hr)) return FALSE; return TRUE; }這里有個(gè)關(guān)鍵點(diǎn)SetCooperativeLevel的協(xié)作級(jí)別必須設(shè)成DSSCL_PRIORITY或DSSCL_EXCLUSIVE否則創(chuàng)建帶音量控制的緩沖區(qū)會(huì)失敗。這也是很多新手初始化后死活沒(méi)聲音的原因之一。3. 播放核心模塊緩沖管理與數(shù)據(jù)填充3.1 主緩沖與次級(jí)緩沖的設(shè)計(jì)DirectSound里有主緩沖和次級(jí)緩沖之分。主緩沖相當(dāng)于聲卡的“總出口”只能播放混合后的數(shù)據(jù)真正承載你音頻文件數(shù)據(jù)的是次級(jí)緩沖。播放器場(chǎng)景通常只需要一個(gè)次級(jí)緩沖就夠了沒(méi)必要直接操作主緩沖。創(chuàng)建次級(jí)緩沖需要指定格式和大小。我用的緩沖區(qū)大小是播放數(shù)據(jù)量的一半或四分之一配合notify機(jī)制拆成兩塊輪詢(xún)填充。緩沖區(qū)太大延遲明顯太小則填充頻繁建議先用1到2秒的數(shù)據(jù)量比如44.1kHz立體聲16bit約176KB/秒緩沖區(qū)可以取512KB到1MB實(shí)測(cè)下來(lái)均衡性最好。BOOL CreateSecondaryBuffer(WAVEFORMATEX* pwfx, DWORD dwBufferBytes) { DSBUFFERDESC dsbd {0}; dsbd.dwSize sizeof(DSBUFFERDESC); dsbd.dwFlags DSBCAPS_CTRLVOLUME | DSBCAPS_CTRLPOSITIONNOTIFY | DSBCAPS_GLOBALFOCUS; dsbd.dwBufferBytes dwBufferBytes; dsbd.lpwfxFormat pwfx; LPDIRECTSOUNDBUFFER pDSB NULL; HRESULT hr g_pDS8-CreateSoundBuffer(dsbd, pDSB, NULL); if (FAILED(hr)) return FALSE; // 獲取8版本接口支持notify hr pDSB-QueryInterface(IID_IDirectSoundBuffer8, (LPVOID*)g_pDSB8); pDSB-Release(); return SUCCEEDED(hr); }DSBCAPS_GLOBALFOCUS這個(gè)標(biāo)志要特別注意它讓緩沖區(qū)在窗口失去焦點(diǎn)時(shí)繼續(xù)播放否則你得時(shí)刻保證窗口在前臺(tái)用戶(hù)體驗(yàn)就差了。DSBCAPS_CTRLVOLUME則讓它支持音量調(diào)節(jié)。3.2 notify機(jī)制與播放線程播放數(shù)據(jù)的核心挑戰(zhàn)是什么時(shí)候往緩沖區(qū)寫(xiě)新數(shù)據(jù)輪詢(xún)CPU占用高且不精確DirectSound提供了IDirectSoundNotify8接口可以在緩沖區(qū)播放到指定位置時(shí)觸發(fā)Win32事件。把緩沖區(qū)分成四等份每份播完觸發(fā)一次事件播放線程阻塞等待事件事件來(lái)了就填充對(duì)應(yīng)區(qū)塊。HANDLE g_hNotifyEvents[4]; BOOL SetupNotify() { IDirectSoundNotify8* pNotify NULL; HRESULT hr g_pDSB8-QueryInterface(IID_IDirectSoundNotify8, (LPVOID*)pNotify); if (FAILED(hr)) return FALSE; DSBPOSITIONNOTIFY pn[4]; DWORD blockBytes g_dwBufferBytes / 4; for (int i 0; i 4; i) { g_hNotifyEvents[i] CreateEvent(NULL, FALSE, FALSE, NULL); pn[i].dwOffset i * blockBytes; pn[i].hEventNotify g_hNotifyEvents[i]; } hr pNotify-SetNotificationPositions(4, pn); pNotify-Release(); return SUCCEEDED(hr); }為什么我用4個(gè)事件而不是2個(gè)4等份能減少每次填充的數(shù)據(jù)量讓CPU負(fù)載更均勻尤其在讀取慢或磁盤(pán)卡頓的時(shí)候容錯(cuò)空間更大。當(dāng)然2等份也完全可行大家可以根據(jù)播放音頻時(shí)長(zhǎng)自行調(diào)整。3.3 數(shù)據(jù)填充邏輯與環(huán)形播放當(dāng)事件觸發(fā)播放線程就需要往對(duì)應(yīng)區(qū)塊寫(xiě)入音頻數(shù)據(jù)。這里的關(guān)鍵是正確計(jì)算偏移并且把文件讀取游標(biāo)和緩沖區(qū)游標(biāo)對(duì)齊保證環(huán)形播放無(wú)縫銜接。DWORD WINAPI PlayThreadProc(LPVOID lpParam) { DWORD blockBytes g_dwBufferBytes / 4; while (g_bPlaying) { DWORD dwWait WaitForMultipleObjects(4, g_hNotifyEvents, FALSE, INFINITE); if (dwWait WAIT_OBJECT_0 dwWait WAIT_OBJECT_0 4) { int idx dwWait - WAIT_OBJECT_0; DWORD offset idx * blockBytes; FillBufferData((BYTE*)g_pData g_dwReadPos, blockBytes, offset); } } return 0; } void FillBufferData(BYTE* pSrc, DWORD dwSize, DWORD dwOffset) { LPVOID pBuf1 NULL, pBuf2 NULL; DWORD dwSize1 0, dwSize2 0; if (dwOffset dwSize g_dwBufferBytes) { // 跨越緩沖區(qū)結(jié)尾需要拆成兩段寫(xiě) HRESULT hr1 g_pDSB8-Lock(dwOffset, g_dwBufferBytes - dwOffset, pBuf1, dwSize1, pBuf2, dwSize2, 0); if (SUCCEEDED(hr1)) { memcpy(pBuf1, pSrc, dwSize1); if (pBuf2) memcpy(pBuf2, pSrc dwSize1, dwSize2); g_pDSB8-Unlock(pBuf1, dwSize1, pBuf2, dwSize2); } } else { HRESULT hr g_pDSB8-Lock(dwOffset, dwSize, pBuf1, dwSize1, pBuf2, dwSize2, 0); if (SUCCEEDED(hr)) { memcpy(pBuf1, pSrc, dwSize1); g_pDSB8-Unlock(pBuf1, dwSize1, pBuf2, dwSize2); } } g_dwReadPos dwSize; }想起當(dāng)年第一次寫(xiě)的時(shí)候忽略了數(shù)據(jù)繞到緩沖區(qū)末尾的情況導(dǎo)致每次播放到后半段就出現(xiàn)“咯噔”一聲的爆音那就是Lock時(shí)沒(méi)處理分段拷貝的后果。這里L(fēng)ock返回兩個(gè)指針的場(chǎng)景就是DirectSound告訴你要跨邊界了別偷懶跳過(guò)。4. 控制功能實(shí)現(xiàn)與交互細(xì)節(jié)4.1 播放、暫停、停止的狀態(tài)機(jī)播放器控件之間的狀態(tài)切換必須邏輯嚴(yán)謹(jǐn)否則會(huì)出現(xiàn)“暫停后又從開(kāi)頭播”“停止后線程還在跑”等詭異的BUG。我維護(hù)了一個(gè)簡(jiǎn)單狀態(tài)枚舉enum PlayState { STATE_STOPPED, STATE_PLAYING, STATE_PAUSED }; PlayState g_playState STATE_STOPPED;播放重置讀位置到起點(diǎn)依次調(diào)用SetCurrentPosition(0)、Play(0, 0, DSBPLAY_LOOPING)同時(shí)創(chuàng)建播放線程。暫停調(diào)用g_pDSB8-Stop()線程繼續(xù)阻塞等待但不能再填充數(shù)據(jù)?;謴?fù)直接再次調(diào)用Play。停止設(shè)置退出標(biāo)志等線程結(jié)束后Stop()并釋放緩沖區(qū)。有個(gè)小坑Play的第三個(gè)參數(shù)如果傳DSBPLAY_LOOPING播放到緩沖區(qū)末尾會(huì)自動(dòng)循環(huán)播。如果你不想讓它自動(dòng)循環(huán)就得在這個(gè)點(diǎn)手動(dòng)停止。我通常固定用LOOPING模式靠線程控制數(shù)據(jù)內(nèi)容這樣環(huán)形填充最穩(wěn)。4.2 音量、平衡和進(jìn)度查詢(xún)DirectSound的音量控制單位是百分之一分貝-10000到0-1000表示比原始音量低10dB。用戶(hù)操作時(shí)我先把滑桿值0-100映射成對(duì)數(shù)刻度更符合人耳聽(tīng)覺(jué)。void SetVolume(int nLevel) // 0~100 { long lVolume -10000; if (nLevel 0) { double dB 40.0 * (log10((double)nLevel / 100.0)); lVolume (long)(dB * 100.0); } g_pDSB8-SetVolume(lVolume); } void SetPan(int nBalance) // -100~100 { long lPan nBalance * 100; g_pDSB8-SetPan(lPan); }進(jìn)度查詢(xún)用GetCurrentPosition第一個(gè)參數(shù)返回當(dāng)前播放位置字節(jié)第二個(gè)參數(shù)可以取寫(xiě)入游標(biāo)位置DWORD dwPlayPos 0; g_pDSB8-GetCurrentPosition(dwPlayPos, NULL); double progress (double)dwPlayPos / g_dwBufferBytes * 100;注意這個(gè)位置是相對(duì)緩沖區(qū)頭部的如果你的文件數(shù)據(jù)被拆成多段需要累加播放次數(shù)后才能計(jì)算總時(shí)長(zhǎng)我這邊是直接維護(hù)g_dwTotalPlayed變量事件每次觸發(fā)時(shí)累加區(qū)塊大小這樣算總進(jìn)度既簡(jiǎn)單又準(zhǔn)確。5. 常見(jiàn)問(wèn)題與實(shí)戰(zhàn)排查5.1 典型錯(cuò)誤速查表這幾類(lèi)錯(cuò)誤是DirectSound開(kāi)發(fā)者最容易遇到的基本占據(jù)了我調(diào)試時(shí)間的八成現(xiàn)象可能原因解決方案初始化失敗協(xié)作級(jí)別未設(shè)或設(shè)備被獨(dú)占確認(rèn)DSSCL_PRIORITY釋放其他程序占用沒(méi)有聲音未設(shè)置GLOBALFOCUS或鎖數(shù)據(jù)失敗檢查L(zhǎng)ock返回值手動(dòng)播一段測(cè)試音爆音/咯咯聲跨邊界未分段寫(xiě)Lock后檢查兩個(gè)指針?lè)侄蝝emcpy停頓/卡頓緩沖區(qū)太小或文件讀取慢增大緩沖區(qū)減少每次拷貝耗時(shí)暫停后聲音變調(diào)未及時(shí)停止線程數(shù)據(jù)填充暫停時(shí)設(shè)置標(biāo)志位禁止繼續(xù)寫(xiě)數(shù)據(jù)音量滑桿無(wú)效緩沖區(qū)沒(méi)有DSBCAPS_CTRLVOLUME創(chuàng)建時(shí)添加該標(biāo)志5.2 噪音、底噪與爆音排查爆音是音頻開(kāi)發(fā)里最磨人的問(wèn)題。一個(gè)字總結(jié)就是“不同步”。第一次遇到爆音我以為是硬件問(wèn)題后來(lái)發(fā)現(xiàn)是SetCurrentPosition和Lock之間數(shù)據(jù)錯(cuò)位。更隱蔽的原因是暫停/恢復(fù)時(shí)DirectSound內(nèi)部游標(biāo)有個(gè)極小的延遲導(dǎo)致重新播放的瞬間出現(xiàn)數(shù)據(jù)重疊。我的處理方案是每次暫停或停止時(shí)把整個(gè)緩沖區(qū)清零恢復(fù)前再?gòu)念^填充前幾塊數(shù)據(jù)等于給播放器一個(gè)“干凈起點(diǎn)”。底噪問(wèn)題通常是數(shù)據(jù)對(duì)齊問(wèn)題。比如16bit雙聲道、每個(gè)采樣4字節(jié)如果寫(xiě)入數(shù)據(jù)的末尾沒(méi)有按4字節(jié)對(duì)齊就會(huì)產(chǎn)生持續(xù)微小雜音。處理方式是在讀取WAV文件時(shí)嚴(yán)格按nBlockAlign字段 聲道數(shù)×位深/8對(duì)齊不足的無(wú)法成為有效采樣填0補(bǔ)齊。5.3 卡頓與撕裂聲優(yōu)化卡頓很多時(shí)候不是DirectSound的鍋而是主線程讀取文件時(shí)的磁盤(pán)IO阻塞。播放線程里千萬(wàn)別做復(fù)雜的解碼、字符串拼接或大內(nèi)存分配盡量只做內(nèi)存拷貝。如果播放WAV還好解碼MP3就麻煩了我一般用一個(gè)生產(chǎn)者線程專(zhuān)門(mén)讀文件/解碼再放到一個(gè)環(huán)形內(nèi)存隊(duì)列里播放線程只從這個(gè)隊(duì)列取數(shù)據(jù)兩者完全解耦。這樣即便解碼慢播放線程也不會(huì)被拖死頂多緩沖區(qū)空了有短暫停頓不會(huì)從頭“撕裂”。實(shí)測(cè)下來(lái)還有個(gè)經(jīng)驗(yàn)整個(gè)播放線程的優(yōu)先級(jí)設(shè)為T(mén)HREAD_PRIORITY_HIGHEST會(huì)明顯降低崩潰筆記本上的掉幀卡頓但注意不要在播放線程里Sleep那會(huì)讓緩沖區(qū)迅速見(jiàn)底。5.4 調(diào)試心得調(diào)試DirectSound程序的時(shí)候我習(xí)慣開(kāi)一個(gè)Debug輸出每次事件觸發(fā)都打印一次當(dāng)前讀取位置用OutputDebugString記錄到Output窗口。這樣一旦出現(xiàn)爆音馬上就能看出是哪一段數(shù)據(jù)沒(méi)對(duì)上。另外在初始化階段做一個(gè)“自檢”用代碼生成一段1kHz正弦波幾秒鐘播放后能確認(rèn)整條鏈路是否通暢。很多奇怪問(wèn)題先別怪DirectSound先用正弦波排除設(shè)備、驅(qū)動(dòng)層面的問(wèn)題再回頭查自己的代碼。把這個(gè)依樣畫(huà)葫蘆跑通一遍你就知道DirectSound的緩沖區(qū)模型有多有趣了。我個(gè)人的體會(huì)是別滿(mǎn)足于“能響”一定要?jiǎng)邮职丫彌_區(qū)切塊、事件通知、跨邊界寫(xiě)這三塊吃透它們才是DirectSound播放器的靈魂。后面如果再想擴(kuò)展加一個(gè)FFT頻譜顯示或者用多緩沖區(qū)做無(wú)縫切換循環(huán)列表都是很好的方向。本文還有配套的精品資源點(diǎn)擊獲取