Co warto wiedzieć o odtwarzaniu dźwięków na STM32?

Co warto wiedzieć o odtwarzaniu dźwięków na STM32?

Masz już dość irytujących buzzerów? Pora pójść krok dalej! Mikrokontrolery, takie jak STM32, bez problemu mogą odtwarzać muzykę. W tym celu konieczne jest jednak opanowanie podstaw I2S.

Oto praktyczny poradnik, który omawia krok po kroku, jak generować pojedyncze tony, a nawet odtwarzać gotowe melodie pobrane z Internetu.

Większość dostępnych kursów programowania mikrokontrolerów koncentruje się na dość prostych modułach peryferyjnych, jak chociażby GPIO, PWM, czy też komunikacyjnych – przykładowo UART, I2C, SPI. Jednak mikrokontrolery STM32 mają do zaoferowania o wiele więcej, a jedną z pomijanych na ogół funkcji jest możliwość odtwarzania dźwięku.

Na przykład popularna płytka STM32F411E-DISCOVERY, używana w kursie STM32F4, ma możliwość podłączenia słuchawek, głośnika lub zewnętrznego wzmacniacza – jednak ta opcja nie była opisywana w kursie. Pora to zmienić! 

Złącze audio na płytce STM32F411E-DISCOVERY (w lewym dolnym rogu)

Złącze audio na płytce STM32F411E-DISCOVERY (w lewym dolnym rogu)

Na wstępie zaznaczam dla formalności, że niniejszy artykuł nie jest częścią wspomnianego kursu. Ten wpis warto traktować jako niezależny zbiór notatek, które powstawały podczas różnych eksperymentów z wyjściem audio. Mam jednak nadzieję, że taki poradnik przyda się wielu osobom, które chciałyby rozpocząć przygodę z generowaniem i odtwarzaniem muzyki na układach z rodziny STM32.

Na zachętę film demonstrujący efekt uzyskany na jednej z płytek (szczegóły w dalszej części):

Warstwa sprzętowa – czego potrzebujemy?

Na początek wypada zapoznać się ze schematem płytki Discovery, a w szczególności z fragmentem związanym z odtwarzaniem dźwięku. W prawym górnym rogu schematu widzimy złącze CN4, czyli gniazdo słuchawkowe. Jest ono połączone z układem CS43L22, czyli ze zintegrowanym przetwornikiem analogowo-cyfrowym oraz wzmacniaczem klasy D.

Fragment schematu płytki Discovery z układem audio

Fragment schematu płytki Discovery z układem audio

Do konfiguracji układu CS43L22 i jego sterowania użyjemy znanego nam interfejsu I2C. W lewej górnej części schematu znajdują się dwie linie od tego interfejsu, podłączone do pinów PB9 (Audio_SDA) oraz PB6 (Audio_SCL).

Odrobinę niżej widzimy nowy interfejs, o mylnie podobnej nazwie: I2S. Za jego pomocą będziemy przesyłali tylko dane do odtwarzania, czyli wartości próbek dźwięku. Na schemacie dostrzegamy jego podłączenie: PC7 (I2S3_MCK), PC10 (I2S3_SCK), PC12 (I2S3_SD) oraz PA4 (I2S3_WS).

Aby odtwarzać dźwięk, będziemy musieli jeszcze wysterować pin PD4 (Audio_RST). Stan niski resetuje układ CS43L22, więc konieczne będzie ustawienie stanu wysokiego na tej linii. Na szczęście to zwykły pin GPIO, więc nic trudnego. Więcej informacji o CS43L22 znaleźć można w nocie katalogowej, z którą warto się zapoznać.

Generowanie projektu w STM32CubeIDE

Kurs STM32F4 wykorzystywał poprzednią wersję narzędzia CubeMX. Dla formalności pokażę więc krok po kroku, jak wygenerować projekt, używając STM32CubeIDE w aktualnie najnowszej wersji, czyli 1.3.0.

Najpierw oczywiście uruchamiamy program STM32CubeIDE. Następnie z menu File wybieramy opcję New > STM32 Project. Opisywana płytka jest wyposażona w mikrokontroler STM32F411VET6 – musimy więc go odszukać i zaznaczyć na liście.

Tworzenie nowego projektu w STM32CubeIDE – wybór mikrokontrolera

Tworzenie nowego projektu w STM32CubeIDE – wybór mikrokontrolera

W kolejnym kroku podajemy nazwę projektu i zatwierdzamy domyślne ustawienia:

Tworzenie nowego projektu w STM32CubeIDE – nazwa projektu

Tworzenie nowego projektu w STM32CubeIDE – nazwa projektu

Na koniec upewniamy się, czy opcja Copy only the necessary library files jest zaznaczona.

Tworzenie nowego projektu w STM32CubeIDE – dalsze ustawienia

Tworzenie nowego projektu w STM32CubeIDE – dalsze ustawienia

Po kliknięciu przycisku Finish powinniśmy otrzymać gotowy szablon projektu. Teraz ustawimy źródło taktowania naszego mikrokontrolera. Więcej szczegółów można znaleźć w kursie F4 – pisząc w skrócie: wybieramy moduł RCC i dla opcji High Speed Clock (HSE) wybieramy z listy pozycję opisaną jako Crystal/Ceramic Resonator. Na razie to tyle, ale do konfiguracji zegarów wrócimy jeszcze później.

Projekt STM32CubeIDE – wybór źródła taktowania mikrokontrolera

Projekt STM32CubeIDE – wybór źródła taktowania mikrokontrolera

Konfiguracja interfejsu I2S

Wreszcie możemy przejść do konfiguracji interfejsu I2S, a dokładniej I2S3. Jeśli używamy widoku kategorii, to znajdziemy nasz interfejs w grupie Multimedia. Po zaznaczeniu I2S3 w środkowej części okna pojawi się możliwość wyboru trybu pracy interfejsu (ang. Mode). Wybieramy opcję Half-Duplex Master oraz zaznaczamy Master Clock Output. Na liście ustawień wyszukujemy zakładkę Parameter Settings i zmieniamy wartość pola Selected Audio Frequency na 22 kHz.

Typowa częstotliwość próbkowania dla dźwięku o jakości znanej z CD to 44,1 kHz. W tym przypadku wybrałem 22 kHz, czyli połowę tej wartości – to nadal całkiem niezły wynik, a znacznie mniej danych oraz mniejsze obciążenie procesora.

Projekt STM32CubeIDE – konfiguracja I2S

Projekt STM32CubeIDE – konfiguracja I2S

Domyślnie przypisane piny zobaczymy, wybierając zakładkę GPIO Settings. Niestety nie odpowiadają one płytce STM32F411E-DISCOVERY, musimy więc je zmienić na PA4PC7PC10 oraz PC12. Warto również przestawić wartość Maximum output speed na High.

Projekt STM32CubeIDE – GPIO Settings

Projekt STM32CubeIDE – GPIO Settings

W pierwszej wersji programu nie będziemy używać przerwań, ale możemy je od razu włączyć, gdyż przydadzą się później. Wchodzimy w zakładkę NVIC Settings i włączamy odpowiednie przerwanie.

Projekt STM32CubeIDE – NVIC Settings

Projekt STM32CubeIDE – NVIC Settings

Tutaj ciekawostka dla spostrzegawczych – konfigurujemy I2S3, a na ekranie widzimy SPI3… taki mały błąd STM32CubeIDE, niestety niejedyny. W każdym razie oba interfejsy są ze sobą mocno powiązane.

Teraz jeszcze jedna opcja, też trochę na wyrost (przyda się później). W zakładce DMA Settings dodajemy obsługę DMA – klikamy przycisk Add, następnie wybieramy DMA Request SPI3_TX, a w dolnej części okna ustawiamy:

  • Mode: Circular
  • Data Width: Half Word
Projekt STM32CubeIDE – DMA Settings

Projekt STM32CubeIDE – DMA Settings

Konfiguracja zegarów

Teraz, gdy interfejs I2S jest już skonfigurowany, możemy wrócić do taktowania. Musimy ustawić częstotliwość kwarcu na 8 MHz (domyślnie jest to 25). Następnie wybieramy taktowanie dla HCLK. Maksymalna wartość to 100 MHz, więc możemy ją wykorzystać.

Jeśli zostawimy ustawienia domyślne i wrócimy do I2S3, zobaczymy Error between Selected and Real  – jest to dość znaczny błąd (ponad 18%). Na szczęście nasz mikrokontroler ma oddzielną pętlę PLL dla generacji dźwięku – nazywa się PLLI2S i pozwala na wybranie częstotliwości dopasowanej do potrzeb audio. Przykładowa konfiguracja używa dzielnika M = 5, mnożnika N = 141, co daje częstotliwość PLLI2S równą 112,8 MHz:

Projekt STM32CubeIDE – Clock Configuration

Projekt STM32CubeIDE – Clock Configuration

Jak teraz przełączymy się z powrotem na I2S3, to zobaczymy, że błąd wynosi tylko 0,14%:

Projekt STM32CubeIDE – konfiguracja I2S Parameter Settings

Projekt STM32CubeIDE – konfiguracja I2S Parameter Settings

Konfiguracja interfejsu I2C

Jak pamiętamy, interfejs I2C zostanie wykorzystany do konfiguracji układu CS43L22. Na płytce Discovery będzie to dokładnie interfejs I2C1 – znajdziemy go w kategorii Connectivity. Aby go włączyć, wystarczy wybrać interfejs, a następnie jako tryb (Mode) ustawić I2C. Parametry możemy zostawić domyślne.

Projekt STM32CubeIDE – konfiguracja I2C

Projekt STM32CubeIDE – konfiguracja I2C

Niestety domyślne przypisanie pinów nie pasuje do naszej płytki, konieczne jest więc wybranie zakładki GPIO Settings i zmienienie pinów na PB6 oraz PB9.

Projekt STM32CubeIDE – konfiguracja I2C GPIO Settings

Projekt STM32CubeIDE – konfiguracja I2C GPIO Settings

Konfiguracja linii Audio_RST

Interfejs I2C powinien być już gotowy do użycia. Możemy przejść do ostatniego etapu, czyli do linii resetującej układ CS43L22. Tym razem sprawa jest prostsza, bo jest to zwykły pin GPIO – dodajemy więc PD4 jako wyjście i tyle!

Projekt STM32CubeIDE – konfiguracja GPIO linii resetującej

Projekt STM32CubeIDE – konfiguracja GPIO linii resetującej

Teraz nasz projekt jest już autentycznie gotowy – wystarczy go zapisać, a CubeIDE wygeneruje kod potrzebny do inicjalizacji używanych przez nas peryferiów.

Pierwszy program audio – generowanie tonu

Przygotowanie projektu za pomocą CubeMX zajęło sporo miejsca, więc żeby nie tworzyć zbyt długiego artykułu, podam teraz mocno skróconą wersję kodu testowego. W dalszej części postaram się skupić nieco dokładniej na programowaniu i omówię precyzyjnie, co i jak działa. Tutaj skupmy się jednak na szybkim uzyskaniu słyszalnego efektu.

Żeby przetestować naszą konfigurację, potrzebujemy dwóch rzeczy: ustawień dla CS43L22 oraz danych, czyli próbek (sampli) dźwięku. Ustawienia CS43L22 to ogromny temat – w przykładach dostarczanych przez ST wraz z CubeIDE znajdziemy rozbudowaną bibliotekę obsługującą ten układ. Poniżej widoczna jest moja minimalna propozycja.

Adres naszego układu, czyli 0x94, znajdziemy w nocie katalogowej oraz na schemacie płytki. Funkcja cs43l22_write zapisuje wartość do wewnętrznego rejestru CS43L22, a opis wszystkich rejestrów znajdziemy oczywiście we wspomnianej wcześniej dokumentacji (za chwilę je omówimy).

Funkcja cs43l22_init to maksymalnie uproszczona inicjalizacja CS43L22 – zwalniamy linię resetującą, czyli AUDIO_RST, a następnie do rejestrów 0x04, 0x06 i 0x02 wpisujemy magiczne wartości. W dalszej części artykułu postaram się wyjaśnić, o co w tym chodzi.

Teraz wystarczy przygotować i wysłać dane samego dźwięku:

Próbki dźwięku są 16-bitowe, kodowane jako wartości ze znakiem, dlatego bufor audio_data ma typ int16_t. Stała BUFFER_SIZE określa liczbę próbek w buforze, a ponieważ obsługiwany jest dźwięk stereo, sama tablica musi być dwa razy większa.

Funkcja play_tone najpierw generuje próbki do odtwarzania – jest to sinusoida o częstotliwości 1 kHz (częstotliwość samplowania to 22 kHz, stąd dzielenie przez 22,0). Na koniec w pętli while dane są wysyłane do układu CS43L22 za pomocą funkcji HAL_I2S_Transmit. Teraz wystarczy skompilować program, wgrać i można „cieszyć się” efektem:

Sukces! Niezbyt widowiskowy, ale sukces – ewidentnie coś się dzieje, bo efekt działania naszego kodu jest słyszalny w głośniku. Pora zająć się tematem dokładniej. Przyjrzyjmy się teraz konfiguracji układu CS43L22 za pomocą I2C. Do tego trochę więcej informacji o samym protokole I2S, a wszystko po to, aby odtworzyć coś ciekawszego niż ton 1 kHz.

Układ CS43L22 – co trzeba wiedzieć?

Za generowanie dźwięku na naszej płytce odpowiada wspominany wcześniej układ CS43L22. Wszystkie informacje o jego działaniu znajdziemy w dokumentacji – ma ona raptem 66 stron, co jest całkiem niewielką liczbą jak na układy dźwiękowe. Większość płytek serii Discovery używa o wiele bardziej rozbudowanego układu WM8994, a w tym wypadku trzeba już przebrnąć przez 360 stron. Zachęcam więc do przeczytania dokumentacji. Poniżej przygotowałem jednak skrócony opis funkcji, które używane były w programie z poprzedniego przykładu.

Komunikacja przez interfejs I2C

Jak wspominałem wcześniej, do konfiguracji CS43L22 używany jest znany nam z kursu F4 interfejs I2C. Adres układu znajdziemy w dokumentacji płytki Discovery i wynosi on 0x94. Sama komunikacja przebiega dość typowo – chcąc zapisać ustawienia, wysyłamy najpierw adres układu, potem numer rejestru, a na koniec wartość. Adres rejestru ma postać pojedynczego bajta, podobnie przechowywana w nim wartość. W dokumentacji znajdziemy elegancki diagram komunikacji z układem:

Diagram komunikacji z układem CS43L22

Diagram komunikacji z układem CS43L22

Odczyt jest nieco bardziej skomplikowany, ale na szczęście w bibliotece HAL mamy gotowe funkcje realizujące dokładnie taki sposób zapisu i odczytu danych:

Parametry tych funkcji to:

  • hi2c – adres struktury opisującej interfejs I2C utworzonej przez CubeIDE
  • DevAddress – adres układu CS22L43, czyli 0x94
  • MemAddress – numer rejestru, do którego zapisujemy albo z którego odczytujemy
  • MemAddSize – wielkość adresu, zawsze 1 (bajt)
  • pData – dane do zapisania albo adres bufora do odczytu
  • Size – liczba zapisywanych lub odczytywanych bajtów (też 1 bajt)
  • Timeout – to chyba wiadomo

Jest jeszcze oczywiście zwracana wartość, którą powinniśmy testować i odpowiednio reagować, jeśli pojawi się błąd. Dla poprawienia czytelności kodu pomijam sprawdzanie błędów, ale zachęcam do ich uwzględniania we własnych programach.

W związku z tym, że większość parametrów jest zawsze taka sama, napisałem funkcję cs43l22_write, która przyjmuje tylko zmieniające się wartości, czyli numer rejestru i zapisywaną wartość:

Moglibyśmy oczywiście również zdefiniować podobną funkcję do odczytu:

Najważniejsze rejestry układu CS43L22

Skoro wiemy, jak zapisywać i odczytywać dane z rejestrów CS43L22, to teraz pora na trudniejszą część, czyli same rejestry. Ich listę znajdziemy w dokumentacji układu.

Dokumentacja układu CS43L22 – lista rejestrów

Dokumentacja układu CS43L22 – lista rejestrów

Dokumentacja układu CS43L22 – lista rejestrów

Dokumentacja układu CS43L22 – lista rejestrów

W pierwszej chwili ich liczba może nieco odstraszać, ale – jak wspomniałem wcześniej – to i tak dość prosty w obsłudze układ. I jak już widzieliśmy – nie musimy używać wszystkich jego funkcji.

Na początek możemy spróbować odczytać wartość z rejestru o numerze 0x01, który ma nazwę ID i – jak łatwo się domyślić – zwraca identyfikator układu:

Rejestr zawierający chip ID

Rejestr zawierający chip ID

W tym celu piszemy bardzo krótki program:

Odczytaną wartość sprawdzamy np. debuggerem – u mnie ID wynosi 0xe3, czyli jest to wersja B1.

Wynik działania programu – wartość podejrzana w debuggerze

Wynik działania programu – wartość podejrzana w debuggerze

Szybka konfiguracja CS43L22

Opis wszystkich rejestrów znajdziemy w dokumentacji – ja postaram się teraz krótko opisać minimalną wersję, która była używana w poprzednim przykładzie, czyli:

Najpierw szybki rzut oka na notę katalogową:

Rejestr 0x04 – Power Control 2

Rejestr 0x04 – Power Control 2

Zapis wartości 0xaf do tego rejestru włącza lewy oraz prawy kanał wyjścia słuchawkowego. Do bitów PDN_HPx[1:0] zapisywane jest 10, czyli wybierana opcja to Headphone channel is always ON, natomiast wyjście głośniczka (speaker) jest wyłączane: PDN_SPKx[1:0] ustawione na 11 odpowiada opcji Speaker channel is always OFF.

Rejestr 0x06 – Interface Control 1

Rejestr 0x06 – Interface Control 1

Tutaj ustawiamy kilka rzeczy jednocześnie – najważniejsze to:

  • Master/Slave – nasz układ będzie działał jako slave, to mikrokontroler generuje wszystkie sygnały sterujące. Zerujemy więc bit M/S.
  • DAC Interface Format – okazuje się, że CS43L22 obsługuje nie tylko interfejs I2S, ale też podobne do niego interfejsy. Każda opcja powinna działać, ale musimy wybrać to samo co w poprzedniej części, konfigurując CubeMX. Do bitów DACIF[1:0] zapisujemy zatem wartość 01.
  • Audio Word Length – w trybie I2S ten parametr nie jest istotny, ale dla porządku wybierzemy 16-bitową wielkość danych, czyli do AWL[1:0] zapisujemy 11.
Rejestr 0x06 – Interface Control 1 (Audio Word Length)

Rejestr 0x06 – Interface Control 1 (Audio Word Length)

Jak przeliczymy wybrane opcje na wartość heksadecymalną, to otrzymamy właśnie 0x07, które było użyte w naszym wcześniejszym programie.

Teraz pora na Power Down. Ten rejestr jest bardzo prosty, chociaż nieco zaskakujący. Zapisanie do niego wartości 0x01 lub 0xaf usypia układ CS43L22, a 0xae budzi go. Zaskakujące jest, dlaczego nie użyto po prostu wartości 0/1, ale jak widać, 0xae było z jakiegoś powodu wygodniejsze.

Rejestr 0x02 – Power Control 1

Rejestr 0x02 – Power Control 1

Na koniec jeszcze dwa rejestry, których nie używaliśmy, ale warto o nich wspomnieć:

Rejestry 0x20 i 0x21 – Master Volume Control

Rejestry 0x20 i 0x21 – Master Volume Control

Tutaj chyba wszystko jest oczywiste – zapisując do tych rejestrów, możemy regulować głośność dźwięku. To może być dość przydatna funkcja w docelowym programie.

Pisząc „prawdziwy” kod, powinniśmy oczywiście zdefiniować odpowiednie stałe i korzystać z nich zamiast z magicznych liczb. Jednak skoro przykłady miały być krótkie, to są też mocno niedoskonałe, za co z góry przepraszam wszystkie osoby, którym to mocno przeszkadza. Przy okazji zachęcam do tego, aby podzielić się w komentarzach swoimi, lepszymi wersjami programu!

Komunikacja przez interfejs I2S

Teraz wreszcie coś nowego, bo o I2S jeszcze nie pisaliśmy. Krótki opis tej magistrali znajdziemy nawet na Wikipedii. Jak widzimy, I2S używa trzech linii sygnałowych, ale często dodawana jest jeszcze jedna – mamy więc razem cztery sygnały. Używając nazw ze schematu płytki:

  • I2S3_MCK – zegar używany wewnętrznie do taktowania CS43L22
  • I2S3_SCK – zegar dla transmisji danych
  • I2S3_SD – linia danych
  • I2S3_WS – aktywny kanał (L/R)

Przedrostek I2S3_ wskazuje na podłączenie CS43L22 do interfejsu I2S3 mikrokontrolera – pisząc ogólnie o I2S, będę go pomijać, zostaną więc linie: MCK, SCK, SD, WS.

Fragment schematu płytki Discovery z układem audio

Fragment schematu płytki Discovery z układem audio

Omówienie interfejsu I2S najłatwiej zacząć od końca, czyli linii WS. Stan niski oznacza transfer danych dla lewego kanału, a wysoki – dla prawego. Prawda, że proste? Przy okazji: jaka powinna być częstotliwość sygnału pojawiającego się na tej linii? Chyba dość łatwo odgadnąć, że powinna ona odpowiadać częstotliwości próbkowania, czyli fs.

To bardzo ważna wartość, bo wszystkie inne są obliczane na jej podstawie. W poprzedniej części wybraliśmy częstotliwość 22 kHz (pewnie oznacza ona 22050 Hz). Teraz możemy podłączyć analizator logiczny i sprawdzić, czy faktycznie nasz układ działa tak, jak tego oczekujemy.

Odczyt analizatora logicznego – sprawdzenie częstotliwości

Odczyt analizatora logicznego – sprawdzenie częstotliwości

Kolejną używaną linią sygnałową jest SD, czyli linia danych. W odróżnieniu od SPI interfejs I2S działa raczej jednokierunkowo – do wyjścia słuchawkowego będziemy tylko wysyłać dane, a gdybyśmy mieli podłączony np. mikrofon, dane byłyby tylko odczytywane. Wysyłamy 16 bitów na każdą próbkę dźwięku, a wartość jest interpretowana jako liczba ze znakiem.

Żeby zobaczyć, jak nasze dane wyglądają w działaniu, możemy analizatorem zmierzyć napięcie na wyjściu słuchawkowym. Jak widzimy poniżej, wysyłając 0, dostajemy na wyjściu około 0 V, zapisanie −32000 daje 1 V, a 32000 – około −1 V. Co więcej, przebieg wygląda jak sinusoida, czyli jest tak jak chcieliśmy.

Linia SCK jest używana jako zegar dla linii danych. Jak pamiętamy, częstotliwość fs to 22 kHz, a każda próbka ma 16 bitów. Musimy jednak przesłać dane dla lewego i prawego kanału – zatem zegar danych będzie miał częstotliwość: fs * 32, czyli 704 kHz.

Odczyt analizatora logicznego – częstotliwość zegara danych

Odczyt analizatora logicznego – częstotliwość zegara danych

Na koniec linia MCK. Nie jest ona używana bezpośrednio do transmisji danych i nie jest częścią standardu I2S. Jednak układ CS43L22 wymaga sygnału taktującego – i taką właśnie funkcję pełni MCK. Częstotliwość powinna wynosić 256 * fs, czyli 5,632 MHz.

Odczyt analizatora logicznego – podgląd sygnału taktującego

Odczyt analizatora logicznego – podgląd sygnału taktującego

Powrót do CubeMX – ostateczne ustawienia

To wszystko skonfigurowaliśmy na początku artykułu. Teraz możemy wrócić do ustawień i wyjaśnić, co dokładnie oznaczały niektóre pozycje. Tryb Half-Duplex Master pozwala tylko wysyłać dane – w końcu i tak nic ciekawego ze słuchawek nie odczytamy. Z kolei zaznaczenie Master Clock Output sprawia, że generowany jest sygnał MCK.

Selected Audio Frequency to częstotliwość próbkowania fs, a wybraliśmy połowę jakości CD, czyli 22050 Hz. Nasz standard komunikacji to I2S Philips, a wybrana wartość musi pasować do tego, co wpisaliśmy do rejestru 0x06 układu CS43L22.

CubeMX – końcowe ustawienia

CubeMX – końcowe ustawienia

Jeszcze dwa słowa o błędzie i taktowaniu mikrokontrolera. Źródło taktowania modułu I2S wybieramy multiplekserem I2S source Mux (widoczny po prawej stronie na dole). Wybraliśmy zegar PLLI2SCLK, który pracuje z częstotliwością 112,8 MHz.

Jeśli podzielimy tę wartość przez oczekiwaną dla MCK, czyli 5,66, dostajemy wynik bliski 20. Słowo „bliski” oznacza właśnie błąd naszej częstotliwości – gdyby wyszło dokładnie 20, mielibyśmy idealnie fs = 22050, a tak mamy 22031 Hz, czyli chyba całkiem nieźle.

Powrót do Clock Configuration

Powrót do Clock Configuration

Poznaliśmy mnóstwo niskopoziomowych szczegółów związanych z odtwarzaniem dźwięku za pomocą STM32. Czas wreszcie zrobić coś ciekawszego i zdobytą wiedzę wykorzystać w praktyce!

Jaki format zapisu dźwięku wybrać?

Niewątpliwym problemem podczas generowania dźwięku przy wykorzystaniu mikrokontrolera jest ilość danych. Wybraliśmy próbkowanie fs = 22050 Hz, więc jedna sekunda „utworu” wymaga ponad 86 kB danych (22050 Hz * 2 kanały * 16 bitów). Układ STM32F411 ma co prawda 128 kB pamięci RAM, jednak nawet wykorzystując całą pamięć, zmieścilibyśmy raptem półtorej sekundy muzyki.

Moglibyśmy zapisać muzykę w pamięci Flash, ale jej mamy 512 kB – bardzo dużo jak na mikrokontroler, ale mało jak na dane audio. Nie pozostaje więc nic innego niż użycie jakiegoś sposobu kompresji.

Obecnie chyba najbardziej popularnym formatem zapisu skompresowanej muzyki jest mp3 i pewnie udałoby nam się odtworzyć plik tego typu. Jednak wielkość pliku mp3 może być nadal spora, a zapotrzebowanie na moc obliczeniową niemałe.

Pliki z muzyką w tym formacie dostępne są w wielu miejscach, np. na stronie modarchive. Co więcej, niektóre są zaskakująco małe. Na początek wybrałem plik nazwany „minimalic”, który zajmuje raptem trochę ponad 13 kB (dla testu plik można odtworzyć w przeglądarce).

Dekodowanie pliku .mod na STM32

Nie jestem niestety ekspertem od plików .mod, pozostało mi więc poszukać biblioteki, która będzie w stanie je przetwarzać. Znalazłem bardzo ciekawą bibliotekę HxCModPlayer. W pierwszej chwili może wyglądać skomplikowanie, ale wystarczy skopiować dwa pliki: hxcmod.h i hcmod.c.

Znajdziemy w nich bardzo łatwe w użyciu funkcje:

Typ modcontext przechowuje stan naszego odtwarzacza i nie musimy właściwie nic o nim wiedzieć  poza tym, że potrzebna będzie nam zmienna tego typu, czyli:

Bardzo prosty program z użyciem tej biblioteki mógłby wyglądać następująco:

Funkcja hxcmod_init inicjalizuje pola w zmiennej modctx. Następnie hxcmod_setcfg odpowiada za ustawienie parametrów dźwięku (najważniejsze jest ustawienie częstotliwości próbkowania). Kolejna wywoływana funkcja to hxcmod_load, która wczytuje plik .mod – jako parametry dostaje on tablicę z danymi, czyli z plikiem .mod, oraz jego wielkość (za chwilę opiszę, jak takie dane przygotować).

Na koniec wywołujemy funkcję hxcmod_fillbuffer, która przygotowuje dane do wysłania za pomocą HAL_I2S_Transmit. Do bufora audio_data zapisywana jest tylko część danych (a nie cały utwór). Liczbę próbek podajemy jako trzeci parametr funkcji – w przykładowym kodzie to po prostu BUFFER_SIZE.

Konwersja pliku .mod na tablicę

Plik .mod musimy jakoś umieścić w pamięci dostępnej dla mikrokontrolera. Chyba najłatwiejszą opcją jest przekonwertowanie pliku na tablicę bajtów i dodanie do programu. Prosty skrypt w Pythonie wykona taką pracę za nas (źródło ze strony stackoverflow).

Po zmianie nazwy tablicy na minimalistic_mod oraz dodaniu stałej minimalistic_mod_size możemy utworzyć plik nagłówkowy minimalic_mod.h. Samego pliku minimalic_mod.c nie załączam tutaj, ale jest dostępny w repozytorium razem ze źródłami opisywanych programów.

Program testowego odtwarzacza

Przed definicją funkcji main dodajemy:

Poprzednio bufor audio_data miał wielkość 2200 – była w tym ukryta pewna sztuczka, bo żeby ton 1 kHz ładnie brzmiał, wielkość bufora musiała być wielokrotnością okresu funkcji (czyli 22). Teraz każdą zawartość bufora będziemy przygotowywać, wywołując funkcję hxcmod_fillbuffer, więc możemy wybrać inną wartość, a 4096 to taka sympatyczna, okrągła (dla programistów) liczba.

Do samej funkcji main dopisujemy:

Teraz możemy uruchomić nasz program – efekt (słyszalny na poniższym wideo) niestety nie do końca jest taki, jak oczekiwaliśmy. Okazuje się, że dekodowanie kolejnej porcji próbek zajmuje mało czasu, ale jednak wystarczająco dużo, aby skutecznie popsuć efekt odtwarzania muzyki.

Problem z naszym dotychczasowym programem polega na tym, że robiliśmy jedną czynność naraz. Najpierw generowaliśmy dane do odtworzenia, później wysyłaliśmy przez I2S – i tak w pętli. Niestety podczas przetwarzania danych nie mogliśmy ich odtwarzać, a to powodowało słyszalne przerwy w dźwięku. Nie pozostaje nam więc nic innego, niż robić obie te rzeczy jednocześnie. Mamy co prawda tylko jeden procesor, ale z pomocą przychodzi nam mechanizm DMA.

Poprawienie działania programu z DMA?

Moglibyśmy po prostu zastąpić wywołanie HAL_I2S_Transmit funkcją HAL_I2S_Transmit_DMA. Niestety samo użycie DMA nie wystarczy, a efekt może być nawet gorszy niż bez DMA – generowanie nowych próbek nakłada się na odtwarzanie; uzyskany w taki sposób rezultat jest ciekawy, ale zupełnie odmienny od zamierzonego.

Podział buforu danych

Zamiast tego spróbujmy podzielić nasz bufor audio_data na dwie części. Gdy pierwsza zostanie w całości odtworzona, wtedy DMA rozpocznie pobieranie danych z drugiej części. Będziemy mogli w tym momencie nadpisać pierwszy bufor bez obawy, że używane dane zostaną uszkodzone.

Jak dowiemy się o zakończeniu odtwarzania? Okazuje się, że po wysłaniu połowy danych z bufora zgłaszane jest przerwanie, a po wysłaniu całości – kolejne. Możemy więc napisać dwie proste funkcje:

Pierwsza jest wywoływana po wysłaniu połowy danych i wywołuje hxcmod_fillbuffer, wypełniając pierwszą część bufora audio_data nowymi danymi. Druga funkcja zostanie wywołana, gdy cały bufor zostanie wysłany – ponieważ DMA jest używana w trybie cyklicznym (Circular), w tym momencie będą nadawane dane z pierwszej połowy bufora, a drugą możemy spokojnie uzupełnić.

Kod w funkcji main jest teraz prostszy niż poprzednio. Na początku wypełniamy bufor audio_data pierwszymi danymi i raz wywołujemy HAL_I2S_Transmit_DMA. Później wszystko będzie działało w przerwaniach – sama pętla główna jest pusta, ale oczywiście możemy umieścić w niej coś ciekawszego.

Cały program jest dostępny w repozytorium na GitHubie, a jego działanie wygląda następująco:

Priorytety przerwań od DMA

Co jeszcze można poprawić? Nasze przerwanie od DMA może zajmować dość dużo czasu procesorowi – powinno mieć zatem niższy priorytet niż np. SysTick. Priorytety przerwań możemy ustawić za pomocą CubeMX (tutaj wyższa wartość oznacza niższy priorytet):

Powrót do Clock Configuration – zmiana priorytetu

Powrót do Clock Configuration – zmiana priorytetu

Jakie jest wykorzystanie procesora?

Na koniec chciałbym wspomnieć o jednej rzeczy, mianowicie o wykorzystaniu procesora. Pomiary wykonywałem na innym układzie (STM32L476 taktowanym z częstotliwością 80 MHz) i wówczas generowanie dźwięku za pomocą biblioteki hxcmod wykorzystywało około 10% czasu CPU. Można więc założyć, że na F4 będzie nieco mniej.

Dzięki temu pozostały czas możemy spożytkować w inny, bardziej pożyteczny sposób, chociażby dodając wizualizację do odtwarzanej muzyki. Jako przykład wykorzystałem program napisany do artykułu o sterowaniu wyświetlaczem TFT. Program działa na płytce STM32F746-DISCOVERY, ponieważ miałem na niej zarówno wyjście audio, jak i wyświetlacz.

Efekt w rozdzielczości 480 × 272 px wyglądał następująco:

Podsumowanie

Chciałbym, żeby ten artykuł zachęcił Was do eksperymentów z generowaniem dźwięku na układach STM32. Okazuje się, że nie jest to takie trudne, jak się wydaje, a możliwości są przeogromne. Jak zawsze najtrudniejszy jest pierwszy krok – mam nadzieję, że poradnik ten ułatwi wielu osobom rozpoczęcie swojej przygody z generowaniem audio na mikrokontrolerach.

Czy wpis był pomocny? Oceń go:

Średnia ocena 5 / 5. Głosów łącznie: 25

Nikt jeszcze nie głosował, bądź pierwszy!

Artykuł nie był pomocny? Jak możemy go poprawić? Wpisz swoje sugestie poniżej. Jeśli masz pytanie to zadaj je w komentarzu - ten formularz jest anonimowy, nie będziemy mogli Ci odpowiedzieć!

Autor: Piotr (Elvis) Bugalski

audio, i2c, i2s, stm32

Trwa ładowanie komentarzy...