BETA
Aby się zalogować, najpiew wybierz portal.
Aby się zarejestrować, najpiew wybierz portal.
Podaj słowa kluczowe
Słowa kluczowe muszą mieć co najmniej 3 sąsiadujące znaki alfanumeryczne
Pole zawiera niedozwolone znaki

Baza wiedzy











Implementacja DirectSound w .NET (cz.2)

17-08-2004 19:34 | Solti
Odtwarzanie strumieniowe

Na czym polega strumieniowanie? Zalety oraz wady buforów strumieniowych

Strumieniowanie jest to cykliczne wypełnianie bufora kolejnymi porcjami danych (dźwięku) przeznaczonych do odtworzenia. Podstawową zaletą takiego rozwiązania jest możliwość stosowania niewielkich buforów zdolnych przechowywać tylko kilka sekund dźwięku, co w porównaniu ze statyczną metodą odtwarzania daje możliwość ogromnej oszczędności alokowanej pamięci systemowej. Zastosowanie małych buforów stwarza kolejną możliwość. Jeżeli karta dźwiękowa pozwala na tworzenie buforów sprzętowych, to możliwe jest umieszczenie bufora w pamięci karty, a tym samym całkowite odciążenie pamięci systemowej. Podstawową wadą strumieniowania jest brak bezpośredniego dostępu do dowolnej próbki odtwarzanego dźwięku. Efektem są opóźnienia podczas uruchamiania odtwarzania, wynikające z konieczności wcześniejszego załadowania bufora porcją dźwięku. Dodatkową wadą strumieniowania jest konieczność zaangażowania jednostki centralnej w proces „podrzucania” kolejnych porcji dźwięku do bufora.

Realizacja mechanizmu strumieniowania

Podczas odtwarzania strumieniowego pojawia się kolejny problem. Nie jest możliwe, aby w każdym cyklu bufor był wypełniany w całości. Wynika to z faktu, że zawsze jakaś jego część jest aktualnie odtwarzana i wypełnianie bufora „od deski do deski” mogłoby doprowadzić do przerw w odtwarzaniu dźwięku.

Bufor DirectSound posiada dwa wskaźniki. Jeden określa aktualną pozycję odtwarzania (GetPlayPosition()), drugi pozycję, od której możliwy jest tzw. „bezpieczny” zapis do bufora (GetWritePosition()). Obszar między tymi wskaźnikami jest uważany za aktualnie wykorzystywany przez DirectSound i nie należy go modyfikować. Rozwiązaniem przedstawionego problemu jest podzielenie bufora na kilka sekcji, np. cztery. W chwili „przejścia” wskaźnika odtwarzania do kolejnej sekcji, wypełniamy ćwiartkę znajdującą się bezpośrednio za wskaźnikiem odtwarzania. Koncepcję przedstawia poniższy rysunek:

Do wyznaczenia sekcji posłużą nam tzw. powiadomienia (Notifications). Dowolnej pozycji w buforze możemy przydzielić zdarzenie (PositionNotify), które zostanie wywołane w momencie przekroczenia tej pozycji przez wskaźnik odczytu zawartości bufora. Dodatkowo utworzymy własny wskaźnik pozycji zapisu WritePosition określający, dokąd wypełniony został bufor (pozycja odczytu wcześniejszego zdarzenia powiadomienia).

Zastosowanie w strumieniowaniu bufora o niewielkim rozmiarze wynika ze stosunkowo częstego odświeżania jego zawartości. Nie możemy mieć stuprocentowej pewności, że stworzona aplikacja zapewni obsługę każdego powiadomienia (np. aplikacja może wykonywać funkcję odczytu/zapisu na dysk, której czas realizacji jest dłuższy niż odstęp między kolejnymi zdarzeniami powiadomienia). W tym celu utworzymy osobny wątek, który będzie monitorował nadejście zdarzenia powiadomienia i realizował funkcję wypełniania bufora.

Implementacja podrzędnego bufora strumieniowego

W klasie głównej projektu deklarujemy potrzebne struktury:

private Device SoundDevice = null;               // urządzenie DirectSound
private SecondaryBuffer SoundBuffer = null;     // bufor drugorzędny
private BufferDescription BuffDesc = null;     // opis tworzonego bufora
private int WritePosition = 0;     // nasz wskaźnik pozycji zapisu do bufora
public const int BUFFER_SIZE = 32768;     // przykładowo 32kBajtowy bufor
public const int NOTIFY_COUNT = 4;          // liczba powiadomień (sekcji) tutaj 4
public const int BUFFER_NOTIFY_SIZE = BUFFER_SIZE/NOTIFY_COUNT;     // rozmiar sekcji bufora
private FileStream WaveFile = null;          // strumień pliku dźwiękowego
private int WaveDataSize = 0;               // rozmiar bloku danych w pliku
private int WaveDataStartPosition = 0;     // początek bloku danych w pliku
// tablica pozycji powiadomień
private BufferPositionNotify[] PositionNotify = new BufferPositionNotify[NOTIFY_COUNT];
private bool KillThread = false;                     // czy zakończyć wątek
private AutoResetEvent NotificationEvent     = null;      // zdarzenie powiadomienia
private Notify  AppNotify = null;                    // obiekt powiadomienia
private Thread NotifyThread = null;                    // wątek obsługujący zdarzenie powiadomienia

Następnie, np. w ciele konstruktora klasy tworzymy instancję urządzenia DirectSound:

try{
     SoundDevice = new Device();
     SoundDevice.SetCooperativeLevel(this, CooperativeLevel.Normal);
}catch{
     // Obsługa błędu
}

Kolejnym krokiem jest utworzenie instancji zdarzenia powiadamiania NotificationEvent i przygotowanie tablicy z listą pozycji po przekroczeniu, których wywoływane będzie to zdarzenie.

NotificationEvent = new AutoResetEvent(false);
for( int i=0;i<NOTIFY_COUNT;i++)
{
     // pozycja dla wywołania zdarzenia
     PositionNotify[i].Offset = i * BUFFER_NOTIFY_SIZE + BUFFER_NOTIFY_SIZE-1;
     // przydzielenie uchwytu zdarzenia
     PositionNotify[i].EventNotifyHandle = NotificationEvent.Handle;
}

Jak widać, pozycję powiadomienia określamy na końcu każdej ćwiartki bufora. Dla każdej pozycji przydzielany jest uchwyt zdarzenia typu AutoResetEvent, którego zadaniem będzie odblokowywanie oczekującego wątku.

Teraz tworzymy i uruchamiamy wątek monitorujący:

NotifyThread = new Thread(new ThreadStart(ThreadProc));
NotifyThread.Start();

Jako ciało wątku została wykorzystana funkcja ThreadProc, którą opiszę w dalszej części.

Tworzenie strumienia plikowego i bufora dźwięku

Pierwszym krokiem jest utworzenie strumienia poprzez który będziemy mogli pobierać dane z pliku  *.WAV:

try{
     WaveFile = new FileStream(„dzwiek.wav”,FileMode.Open,FileAccess.Read);
}catch{
     //Obsługa błędu
}

Zanim zaczniemy tworzyć bufor musimy utworzyć opis jego właściwości:

BuffDesc = new BufferDescription();  

W opisie musimy określić format odtwarzanego dźwięku. Podczas tworzenia bufora statycznego, format próbki dźwięku określany jest automatycznie. Tutaj tę czynność musi wykonać nasza aplikacja analizując nagłówek pliku WAV.

Aby nie odbiegać od głównego tematu, pominę analizę fragmentu kodu, w którym realizowany jest proces rozpoznawania danych w nagłówku. Proces ten dokładnie opisałem w kodzie przykładu PlaySound.zip (funkcja ExtractWaveFormat).

Załóżmy więc że dysponujemy funkcją ExtractWaveFormat, która na podstawie strumienia plikowego (będącego plikiem typu WAV), zwraca format dźwięku i rozmiar bloku danych:

try{
     BuffDesc.Format = ExtractWaveFormat(WaveFile,out WaveDataSize);
}catch(BadFormatException){
     //BŁĄD! "Zły format pliku"
}catch(Exception){
     //Obsługa innego błędu
}

Plik typu WAV składa się z nagłówka i bloku danych. Po dokonanej analizie nagłówka pozycja odczytu w strumieniu plikowym (WaveFile) jest ustawiona dokładnie na początek bloku danych. Jeżeli chcemy, aby próbki dźwięku były odtwarzane cyklicznie, musimy zapamiętać tę pozycję.

WaveDataStartPosition = (int)WaveFile.Position;

Strumieniowanie stosujemy do odtwarzania próbki dźwiękowej o dowolnej długości, lecz nie może ona być zbyt krótka. Dopuszczalne jest, aby próbka dźwięku była krótsza od samego bufora dźwięku, ale na pewno nie może być krótsza od rozmiaru sekcji, gdyż może to doprowadzić do przerw w odtwarzaniu. Przyjmijmy rozmiar krytyczny próbki dźwięku równy 1,5 rozmiaru sekcji:

if(WaveDataSize<(BUFFER_NOTIFY_SIZE*1.5))
{
     //BŁĄD! „Próbka dźwięku jest zbyt krótka."
}

Aby proces powiadamiania w ogóle zadziałał, w opisie bufora musimy włączyć obsługę powiadamiania o przekroczeniu pozycji ControlPositionNotify.

BuffDesc.ControlPositionNotify = true;     // włącz obsługę powiadomień dla tego bufora
BuffDesc.BufferBytes = BUFFER_SIZE;          // ustaw rozmiar bufora
BuffDesc.LocateInSoftware = true;          // umieść w pamięci systemowej

Dla przypomnienia, LocateInSoftware i LocateInHardware określają miejsce utworzenia bufora. Jeżeli obie flagi ustawione są na false (domyślnie) to DirectSound najpierw będzie próbował utworzyć bufor sprzętowy, a jeżeli mu się to nie powiedzie, wówczas będzie próbował zaalokować bufor w pamięci systemowej.

BuffDesc.GlobalFocus = true;

Flaga GlobalFocus określa, czy bufor ma być dalej „słyszalny” w chwili, gdy aplikacja stanie się nieaktywna (np. gdy przejdziemy do innej aplikacji używając Alt+Tab) lub zminimalizowana.

Po ustaleniu właściwości bufora możemy go w końcu utworzyć:

try
{
     SoundBuffer = new SecondaryBuffer(BuffDesc, SoundDevice);
}
catch(FormatException)
{
     //BŁĄD! „Zły format”
}
catch(Exception)
{
     //Obsługa innego błędu
}

Mając utworzoną instancję bufora, ostatnim już krokiem jest utworzenie instancji klasy typu Notify. Dzięki niej możemy przydzielić buforowi listę pozycji, w których ma zostać wywołane powiadomienie:

AppNotify = new Notify(SoundBuffer);
AppNotify.SetNotificationPositions(PositionNotify,NOTIFY_COUNT);

Uff...

Rozpoczęcie i zatrzymanie odtwarzania bufora strumieniowego

Zanim przystąpimy do odtwarzania, musimy wypełnić bufor pierwszą porcją dźwięku. Zatem cofamy pozycję w strumieniu pliku na początek bloku danych (zakładając wcześniejsze wystąpienie odtwarzania) i wypełniamy:

if(WaveDataSize > BUFFER_SIZE){
     WaveFile.Position = WaveDataStartPosition;
     SoundBuffer.Write(0,WaveFile,BUFFER_SIZE,LockFlag.None);
}

Pierwszy parametr to przesunięcie względem początku bufora – miejsce, od którego ma się rozpocząć zapis, następnie strumień źródłowy, rozmiar bloku danych do zapisu, no i flaga blokady bloku pamięci.

Idea odtwarzania strumieniowego stosowana jest przy długich próbkach dźwięków, ale nic nie stoi na przeszkodzie, aby próbka dźwięku (blok danych) była trochę krótsza niż sam bufor. W tym przypadku zwielokrotniamy operacje zapisu próbki do bufora tak, aby został wypełniony w całości:

else      //przypadek, gdy bufor jest większy od samej próbki dźwięku
{
     int WritePos = 0;
     int WriteSize = 0;
     do{
          // skok w strumieniu do początku bloku danych
          WaveFile.Position = WaveDataStartPosition;
          // ustalenie rozmiaru bloku do zapisu
          if ((WritePos+WaveDataSize)>BUFFER_SIZE){
               WriteSize=BUFFER_SIZE-WritePos;
          }else{
               WriteSize = WaveDataSize;
          }
          // zapis do bufora
          SoundBuffer.Write(WritePos,WaveFile,WriteSize,LockFlag.None);
          // aktualizacja pozycji zapisu
          WritePos += WaveDataSize;
     }while(WriteSize == WaveDataSize);
}

Teraz wystarczy ustawić nasz indeks pozycji zapisu na początek, indeks odtwarzania bufora również, a potem rozpocząć odtwarzanie:

WritePosition = 0;
SoundBuffer.SetCurrentPosition(0);
SoundBuffer.Play(0, BufferPlayFlags.Looping);     // odtwarzaj cyklicznie

Zatrzymanie odtwarzania bufora strumieniowego jest realizowane identycznie, jak w buforze statycznym:

if (SoundBuffer.Status.Playing == true){
     SoundBuffer.Stop();
     SoundBuffer.SetCurrentPosition(0);
}

Funkcja wątku

W końcu doszliśmy do funkcji wątku, która w zasadzie jest najistotniejsza w tym całym zamieszaniu. To tutaj realizujemy proces uzupełniania bufora kolejnymi porcjami dźwięku.

private void ThreadProc()
{
     int WriteSize = 0;     // rozmiar bloku danych do przesłania z strumienia do bufora
     long ReadSize;          // rozmiar bloku danych odczytanego ze strumienia
     while(!KillThread)
     {
               // Czekaj na nadejście powiadomienia
          NotificationEvent.WaitOne(Timeout.Infinite, true);     //<<----
          if( SoundBuffer == null) return;
               // odczytanie aktualnej pozycji odtwarzania
          int PlayPosition = SoundBuffer.PlayPosition;
               // zapamiętanie pozycji w strumieniu przed odczytem
          ReadSize = WaveFile.Position;
          if(PlayPosition>WritePosition)// Blok jest "zawinięty"?: nie
          {
               WriteSize = PlayPosition-WritePosition;
     SoundBuffer.Write(WritePosition,WaveFile,WriteSize,LockFlag.None);
          }
          else if(PlayPosition!=WritePosition)          // tak, zapisz po kawałku
          {
               WriteSize = BUFFER_SIZE-WritePosition;
     SoundBuffer.Write(WritePosition,WaveFile,WriteSize,LockFlag.None);
               WriteSize += PlayPosition;
               SoundBuffer.Write(0,WaveFile,PlayPosition,LockFlag.None);
          }
               // określ rozmiar bloku odczytanego z strumienia
          ReadSize = WaveFile.Position - ReadSize;
               // korekcja pozycji zapisu;
          if(ReadSize == WriteSize)
          {
               WritePosition += WriteSize;
          }
          else     //jezeli ReadSize< WriteSize to znaczy, że jest już końcówka strumienia
          {
               WritePosition += (int)ReadSize;
               //cofnij pozycję w strumieniu na początek bloku danych
               WaveFile.Position = WaveDataStartPosition;
          }
               // "zawiń" pozycję
          if(WritePosition>=BUFFER_SIZE)WritePosition-=BUFFER_SIZE;
     }
}

Zasada działania procedury jest dosyć prosta. Wszystko zamknięte jest w pętli while, której argumentem jest globalna zmienna KillThread. Wartość true tej zmiennej powoduje wyjście z pętli i zakończenie wątku. Pierwszym poleceniem w pętli jest wywołanie metody NotificationEvent.WaitOne, która blokuje dalsze wykonywanie wątku do momentu wystąpienia zdarzenia. Po odblokowaniu wykonywana jest dalsza część pętli, która jest odpowiedzialna za wypełnienie bufora porcją danych i cykl się powtarza.

W procedurze wypełniania najpierw zapamiętujemy aktualną pozycję odtwarzania bufora PlayPosition i pozycję w strumieniu pliku, która posłuży do określenia ilości przeczytanych z pliku danych ReadSize. Następnie sprawdzamy, czy pozycja odtwarzania PlayPosition poprzedza pozycję zapisu WritePosition. Jeżeli tak, to wypełniamy nową porcją danych obszar między tymi wskaźnikami. Jeżeli nie - oznacza to, że obszar zapisu jest „zawinięty” i trzeba go wypełnić dwufazowo. Najpierw wypełniamy obszar między pozycją odtwarzania PlayPosition a końcem bufora, a następnie obszar między początkiem bufora i wskaźnikiem zapisu WritePosition. Na koniec wskaźnik zapisu do bufora WritePosition inkrementujemy o ilość przeczytanych danych ze strumienia pliku ReadSize.

Zwalnianie zasobów

Aby prawidłowo zakończyć aplikację najpierw musimy zakończyć wątek, który może jeszcze korzystać z bufora, a następnie kolejno zwalniamy: bufor, urządzenie DirectSound i zamykamy otwarty strumień plikowy .

if (null != NotificationEvent)
{
     KillThread = true;     // zakończ wątek
     NotificationEvent.Set();     // wywołaj zdarzenie
}
if (null != SoundBuffer)     // zwolnij bufor
{
     if(SoundBuffer.Status.Playing) SoundBuffer.Stop();
     SoundBuffer.Dispose();
}
if (null != SoundDevice)SoundDevice.Dispose();     // zwolnij urządzenie
if (null != WaveFile) WaveFile.Close();          // zamknij strumień plikowy

Podsumowując…

Jak widać implementacja bufora statycznego to „kaszka z mleczkiem” w porównaniu z implementacją bufora strumieniowego. Wysiłek się jednak opłaca. Stosowanie buforów o niewielkich rozmiarach pozwala znacząco zredukować obszar używanej pamięci systemowej. Z redukcją wielkości bufora nie należy jednak zbytnio przesadzać. Im mniejszy bufor, tym częściej musi być odświeżany, więc bardziej absorbuje procesor zmuszany do częstszej obsługi zdarzeń powiadomienia. To samo dotyczy liczby sekcji, na które dzielimy bufor - nie może być zbyt duża. Jeżeli wykorzystujemy bufor strumieniowy tylko do odtwarzania dźwięku, optymalnym rozwiązaniem jest podział na kilka sekcji np. trzy, lub cztery.

Zastosowanie

Technika strumieniowania jest stosowana przede wszystkim tam, gdzie nie możemy ściśle określić całkowitego rozmiaru przetwarzanego dźwięku. Jest wykorzystywana w aplikacjach przechwytujących dźwięk takich, jak rejestratory lub w aplikacjach dokonujących obróbki/analizy sygnału w czasie rzeczywistym np. generatory, filtry, miksery itp.

Dołączona do artykułu aplikacja SpectrumAnalyzer.zip jest przykładem, jak wykorzystać technikę strumieniowania do analizy próbki odtwarzanego dźwięku.

Przykład zawiera implementację klasy SpectrumAnalyzer, która w czasie rzeczywistym dokonuje analizy harmonicznej próbki dźwięku z wykorzystaniem algorytmu szybkiej transformaty Fouriera (FFT). Klasa SpectrumAnalyzer może pracować w dwóch trybach. Pierwszym jako analizator spektrum, gdzie wynik analizy przedstawiony jest w postaci słupków (prążków) reprezentujących kolejne zakresy częstotliwości harmonicznych. Drugim jako spektroskop, przedstawiający wykres występujących częstotliwości harmonicznych w funkcji czasu.

Myślę, że przykład ten może być szczególnie przydatny dla osób, które bawi pisanie własnych odtwarzaczy (playerów), wizualizatorów, instrumentów muzycznych itp.

Oprócz SpectrumAnalyzer.zip dodałem prosty przykład SoundPlay.zip, który jest podstawowym odtwarzaczem plików dźwiękowych w formacie WAV i przeznaczony jest dla troszkę mniej zaawansowanych. Obydwa zbudowane zostały w VS.NET 2003 z zainstalowanym pakietem DX 9.0b SDK Update (Summer2003).

W kolejnym artykule mam zamiar poruszyć znacznie ciekawszy temat. Będzie dotyczył przetwarzania sygnału poprzez dodawanie filtrów i innych efektów dźwiękowych, w które wyposażony jest DirectSound. Zapraszam i pozdrawiam,

Krzysztof Sołtys

Załączniki:

Podobne artykuły

Komentarze 4

User 109825
User 109825
2 pkt.
Nowicjusz
21-01-2010
oceń pozytywnie 0
Bardzo dobry artykół! Po przeczytaniu można się pokuzić o zrobienie odtwarzacza wav
Szopa
Szopa
0 pkt.
Nowicjusz
21-01-2010
oceń pozytywnie 0
Ciekawy artykuł.
testa1435
testa1435
0 pkt.
Nowicjusz
21-01-2010
oceń pozytywnie 0
Cieszę się, że drugi artykuł jest tak samo doskonały, jak pierwszy. Najwyzsza ocena.
:)
:)
3 pkt.
Nowicjusz
:)
21-01-2010
oceń pozytywnie 0
Artykuł nie sprawdia dobrego wrażenia... nie jest spójny. wygląda, jak by autor sam nie wiedział, co tak naprawdę napisze (ale to moje odczucie)
pkt.

Zaloguj się lub Zarejestruj się aby wykonać tę czynność.