41. fejezet - Hangkezelés XAudio 2-vel


Nemrég eljutottam arra a szintre, hogy érdemes lenne hangkezelést is belevinni az enginebe (ld. címoldal) és nekiálltam keresgélni ilyen API-kat/librarykat. Először ezek közül emlitenék meg néhányat:

  • DirectSound - ősrégi cucc, már nem nagyon fejlődik, de legalább letisztult; alacsony szintű; régebbi Windows-on is megy
  • XAudio 2 - a DirectSound örököse; még fejlődik, XP/Vista/Win 7/XBox-on megy
  • fmod - egy nagyon jól használható sound library; platformfüggetlen; nem kereskedelmi célra ingyenesen használható
  • OpenAL - nem annyira jó mint az fmod, de a felülete hasonló az OpenGL-hez és ráadásul zlib licenszes így bármire használható
Ehhez a fejezethez az XAudio2-t választottam, mert egyrészt kevés cikk van hozzá, másrészt ez fog továbbfejlődni a DirectSound-al szemben. A másik két említett dolog már ezek fölé épül, így kellően barátságos a felületük és sok példaprogram is van hozzájuk.

A fejezetben egy olyan osztályt fogok írni, ami egy külön szálon képes streamelni az audio adatot, miközben a főszál rajzol, illetve az XAudio2 eszköz lejátssza a hangot.


Nézd anyu, több szálon futok!

Azt gondolom mindenki tudja, hogy a memória mérete véges, sőt ez különösen igaz pl. a videókártyára vagy hangkártyára (főleg ha alaplapi). Ezért akármekkora zenéket nem kéne se a kártyán, se a fő memóriában tárolni. Másrészt amíg egy zenerészletet játszik a kártya, addig a következő részletet már nyugodtan töltögethetné a háttérben, így mondjuk egy 10 MB-os zenefájl helyett összesen csak 3x64 KB -ot kell eltárolni, ami igencsak takarékos! Ezt a töltögetést szokták hívni streaming-nek (adatfolyam).

Sőt, ez nem csak a hangok esetében hasznos, gondoljunk például egy nagy bejárható terrain-re. Nyilván nem fogjuk az egészet eltárolni a memóriában, hanem amikor kell egy új részlet azt a háttérben betöltjük amíg a karakter az előzőn mozog.

Amit minden óvodánál tovább jutott programozó tud, hogy a merevlemezről való írás/olvasás igencsak lassú, tehát nem várhatjuk el a programtól, hogy megvárja amíg mi betöltjük a következő részletet. Eddig csak szinkron üzenetküldésekre mutattam példát, azaz a hívó addig blokkolódik, amíg a hívott fél be nem fejezte az üzenet feldolgozását (jelen esetben a töltögetést).

Az aszinkron üzenetküldés ezzel szemben olyan, hogy a hívó nem blokkolódik, hanem mehet tovább, így a két folyamat párhuzamosan tud egymás melett futni. Az alábbi ábra az üzenetküldés folyamatát mutatja be egy példán keresztül.

async

A bar() függvény semmiféle feldolgozást nem végez, hanem berakja az üzenetet egy sorba és vissza is tér, így a főszál megy tovább a saját dolgára. Egy másik szál azonban időnként megnézi ezt a (közös) sort, és ha van benne üzenet azt feldolgozza.

Egy nagyon fontos dolog azonban kimaradt az ábráról: könnyen lehet, hogy a push végrehajtásának felénél az operációs rendszer elveszi a processzort a fő száltól és a dolgozó kapja meg. Tegyük fel, hogy a méretet már megnövelte a sor, de az elem még nem került be: elszáll a program. Ez azért van, mert a C++ STL egyáltalán nem thread safe. Tehát a programozó dolga biztosítani, hogy az ilyen közös változókat mindig csak az egyik thread használhassa. Jobb helyeken ezt mutual exclusion -nak (kölcsönös kizárás, mutex) hívják.

Az ide vonatkozó implementációkat nem fogom most leírni, inkább majd visszamenőleg egy külön cikkben. A lényeg annyi, hogy a fő osztályban van egy Guard objektum, ami nem enged be más szálat amíg az egyik szál használja a közös változót.


XAudio 2 incializálás

Első nekifutásra csak hozzuk létre a lényeges objektumokat. Hasonlóan a Direct3D-hez itt is két központi objektum van. Az egyik az IXAudio2 interfész, ez hozza létre a hang objektumokat és ez felel a hardver kezeléséért is. A másik interfész az IXAudio2MasteringVoice, ami a konkrét output eszközre írja az audio adatokat, de nem nagyon kell foglalkozni vele (hacsak nem a master volume-t akarod állitgatni).

Ami már a legelején fontos, hogy azok a szálak amik COM interfészeket használnak azokra a COM library concurrency model -jét többszálúra kell állítani. Ezt a CoInitializeEx(NULL, COINIT_MULTITHREADED); hívással lehet megtenni. Erre azért van szükség, mert az XAudio2 maga is külön szálon fut. Nyilván a thread befejeződése előtt ki kell adni egy CoUninitialize(); hívást is.

Egyetlen kódrészletbe összefoglalva a program váza a következő:

CODE
#include <xaudio2.h> IXAudio2* xaudio2 = NULL; IXAudio2MasteringVoice* masteringvoice = NULL; int main() { CoInitializeEx(NULL, COINIT_MULTITHREADED); InitXAudio2(); // TODO: if( masteringvoice ) masteringvoice->DestroyVoice(); if( xaudio2 ) xaudio2->Release(); CoUninitialize(); system("pause"); return 0; } HRESULT InitXAudio2() { HRESULT hr; if( FAILED(hr = XAudio2Create(&xaudio2, 0)) ) { MYERROR("Could not create XAudio2 object"); return E_FAIL; } if( FAILED(hr = xaudio2->CreateMasteringVoice(&masteringvoice)) ) { MYERROR("Could not create mastering voice"); return E_FAIL; } return S_OK; }

Az XAudio2Create függvénynek meg lehet adni második paraméterként az XAUDIO2_DEBUG_ENGINE flaget, ekkor a D3DX-hez hasonlóan a debug libraryt fogja használni. Harmadik paraméterként még meg lehet adni, hogy melyik processzort használja az objektum, de a dokumentáció azt javasolja, hogy bízzuk rá az XAudio-ra.

Az IXAudio2::CreateMasteringVoice metódusnak szintén több paramétere van, ha az alapértelmezetteket hagyjuk, akkor a detektált hardver alapján választ, így nem különösebben érdekesek.

A main függvényben nagyjából ennyi történik hang szempontból, a többi ehhez kapcsolódó dolgot az AudioStreamer nevű osztályban fogom megvalósítani.


Ogg Vorbis hangfájlok betöltése

Azért ezt választottam mert egyszerű betölteni (van hozzá lib), és a VLC player tud ilyenbe konvertálni. Két libraryra van szükség, az egyik a libogg, a másik a libvorbis (ki nem találtátok volna). Egy kis fejtágítás: a vorbis egy audio formátum (és codec), az ogg pedig a fájl (mint konténer) formátuma ami ezt tárolja (tehát ha valami meg tudja nyitni az ogg fájlokat, az nem biztos hogy dekódolni is tudja ami benne van). Amit meg kell említeni, hogy a vorbis egy veszteséges tömörítő.

Aki lusta lenne leszedni ezt a két libet annak mondom, hogy letölhető itt, de a cikk csomagjába belepakoltam az általam fordítottakat. Fontos, hogy a debug és release verziókat ne keverd, mert vidáman elszállnak.

Először azt fogom megmutatni, hogy hogyan lehet egy kis hangfájlt betölteni (egyben) és lejátszani. A későbbi bővíthetőség és az OOP szemlélet érdekében a lényeges dolgokat a Sound osztályba foglaltam össze.

CODE
class Sound : public IXAudio2VoiceCallback { friend class AudioStreamer; private: WAVEFORMATEX format; IXAudio2SourceVoice* voice; char* data[MAX_BUFFER_COUNT]; unsigned int totalsize; // ... };

Nem adtam meg most minden membert, azok majd a streamelésnél fognak szerepet játszani, mint ahogy a data tömb többi eleme is. A voice változó reprezentálja a hangot az XAudio2 felé (mint pl. Direct3D-ben a textúra). Erről azt kell tudni, hogy a háttérben egy buffer sort tárol és ha az egyiknek a lejátszását befejezte, akkor veszi a következőt. Hogy a user felé legyen valami visszajelzés erről, meg lehet adni létrehozáskor egy callback objektumot, ezért származtattam az osztályt az IXAudio2VoiceCallback interfészből. Hét metódust kell felüldefiniálni, ezekről majd később (csak egyet fogok ténylegesen használni).

A betöltéshez Crusader kolléga tutorialját használtam fel. A lényeg, hogy a vorbis nem garantálja, hogy annyi adatot fog beolvasni amennyit kértél tőle, ezért mindenképpen egy ciklusban kell elvégezni az olvasást.

A betöltést az AudioStreamer osztály végzi el, a létrehozott Sound objektumot pedig egy listában tárolja (és a program végén felszabadítja).

CODE
Sound* AudioStreamer::LoadSound(IXAudio2* xaudio2, const std::string& file) { FILE* infile = NULL; fopen_s(&infile, file.c_str(), "rb"); // ... OggVorbis_File oggfile; int stream; long readbytes = 0; long totalread = 0; ov_open(infile, &oggfile, NULL, 0); vorbis_info* info = ov_info(&oggfile, -1); Sound* s = new Sound(); // ez a struktúra irja le a hangot az XAudio2 felé s->format.nChannels = info->channels; s->format.cbSize = sizeof(WAVEFORMATEX); s->format.wBitsPerSample = 16; s->format.nSamplesPerSec = info->rate; s->format.nAvgBytesPerSec = info->rate * 2 * info->channels; s->format.nBlockAlign = 2 * info->channels; s->format.wFormatTag = 1; s->totalsize = (unsigned int)ov_pcm_total(&oggfile, -1) * 2 * info->channels; s->data[0] = new char[s->totalsize]; // addig tölt amíg van mit olvasni do { readbytes = ov_read( &oggfile, s->data[0] + totalread, s->totalsize - totalread, 0, 2, 1, &stream); totalread += readbytes; } while( readbytes > 0 && totalread < s->totalsize ); ov_clear(&oggfile); // folyt.

Ezen a ponton az adat már bent van a tömbben, már csak az XAudio2 felé kéne ezt valahogy delegálni. Először a voice változót hozom létre az alábbi módon:

CODE
if( FAILED(xaudio2->CreateSourceVoice(&s->voice, &s->format, 0, 2.0f, s)) ) { SND_ERROR("AudioStreamer::LoadSound(): Could not create source voice"); delete s; return NULL; } // folyt.

Az első két változó nyilvánvaló, a második kettőt csak azért adtam meg, hogy utolsó paraméterként megadhassam az s objektumot, mint callback, így le tudja majd kezelni a különféle eseményeket. Innen már csak magát az adatot kell beadni az objektumnak, még pedig egy buffer leíróval:

CODE
XAUDIO2_BUFFER audbuff; memset(&audbuff, 0, sizeof(XAUDIO2_BUFFER)); audbuff.pAudioData = (const BYTE*)s->data[0]; audbuff.Flags = XAUDIO2_END_OF_STREAM; audbuff.AudioBytes = s->totalsize; audbuff.LoopCount = XAUDIO2_LOOP_INFINITE; if( FAILED(s->voice->SubmitSourceBuffer(&audbuff)) ) { SND_ERROR("AudioStreamer::LoadSound(): Could not submit buffer"); delete s; return NULL; } // ... soundguard.Lock(); sound.push_back(s); soundguard.Unlock(); }

Ezen a bufferen keresztül lehet megadni az ismétlések számát, helyét meg egy csomó egyéb dolgot, amire most abszolút nincs szükség. Mivel ez után nem fog több adat jönni, ezért megadtam az XAUDIO2_END_OF_STREAM flaget, ezzel jelezve az objektum felé, hogy ha befejezte ezt a buffert, akkor több adat nem jön már.

A lejátszás a voice->Start(0); hívással történik, a felszabadítás pedig a fentihez hasonlóan voice->DestroyVoice();. A hangerőt lehet állitani a mastering voice-on is, de ezen is a voice->SetVolume(mennyi); metóduson keresztül (alapértelmezésben 1).


Streamelés külön szálon

Ez eddig még nem is volt olyan nehéz, most viszont ismertetni fogom, hogy a streamelést hogyan oldottam meg. A Vorbis és az XAudio2 oldaláról nem lesz nehéz (használatilag teljesen ugyanaz), programozásilag viszont jól meg kell gondolni, hogy mit csinálsz. Első lépésként a Sound osztályt kiegészítem az alábbi változókkal:

  • readsize - mennyi bájtot olvasott eddig be
  • currentbuffer - az aktuális buffer amibe lehetne töltögetni
  • freebuffers - hány buffer szabad töltögetésre
Összesen MAX_BUFFER_COUNT buffer lesz (tipikusan 3 elég), ebből egy mindig a voice -on kívül lesz, hogy abba lehessen tölteni, a többi pedig sorban lejátszódik. A töltögetés mindig a currentbuffer sorszámú bufferbe történik. Egy buffer kétféleképpen kerülhet be a voice sorba:
  • a betöltés befejezésekor még befér a sorba
  • egy buffer lejátszásának befejezésekor behúzzuk
A dolgozó thread a következőt csinálja: kigyűjti azokat a hangokat, amiket streamelni kell (ez lesz a kritikus szakasza a hanglistára nézve), majd sorban végigmegy rajtuk és betölt egy újabb adag audio adatot. Ha a hanghoz tartozó sorban még van hely, akkor rögtön be is rakja. Ha nincsen frissitendő hang, akkor alszik 0.1 másodpercet.

A másik eset az lesz, amikor az XAudio2 befejezte egy buffer lejátszását, és meghívja a felüldefiniált OnBufferEnd metódust. Ez paraméterként meg tudja kapni a bufferhez tartozó user adatot (XAUDIO2_BUFFER::pContext member); legyen ez az adat a buffer indexe.

Hogyan tudjuk eldönteni, hogy melyik buffert kell behúzni? Tudjuk, hogy ha van még hely a sorban, akkor a buffer bekerült már, tehát csak akkor kell itt behúzni, ha nincs szabad buffer. Sőt azt is tudjuk, hogy a buffereken sorban megyünk végig, tehát az éppen aktuális tölthető buffer előttit töltöttük be utoljára:

CODE
COM_DECLSPEC_NOTHROW void STDMETHODCALLTYPE Sound::OnBufferEnd(void* context) { if( context ) { int id = *reinterpret_cast<int*>(context); if( freebuffers == 0 ) { int buffind = (MAX_BUFFER_COUNT + (currentbuffer - 1)) % MAX_BUFFER_COUNT; PullBuffer(buffind); } ++freebuffers; } // ... }

A PullBuffer metódus teljesen hasonlóan rakja be a buffert, mint a szimpla hangnál, de csak akkor lesz a flag member XAUDIO2_END_OF_STREAM, ha ez az utolsó buffer (azaz már az egész fájlt beolvastuk).


Shared változók pokla

A program nehéz része az, hogy hova tegyünk lockokat. Egyáltalán mik a shared változók? Először is azt kéne megnézni, hogy hány thread van:

  • main thread - írhatja a hanglistát és a Sound objektum változóit
  • worker thread - olvassa a hang listát és írja a Sound objektum változóit
  • XAudio2 thread - a callbackon keresztül írja a Sound objektum változóit
Feltételezem, hogy az XAudio2 thread-safe, ezért a voice változó hívásait nem védem le. Amit biztosan le kell védeni az a hanglista az AudioStreamer osztályban. Le kell védeni amikor a main threadből request érkezik egy hang betöltésére, és szintén le kell védeni, amikor a worker thread kigyűjti belőle a frissitendő hangokat.

Ezen kívül még a Sound osztályba is raktam egy guardot, mégpedig a Play metódusba. Ha a hang szimpla, akkor rögtön le lehet játszani amint meghivja a main thread ezt a metódust. Ha viszont streamelendő, akkor meg kell jegyezni, hogy már játszani kéne, és majd a worker thread fogja ténylegesen elindítani. Másrészt az itt felhasznált változókat a worker thread egyébként is bármikor írhatja:

CODE
void Sound::Play() { playguard.Lock(); { playrequest = true; if( voice && canplay && !playing ) { playing = true; voice->Start(0); } } playguard.Unlock(); }

Az AudioStreamer osztályban a fentiek értelmében a guard mindenhol lockol, ahol a hanglistát babrálja. Különösen figyelni kell a Destroy() metódusra, hiszen a worker thread még futhat ekkor! Az appverif.exe nevű toolt nagyon ajánlom használni, mert segít megmondani, hogy hol rontottad el.


Summarum

A fejezet végére még néhány gondolat:

  • az MSDN szerint a CoInitializeEx -et minden threadnek meg kel hívnia, amelyik használ COM objektumokat
  • oda kell figyelni, hogy a lockolásokkal ne csinálj deadlockot (holtpont, azaz senki sem kapja meg a jogot, de mindegyik vár)
  • a Thread és a Guard osztályok implementációját itt nem ismertettem, majd egy külön WinAPI-s fejezetben fogom
  • a dll-ek közül a XAPOFX1_5 és a XAudio2_7 -t kell mellékelned a programodhoz (verziószámban különbözhet)
Az itt megírt program meglehetősen szimpla és nem garantáltan bugmentes; csak az XAudio 2 alapvető használatát akartam bemutatni (ami elég ahhoz, hogy le tudj játszani hangokat és zenéket). Egy éles implementációban sokkal körültekintőbben kell eljárni (főleg a multithreadinges dolgokkal).

A kód és a bináris letöhető a szokott helyen. A 11_Particles nevű progiba raktam bele.

A teszt zene Judas Priest: Painkiller nevű heavy metal dal. 320 kb/s-be tömörítettem, ez kb. MP3 minőség. Ha kell valakinek FLAC-ban szóljon. METAL FOREVAH!!!!!


Höfö:
  • Találd ki, hogy miben segít az XAUDIO2_VOICE_MUSIC flag!
  • Csinálj XAudio 2-vel 3D hangot!
  • Ezeket írd meg nekem, hogy legyen miről cikket írni...

back to homepage

Valid HTML 4.01 Transitional Valid CSS!