Radioodbiornik internetowy z dekoderem VS1053

Radioodbiornik internetowy z dekoderem VS1053

W dzisiejszych czasach większość rozgłośni radiowych prowadzi transmisje również w Internecie. Co więcej, wiele nowych stacji funkcjonuje wyłącznie w sieci. Sprawia to, że odbiorcy chętnie rozbudowują domowe systemy audio o urządzenia umożliwiające odbiór radia internetowego.

Głównym założeniem projektu było stworzenie odbiornika, który oprócz odtwarzania strumienia sieciowego, pozwoliłby także na wyszukiwanie oraz zapamiętywanie nowych stacji. Dodatkowe wymagania to zaprojektowanie intuicyjnego i prostego interfejsu użytkownika oraz możliwość sterowania urządzeniem za pomocą pilota lub enkodera obrotowego.

Wybór podzespołów

Jako centralny element wybrany został mikrokontroler ESP32-S3 na płytce developerskiej DevKitC-1. Głównym argumentem przy wyborze tego właśnie układu była wbudowana obsługa sieci Wi-Fi (stanowiąca kluczowy element projektu) oraz wystarczająca ilość pamięci i wydajność.

Kolejną kwestią wymagającą przemyślenia okazał się sposób obsługi skompresowanych danych audio. Pierwsze próby realizacji zakładały programowe dekodowanie strumienia MP3 i wysyłanie zdekodowanego sygnału do zewnętrznego przetwornika cyfrowo-analogowego (DAC) przez magistralę I²S. Jednak z uwagi na braki w dostępnych bibliotekach (liczbę obsługiwanych formatów) oraz dużą konsumpcję zasobów mikrokontrolera, przynosiło to niezadowalające rezultaty. Ostatecznie wybór padł na dekodowanie przy użyciu wyspecjalizowanego układu, a konkretnie VS1053 firmy VLSI Solution. Jest on często stosowany w tego rodzaju aplikacjach, gdyż zapewnia obsługę praktycznie wszystkich popularnych formatów kompresji audio (MP3, WMA, OGG Vorbis, FLAC, AAC). Co ciekawe, układ obsługuje również format MIDI. Do komunikacji z mikrokontrolerem używa magistrali SPI. Bardzo istotny okazuje się fakt, że dostępne są gotowe moduły z zamontowanym układem oraz obwodami peryferyjnymi, co ułatwia zastosowanie go w prototypach.

Aby spełnić założenia dotyczące interfejsu użytkownika, urządzenie trzeba było wyposażyć w odpowiednio duży i czytelny wyświetlacz w technologii OLED. Wybór padł na model o wielkości 2,42”, rozdzielczości 128×64 pikseli, sterowany układem SSD1309. Do budowy użyty został również enkoder obrotowy oraz odbiornik podczerwieni TSOP2236.

Interfejs użytkownika

Po uruchomieniu urządzenia na wyświetlaczu pojawia się ekran startowy, a w tle następuje inicjalizacja. Po jej zakończeniu odbiornik jest gotowy do obsługi i wyświetlone zostaje główne menu (fotografia 1). Wybraną pozycję zatwierdzamy krótkim naciśnięciem gałki enkodera. Dłuższe przyciśnięcie gałki powoduje powrót do poprzedniego menu. Do sterowania można również używać przycisków kursora na pilocie zdalnego sterowania. Istnieją ponadto nieliczne funkcje dodatkowe dostępne tylko za pośrednictwem pilota.

Fotografia 1. Główne menu

Favorites

Na wyświetlaczu pokazana jest lista zapisanych wcześniej „ulubionych” stacji radiowych. Użytkownik może uruchomić tutaj odtwarzanie wybranej stacji, a także usunąć wybraną pozycję z listy (usunięcie z listy dostępne jest tylko za pomocą pilota).

Select by country

Na wyświetlaczu widzimy listę krajów wraz z liczbą zarejestrowanych stacji z każdego kraju. Lista posortowana została pod kątem liczby dostępnych rozgłośni. Wybranie danej pozycji przenosi nas na listę stacji radiowych wybranego kraju (posortowaną pod kątem popularności) – wskazanie którejś z nich uruchamia odtwarzanie.

Select by tag

Po wybraniu tej pozycji otrzymujemy listę tagów, czyli słów kluczowych, które opisują rozgłośnie. Przy każdym tagu widzimy liczbę zarejestrowanych rozgłośni. Słowa kluczowe są posortowane pod kątem liczby zarejestrowanych w odniesieniu do nich stacji. Wybranie konkretnej pozycji przenosi użytkownika do listy rozgłośni dla danego tagu, z której można od razu przejść do odtwarzania wybranej stacji.

Settings->Select radio list server

Mamy tu możliwość wyboru serwera usługi sieciowej udostępniającej bazę danych z informacjami o stacjach. Usługa ta jest dostępna na kilku serwerach. W razie awarii jednego z nich możemy w tym miejscu przełączyć się na inny.

Settings->Wifi settings

Użytkownik może tutaj wybrać z listy sieć Wi-Fi, do której ma być podłączone urządzenie. Po wybraniu odpowiedniej sieci należy wprowadzić hasło dostępowe. Zatwierdzamy je długim przyciśnięciem gałki enkodera.

Settings->Restart

Wybierając tę opcję, można zrestartować urządzenie.

Turn off

Po wybraniu tej opcji odbiornik przechodzi w tryb standby.

Ekran odtwarzania

Podczas odtwarzania na wyświetlaczu (fotografia 2) widoczne są:

  • nazwa odtwarzanej stacji,
  • kraj odtwarzanej stacji,
  • bitrate [kbps],
  • ustawiona głośność,
  • liczba bajtów w buforze odtwarzania (aktualizowana cały czas),
  • napis „chunked”, jeśli strumień transmitowany jest w takim właśnie trybie.
Fotografia 2. Ekran odtwarzania

W trakcie odtwarzania użytkownik może dodać stację do listy ulubionych. Funkcja ta dostępna jest tylko za pomocą pilota. Możliwa jest również regulacja głośności odtwarzania.

Usługa radio-browser.info

Jedno z głównych założeń projektu zakłada możliwość wyszukiwania nowych stacji. Do zrealizowania tej funkcjonalności potrzebna jest baza danych zawierająca aktualne informacje i udostępniająca interfejs (API – Application Programming Interface), przez który będziemy w stanie pobrać potrzebne dane. Powyższe założenia spełnia serwis radio-browser.info. Jest to w pełni darmowa usługa utrzymywana przez społeczność internetową. Obecnie w serwisie zarejestrowanych jest ponad 47 tysięcy rozgłośni. Użytkownik może wyszukiwać stacje po różnych kryteriach, takich jak: kraj, popularność, powiązane tagi, język, kodek itp. Dostępna jest również opcja dodawania nowych stacji do bazy.

Komunikacja z API serwisu odbywa się przez protokół HTTP i nie jest wymagane zakładanie konta ani żadna inna metoda uwierzytelniania. Użytkownicy otrzymują rozbudowane opcje dotyczące stronicowania, sortowania i filtrowania zwracanych danych. Parametry zapytań do serwisu można przekazywać na dwa sposoby:

  • W adresie URL żądania HTTP GET (przez Query String Parameters – parametry oddzielone znakiem ‘&’).
  • W zawartości (payload) żądania HTTP POST, w którym lista parametrów musi być zapisana w formacie JSON (JavaScript Object Notation).

Przykładowy URL żądania HTTP GET (stanowiącego zapytanie o listę stacji z danego kraju) wygląda następująco:

Dane z serwisu zwracane są w formacie JSON, a ich przykładowa postać widnieje na listingu 1.

[
{
"changeuuid": "8125d095-6397-4660-a5c4-ef0254f67e06",
"stationuuid": "9617a958-0601-11e8-ae97-52543be04c81",
"serveruuid": null,
"name": "Radio Paradise (320k)",
"url": "http://stream-uk1.radioparadise.com/aac-320",
"url_resolved": "http://stream-uk1.radioparadise.com/aac-320",
"homepage": "https://www.radioparadise.com/",
"favicon": "https://www.radioparadise.com/favicon-32x32.png",
"tags": "california,eclectic,free,internet,non-commercial,paradise,radio",
"country": "The United States Of America",
"countrycode": "US",
"iso_3166_2": null,
"state": "California",
"language": "english",
"languagecodes": "en",
"votes": 186524,
"lastchangetime": "2023-11-04 14:11:01",
"lastchangetime_iso8601": "2023-11-04T14:11:01Z",
"codec": "AAC",
"bitrate": 320,
"hls": 0,
"lastcheckok": 1,
"lastchecktime": "2024-04-09 08:25:52",
"lastchecktime_iso8601": "2024-04-09T08:25:52Z",
"lastcheckoktime": "2024-04-09 08:25:52",
"lastcheckoktime_iso8601": "2024-04-09T08:25:52Z",
"lastlocalchecktime": "2024-04-09 08:25:52",
"lastlocalchecktime_iso8601": "2024-04-09T08:25:52Z",
"clicktimestamp": "2024-04-09 20:26:41",
"clicktimestamp_iso8601": "2024-04-09T20:26:41Z",
"clickcount": 1570,
"clicktrend": 28,
"ssl_error": 0,
"geo_lat": null,
"geo_long": null,
"has_extended_info": false
}
]

Listing 1. Odpowiedź w formacie JSON

Zachęcam do zapoznania się z dokumentacją API usługi radio-browser.info, gdyż serwis ten oferuje wiele funkcjonalności i może być bardzo pomocny przy budowie podobnych projektów.

Oprogramowanie

Na początku programu wywoływana jest funkcja Setup(), w której następuje inicjalizacja potrzebnych zasobów oraz wczytywana jest konfiguracja przechowywana w postaci pliku JSON, w pamięci flash mikrokontrolera. Do zapisu i odczytu konfiguracji użyty został system plików SPIFFS (SPI Flash File System), zaimplementowany w mikrokontrolerach firmy Espressif.

Plik konfiguracyjny zawiera informacje, takie jak: dane dostępowe do sieci Wi-Fi, nazwa serwera usługi radio-browser.info oraz lista zapisanych stacji. Fragment struktury opisującej konfigurację pokazany jest na listingu 2.

struct DeviceConfiguration
{
String serverName;
String wifiName;
String wifiPassword;
vector<RadioStationDTO> favorites;
//...
}

Listing 2. Fragment struktury opisującej konfigurację

Bardzo dużą rolę w naszym programie odgrywa obsługa formatu JSON. Zarówno plik konfiguracyjny, jak i dane z zewnętrznego serwisu są przekazywane w tym właśnie formacie. Dlatego istotne jest zaimplementowanie serializacji (zapisu obiektu do formatu JSON) oraz deserializacji (odtworzenia obiektu z postaci JSON). Z pomocą przychodzi tutaj popularna biblioteka ArduinoJson, która zapewnia obsługę potrzebnych operacji. Na listingu 3 widoczne są funkcje serializujące i deserializujące dane konfiguracyjne.

String SerializeConfguration()
{
DynamicJsonDocument configJson(8192);
configJson["serverName"] = serverName;
configJson["wifiName"] = wifiName;
configJson["wifiPassword"] = wifiPassword;

JsonArray favoritesArray = configJson.createNestedArray("favorites");

for (const auto &station : favorites)
{
JsonObject stationObject = favoritesArray.createNestedObject();
stationObject["name"] = station.Name;
stationObject["url"] = station.Url;
stationObject["bitrate"] = station.Bitrate;
stationObject["country"] = station.Country;
stationObject["radiofrequency"] = station.RadioFrequency;
}

String jsonString;
serializeJson(configJson, jsonString);
return jsonString;
}

void DeserializeDeviceConfiguration(const char *jsonString)
{
const size_t capacity = JSON_OBJECT_SIZE(4) + 160000;
DynamicJsonDocument jsonDocument(capacity);

DeserializationError error = deserializeJson(jsonDocument, jsonString);
if (error)
{
Serial.print("Json serialization errror: ");
Serial.println(error.c_str());
return;
}

serverName = jsonDocument["serverName"].as<String>();
wifiName = jsonDocument["wifiName"].as<String>();
wifiPassword = jsonDocument["wifiPassword"].as<String>();
favorites.clear();
JsonArray favoritesArray = jsonDocument["favorites"];
for (const auto &station : favoritesArray)
{
RadioStationDTO favoriteStation;
favoriteStation.Bitrate = station["bitrate"];
favoriteStation.Country = station["country"].as<String>();
favoriteStation.Name = station["name"].as<String>();
favoriteStation.RadioFrequency = station["radiofrequency"].as<String>();
favoriteStation.Url = station["url"].as<String>();
favorites.push_back(favoriteStation);
}
}

Listing 3. Funkcja serializująca i deserializująca konfigurację

Komunikacja z serwisem radio-browser.info zaimplementowana została w klasie RadioListHttpClient. Znajdują się tam metody, takie jak: GetTags(), GetCountries(), GetRadioURLsByCountry(), GetRadioURLsByTag(). Odpowiadają one za pobieranie danych z tego serwisu. W przypadku każdej metody proces przebiega według schematu:

  1. Przygotowanie odpowiedniego URL zawierającego potrzebne parametry (należy pamiętać, aby odpowiednio zakodować znaki specjalne – funkcja urlEncode).
  2. Wysłanie zapytania HTTP GET (metoda GET() klasy HTTPClient).
  3. Przekazanie strumienia HTTP do funkcji deserializeJson.
  4. Utworzenie potrzebnych obiektów klas DTO (Data Transfer Object) na podstawie obiektu klasy DynamicJsonDocument.
  5. Zwolnienie zasobów (httpClient.end() oraz jsonDoc.clear()).

Na listingu 4 ukazana jest funkcja GetCountries().

vector<CountryDTO> RadioListHttpClient::GetCountries()
{
vector<CountryDTO> outList;
DynamicJsonDocument jsonDoc(8072);
char urlText[400];
sprintf(urlText, "https://%s/json/countries?order=stationcount&limit=%i&reverse=true&offset=%i", _config->serverName.c_str(), countriesPerPage, countriesPerPage * countriesPageIndex);
httpClient.begin(urlEncode(urlText));
httpClient.GET();
deserializeJson(jsonDoc, httpClient.getStream());
httpClient.end();
for (auto item : jsonDoc.as<JsonArray>())
{
const char *name = item["name"];
const char *code = item["iso_3166_1"];
int stationsCount = item["stationcount"];
CountryDTO c;
c.name = name;
c.code = code;
c.count = stationsCount;
outList.push_back(c);
}
jsonDoc.clear();
return outList;
}

Listing 4. Funkcja pobierająca listę krajów

Wspomniane wyżej klasy DTO służą do przechowywania informacji o stacjach, krajach oraz tagach (słowach kluczowych). Mają one postać jak na listingu 5.

class RadioStationDTO
{
public:
String Name;
String Url;
int Bitrate;
String RadioFrequency;
String Country;
};

class CountryDTO
{
public:
String code;
String name;
int count;
};

class TagDTO
{
public:
String name;
int count;
};

Listing 5. Klasy DTO

Kolejny wart omówienia fragment firmware to proces odtwarzania strumienia. Najważniejsza jego część znajduje się w pliku HttpWebRadioClient.h. Na początku procesu odtwarzania tworzony jest task RTOS, w którym następuje podłączenie do wybranego serwera oraz wywołanie funkcji HTTP GET. Następnie odczytywane są nagłówki HTTP i pobierany jest wskaźnik do strumienia audio. Dane ze strumienia trafiają do bufora kołowego (circular buffer). Zawartość bufora kołowego (o ile nie jest pusty) jest cały czas wysyłana na magistralę SPI do układu dekodującego (VS1053). Odbywa się to w osobnym tasku RTOS, inicjalizowanym tylko raz, przy starcie urządzenia. Warto dodać, że istotna okazuje się tutaj synchronizacja dostępu do bufora kołowego, gdyż może zdarzyć się sytuacja, w której dwa zadania RTOS będą próbowały jednocześnie uzyskać do niego dostęp. W tym celu, w tzw. sekcji krytycznej użyta została klasa mutex i metody lock oraz unlock. Brak takiego zabezpieczenia powodował słyszalne błędy przy odtwarzaniu strumienia.

Warto zwrócić uwagę, że niektóre serwery wysyłają strumień w trybie chunked stream. Można to rozpoznać, gdy – po wysłaniu żądania HTTP GET – w nagłówku Transfer-Encoding otrzymamy wartość chunked. W trybie tym dane w strumieniu podzielone są na bloki. Każdy blok poprzedza informacja o jego długości (zapisana heksadecymalnie w ASCII) oraz sekwencja [CR][LF] (szesnastkowo [0x0D] [0x0A]). Koniec bloku danych również oznaczony jest sekwencją [CR][LF]. Transmisja kończy się pakietem o zerowej wielkości. Jeśli nie zinterpretujemy poprawnie takiego strumienia, w sygnale audio będą słyszalne nieprzyjemne zniekształcenia.

class DeviceStateBase{

public:
virtual void HandleLoop() = 0;
virtual void HandleUp() = 0;
virtual void HandleDown() = 0;
virtual void HandleLeft() = 0;
virtual void HandleRight() = 0;
virtual bool HandleEnter()= 0;
virtual bool HandleBack() = 0;
};

Listing 6. Klasa abstrakcyjna DeviceStateBase

Interfejs użytkownika jest obsługiwany przez klasy dziedziczące po klasie abstrakcyjnej DeviceStateBase widocznej na listingu 6. Umieszczone są one w podkatalogu /lib/DeviceStates. Znajdziemy w nich metody obsługujące zdarzenia wygenerowane przez użytkownika oraz wyświetlanie informacji na ekranie OLED. Każda z tych klas odpowiada za konkretny stan, w jakim może się znaleźć urządzenie. Listę stanów reprezentuje typ wyliczeniowy UIState (listing 7).

enum UIState
{
MODE_SELECT,
SELECT_TAG,
SELECT_COUNTRY,
SELECT_STATION,
SELECT_FAVORITES,
PLAY,
DEVICE_START,
SELECT_SETTINGS,
SELECT_SERVER_SETTING,
SELECT_WIFI_SETTING,
ENTER_PASSWORD,
RESTART,
SLEEP
};

Listing 7. Typ wyliczeniowy UIState

Przykładowe implementacje metod wirtualnych możemy znaleźć na listingu 8. W każdej z nich najpierw sprawdzany jest stan, w jakim znajduje się urządzenie (w tym przypadku SELECT_TAG), a później wykonywana jest logika danej funkcji.

bool TagsListState::HandleEnter()
{
if (*_currentState != SELECT_TAG)
return false;

*_currentState = _stationsListState->EnterState(SELECT_TAG, "", tags[currentIndex].name);
return true;
}

void TagsListState::HandleRight()
{
if (*_currentState != SELECT_TAG)
return;

_radioListClient->SetNextTagsPage();
GetTagsPage();
currentIndex = 0;
}

void TagsListState::HandleLeft()
{
if (*_currentState != SELECT_TAG)
return;

_radioListClient->SetPrevTagsPage();
GetTagsPage();
currentIndex = 0;
}

Listing 8. Implementacje metod wirtualnych w klasie TagsListState

Obsługa pilota zdalnego sterowania zrealizowana została za pomocą biblioteki IRemote, pozwalającej na dekodowanie większości protokołów używanych we współczesnych pilotach. Przy inicjalizacji wystarczy tylko podać pin mikrokontrolera, do którego podłączono odbiornik podczerwieni. Następnie należy w pętli wywoływać metodę decode() – zwraca ona wartość true, jeśli sygnał z pilota zostanie odebrany i zdekodowany. Po zdekodowaniu mamy dostęp do informacji, takich jak: typ protokołu, adres i komenda. Można również pobrać surowe dane odebrane przez odbiornik. Na podstawie wykrytych komend wywoływane są odpowiednie metody klas obsługujących stany urządzenia.

Wyłączenie odbiornika realizowane jest przez przejście mikrokontrolera w tryb deep sleep. Wybudzenie na przyciśnięcie gałki enkodera zostało skonfigurowane za pomocą funkcji esp_sleep_enable_ext0_wakeup.

Cały proces developmentu odbył się z użyciem edytora Visual Studio Code oraz frameworku PlatformIO. Zawartość pliku inicjalizacyjnego platformio.ini widoczna jest na listingu 9.

[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
lib_deps =
baldram/ESP_VS1053_Library@^1.1.4
bblanchon/ArduinoJson@^6.21.3
rlogiacco/CircularBuffer@^1.3.3
olikraus/U8g2@^2.35.4
igorantolic/Ai Esp32 Rotary Encoder@^1.6
z3t0/IRremote@^4.2.0
monitor_speed = 115200

Listing 9. Plik inicjalizacyjny platformio.ini

Znajdują się w nim dane dotyczące modelu mikrokontrolera, lista zależności (czyli użytych zewnętrznych bibliotek) oraz inne dane konfiguracyjne. Dokumentacja dotycząca pliku platformio.ini znajduje się na stronie: https://docs.platformio.org/page/projectconf.html.

Montaż i uruchomienie

Schemat ideowy urządzenia pokazano na rysunku 1.

Rysunek 1. Schemat ideowy odbiornika

Odbiornik zasilany jest napięciem 5 V, filtrowanym przez kondensatory C1...C4 i doprowadzonym do płytki mikrokontrolera oraz do modułu dekodera. Wyświetlacz i enkoder obrotowy zasilane są z wyjścia 3,3 V na płytce mikrokontrolera. Wyjście audio stanowi gniazdo jack 3,5 mm umieszczone na płytce z układem dekodera. Wyświetlacz i układ VS1053 komunikują się z mikrokontrolerem za pośrednictwem dwóch oddzielnych magistral SPI. Zasilanie sygnalizuje dioda LED podłączona przez rezystor R1. Odbiornik podczerwieni TSOP2236 – zasilany napięciem 5 V – podłączony jest do pinu GPIO41 mikrokontrolera.

Prototyp został zrealizowany na uniwersalnej płytce drukowanej (fotografia 3), na co pozwoliła mała liczba komponentów potrzebnych do budowy. Obudowę zaprojektowano w aplikacji Fusion360, a następnie wydrukowano na drukarce 3D przy użyciu filamentu PLA.

Fotografia 3. Prototyp w trakcie budowy

Podsumowanie

Repozytorium projektu zostało umieszczone w serwisie GitHub i jest publicznie dostępne. Gorąco zachęcam do budowy podobnych urządzeń i mam nadzieję, że rozwiązania opisane w tym artykule okażą się pomocne.

Paweł Ciuraj

Wykaz elementów:
 
  • ESP32-S3 DevKitC-1 (1 szt.)
  • Rezystor 220 Ω (1 szt.)
  • Dioda LED (1 szt.)
  • Wyświetlacz OLED 2.42” SSD1309 (1 szt.)
  • Enkoder obrotowy z przyciskiem (1 szt.)
  • Odbiornik podczerwieni TSOP2236 (1 szt.)
  • Moduł VS1053 (1 szt.)
  • Kondensator 100 μF/16 V (2 szt.)
  • Kondensator 100 nF (2 szt.)
Artykuł ukazał się w
Elektronika Praktyczna
maj 2024
DO POBRANIA
Materiały dodatkowe
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik grudzień 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio listopad - grudzień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje listopad - grudzień 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna grudzień 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich grudzień 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów