Rádio OTAVA vysílalo na internetu způsobem, který funguje přesně do chvíle, než přestane. Zvuk ze studia se přehrával ve VLC a odtud putoval na cizí virtuální server, ke kterému neměl nikdo ze stanice ani SSH přístup. Když pak na webu přestaly naskakovat názvy právě hraných písní — nejspíš potom, co web přešel z Apache na nginx — bylo jasné, že provizorium dosloužilo.
Klient přišel s docela konkrétní představou. Přejít na Icecast, tři kvality streamu (32, 64 a 128 kbps), kódování v AAC, funkční přes HTTP i HTTPS, dva zvukové zdroje s automatickým přepnutím na záložní, názvy písní z programu PIRA Magic RDS a rozumnou cenu. A pak tam byly dvě věty, které vypadaly jako drobnost, ale nakonec určily celou architekturu: stream se má „automaticky přepínat podle připojení" a zároveň „aby si uživatel mohl vybrat".
Ve studiu stály dva hardwarové audio enkodéry DEVA DB91-TX a na Windows běžel PIRA Magic RDS 4. S tím se mělo pracovat. Než jsem ale načrtl jedinou konfiguraci, sedl jsem si k manuálům. A dobře, že jsem to udělal.
Nejdřív manuály, teprve pak návrh
Mám jednoduché pravidlo: dokud si limity hardwaru neověřím v dokumentaci, jsou to jen moje domněnky. Tři zjištění z manuálů obrátila původní plán naruby.
1. Enkodér neumí metadata. Vůbec. V celé dokumentaci ani v historii firmwaru DB91-TX se nevyskytují slova metadata, title, song, artist ani RDS. Na zadním panelu není jediný sériový port, kudy by názvy mohly odejít. Znamená to, že názvy písní musí jít úplně samostatnou cestou — přes Magic RDS. Klientova představa „názvy jinou trasou" tak nebyla jen možná, byla jediná možná.
2. Enkodér neumí sám přepnout na zálohu. Jeden Icecast source client má v jednotce přesně pět polí: adresa, port, mount, uživatel, heslo — a míří vždy jen na jeden server. Když cíl vypadne, sám nepřepne. (Jednotka má sice tři nezávislé sendery a jimi by teoreticky zvládla tlačit i na dva servery paralelně, ale rozhodnout o přepnutí sama neumí.) Redundanci proto musí řešit server, ne hardware. Obě jednotky tak tlačí nepřetržitě, každá na svůj mount, a vybírá se až na serveru. Paradoxně je to lepší: záloha není „studená", ale pořád běží a je nabufferovaná, takže přepnutí je okamžité.
3. HE-AAC z jednotky končí na 88 kbps stereo. Kvalita se v DB91-TX volí z pevné tabulky 1–6 a strop HE-AAC je právě těch 88 kbps. Cílový 128k stream se z jednotky prostě nevyrobí. Přepis na serveru tím přestal být volitelný a stal se nutným — a padla varianta „ať si jednotka vyrobí všechny tři kvality sama".
Kdybych tyhle tři věci neověřil a jel podle předpokladů, postavil bych architekturu, která by v půlce spadla na hardwarovém limitu.
Dvě cesty k posluchači, ne dva režimy jedné věci
Teď k té „drobnosti" ze zadání. „Automaticky přepínat podle připojení" a „aby si uživatel vybral" nejsou dva režimy jedné technologie. Jsou to dvě různé technologie a klientovi bylo potřeba to na rovinu vysvětlit.
| HLS (adaptivní) | Icecast (pevný bitrate) | |
|---|---|---|
| O kvalitě rozhoduje | přehrávač podle linky a bufferu | posluchač ručně přes URL |
| Kde září | web, mobily, iOS nativně | autorádia, TuneIn, staré přehrávače |
| Latence | ~8–10 s | ~2–5 s |
Jádro celého nedorozumění se dá říct jednou větou:
Icecast neumí přepínat kvalitu podle rychlosti posluchače — ne proto, že by o jeho lince nic nevěděl, ale proto, že netranskóduje a žádný mechanismus adaptivní kvality nemá. Každý mount servíruje jeden pevný bitrate a víc z něj nedostanete.
Rozhodování o kvalitě proto musí proběhnout u klienta — jedině přehrávač měří svoji linku i stav bufferu v reálném čase. A přesně to umožňuje HLS: když server nabídne víc kvalit najednou, přehrávač si mezi nimi sám vybírá podle odhadované šířky pásma i naplnění bufferu. To je jediná cesta, jak ono „automaticky" splnit doopravdy. „Aby si uživatel vybral" zase přesně sedí na Icecast s pevnými bitraty. Takže se nedělá jedno, nebo druhé. Dělá se obojí a každé slouží jinému posluchači i jinému zařízení.
Kodeky jsem volil podle toho, co na daném bitrate skutečně zní:
- 32k HE-AAC v2 — parametric stereo, jediné, co při 32k dává použitelný poslech.
- 64k HE-AAC v1 — SBR, sladké místo pro mobilní data.
- 128k AAC-LC — nad zhruba 96 kbps je „obyčejné" AAC-LC lepší než HE varianty.
- 128k MP3 navíc pro techniku, která AAC neumí.
A ještě jedno rozhodnutí, které se snadno přehlédne — kontribuce ze studia na server jede jako MP3 320 kbps. Vzít jako zdroj 88k HE-AAC z jednotky by znamenalo vyrábět „lepší" stream z horšího, což nejde. MP3 320 je z té pevné tabulky nejčistší dostupná volba a 320k upload utáhne úplně každá linka.
Proč to nakonec jede v Dockeru
Tady se plán potkal s realitou hned dvakrát a pokaždé jinak, než jsem čekal.
Původně jsem počítal s Ubuntu, které balí ořezaný fdk-aac-free bez HE-AAC. Docker měl tenhle problém obejít. Jenže server dorazil jako Debian 13 — a ten problém nemá, protože v non-free má plný Fraunhofer fdk-aac. Chvíli to vypadalo, že Docker vůbec nebude potřeba.
Háček přišel vzápětí. Debianní Liquidsoap FDK-AAC neumí — jeho balík je v main a ten ze zásady nesmí záviset na non-free. Bez FDK-AAC ale není HE-AAC, a bez HE-AAC nejsou použitelné nízké bitraty. Oficiální .deb od Savonetu zase nešel nainstalovat, protože je stavěný proti deb-multimedia (jiné epochy závislostí). A nasadit deb-multimedia by znamenalo vyměnit půl multimediálního stacku Debianu za třetí stranu — na produkci, kde se pak roky aktualizuje, nepřijatelné riziko.
Řešení byl oficiální Docker obraz savonet/liquidsoap, kde je FDK-AAC zkrátka yes. Docker přitom používám výhradně pro Liquidsoap a z doloženého důvodu — Icecast, nginx i metadata bridge běží nativně přes systemd. Žádné „zabalíme do kontejneru všechno, ať je klid".
Že to opravdu funguje, jsem si ověřil ostrým testem, ne jen pohledem do konfigurace. Tři soubory zakódované ze stejného zdroje vyšly ve velikostech v přesném poměru:
32k : 64k : 128k = 1 : 2 : 4
Bonus na závěr: Debianní nginx už obsahuje modul pro HTTP/3, takže odpadl plánovaný externí repozitář a s ním jedna závislost navíc.
Failover, který hlídá ticho — ne jen spojení
Nejčastější reálný výpadek v rádiu není „spadl enkodér". Je to „enkodér vysílá, ale teče z něj ticho". Posluchač neslyší nic a samotný Icecast fallback to nepozná — zdroj je pro něj pořád připojený, bajty tečou. Proto na serveru hlídá zvuk Liquidsoap přes blank.strip, který ticho detekuje a zdroj odstaví.
ticho ~10 s → odstavit zdroj
zvuk ~2 s → vrátit zdroj
práh ~-45 dB
Pořadí je hlavní zdroj → záloha → nouzová nahrávka a přepnutí je okamžité, nečeká se na konec skladby. Když utichnou oba enkodéry, naskočí nouzová smyčka s téměř čtyřmi hodinami hudby. Nikdy tak nejde do streamu ticho.
Zvlášť stojí za zmínku, kudy tečou názvy písní. Magic RDS je pošle přes HTTPS na nginx, ten je předá malému Python bridgi a ten je vstříkne jednou, ještě před rozvětvením na výstupy. Díky tomu je dostanou všechny Icecast mounty, HLS i JSON pro web najednou. Návody na fórech radí posílat názvy rovnou do Icecastu — jenže to by byly čtyři samostatné požadavky (na každý mount zvlášť) a HLS ani web by název nedostaly. A chybějící názvy na webu byl přesně ten původní problém, se kterým klient přišel.
Co se pokazilo cestou
Nejcennější část každé zakázky. Vyberu tři momenty, které nejlíp ukazují, že se odpovědi hledají v logu, ne v hlavě.
„Léto v ráji" místo „Léto v ráji"
Na webu naskočila rozbitá diakritika. Příčina je hezky učebnicová: Python BaseHTTPRequestHandler dekóduje řádek požadavku jako latin-1. Ne kvůli nějakému příkazu specifikace a ne proto, že „v URL smí být jen ASCII" — je to implementační rozhodnutí CPythonu, protože latin-1 bezeztrátově a bijektivně mapuje všech 256 možných bajtů na kódové body, takže na žádném vstupu nespadne. Když ale klient pošle v požadavku syrové UTF-8 místo procentového kódování, tenhle bajtově věrný převod z něj udělá mojibake. Řešením byla heuristika undo_latin1() postavená na dvou pojistkách za sebou: text se zkusí zakódovat zpátky do latin-1 a znovu přečíst jako UTF-8. Znaky nad rámec latin-1 (typicky české č, ř, ž, š, ě, ů) shodí už první krok; co jím projde, musí ještě tvořit platné UTF-8, jinak couvne krok druhý. Mojibake projde obojím a opraví se, korektní text většinou zůstane beze změny. Stoprocentní to není — vzácně se dá napálit — a přesně proto to hlídá unit test.
„%1% - %2%" na místě názvu
Chvíli to vypadalo, že Magic RDS nenahrazuje zástupné symboly. Access log nginxu ale ukázal, že název písně tam byl celou dobu — jen pod jiným parametrem (rds). Opravu jsem udělal na serveru, ne u klienta: bridge teď čte i tenhle parametr, přeskakuje nenahrazené zástupné symboly a jako poslední pojistka radši odmítne text, ve kterém zástupný symbol zůstal. Do streamů nikdy nepůjde „%1%", radši nepůjde nic. Klient nemusel měnit vůbec nic.
Názvy o 16 sekund napřed
Názvy písní běžely před zvukem, a to o pořádných 16 sekund — buffer harboru 8 s plus HLS 8 s. Původní měření 8 s bylo neúplné, protože probíhalo bez připojeného živého zdroje. Řešení bylo dvojí: zkrátit buffery na zhruba 10 sekund a názvy na stránce synchronizovat se zvukem — přehrávač si latenci měří za běhu a zobrazení názvu o přesně tolik podrží. Není to pevná konstanta, přizpůsobuje se.
(Do galerie kuriozit patří i to, že Docker kontejner neběží jako root. HLS segmenty se píší do tmpfs, ten byl vlastněný rootem, a kontejner pod neprivilegovaným uid nezapsal jediný segment. Stačilo do fstabu dosadit správné uid.)
Bezpečnost: nejlepší tajemství se nepřenáší
První, co na novém serveru udělám, je SSH — a rovnou to ověřím, ne jen nastavím. Přihlášení jen klíčem, root pouze klíčem, MaxAuthTries 3, fail2ban. Vynucené heslo vrací Permission denied (publickey) a server heslo vůbec nenabízí. Že to není zbytečná paranoia, ukázal log: za prvních 24 hodin ~2 300 neúspěšných pokusů o průnik z 13 IP adres, slovníkové útoky na uživatelská jména.
Vstup z enkodérů je citlivé místo. Icecast source protokol nemá TLS a jednotky ho neumí, takže zdrojové heslo i zvuk jdou po internetu otevřeně. Proto je port pro enkodéry otevřený výhradně pro IP adresy studia — a ověřeno zvenčí, že z jiné adresy je zavřený.
Nejpoučnější lekce byla ale kolem názvů písní. Token pro jejich zápis unikl. Byl v query stringu odkazu, a když se ten odkaz poslal přes chat, servery té služby si ho samy stáhly kvůli náhledu. Totéž udělal náhledový robot Applu. Token jsem okamžitě rotoval a autentizaci přesunul na IP adresu studia — do studia se tak nemusí předávat žádné tajemství.
Nejlepší tajemství je to, které se vůbec nepřenáší. A odkaz s tajemstvím v URL nikdy neposílejte chatem — náhledový robot si ho stáhne za vás.
Dva detaily na závěr, které umí pokazit den:
- Let's Encrypt má limit zhruba pěti neúspěšných validací na hostname za hodinu. Dokud u domény visel mrtvý druhý A záznam, nemělo smysl validaci opakovat naslepo — jen by se vyčerpal limit.
- Cloudflare musí u té subdomény zůstat v režimu „DNS only" (šedý mráček). Streamovat přes proxy na free tarifu stejně nedává smysl: video je zakázané, nepřetržité audio je v šedé zóně fair-use a ICY stream proxy tak jako tak necachuje ani nepodporuje. A port pro enkodéry by proxy neprošel.
Co si z toho odnést
Finální stav je čtyřicípý: čtyři Icecast mounty (128 AAC-LC, 64 HE-AAC v1, 32 HE-AAC v2, 128 MP3), HLS se třemi variantami, web s přehrávačem (volba kvality Auto/32/64/128, kterou si pamatuje, seznam adres a historie názvů synchronizovaná se zvukem), JSON API pro web a healthcheck každých pět minut. Ten hlídá služby, mounty, stáří HLS segmentu i stáří názvu — a oba zdroje sleduje zvlášť od toho, který zrovna hraje. Při plném běhu se sedmi enkodéry to celé žere jen ~11 % CPU a ~220 MB RAM. Všechno měřeno na produkčním stroji, nic odhadnuto.
Pár vět, které si z téhle zakázky odnáším a klidně si je vezměte s sebou:
- Ověřujte v manuálech, ne v předpokladech. Tři řádky v dokumentaci enkodéru přepsaly celý návrh.
- Měřte v reálných podmínkách. Osm sekund latence bez živého zdroje je jiné číslo než šestnáct s ním.
- Čtěte logy místo hádání. Název písně byl v access logu celou dobu, jen pod jiným parametrem.
- Když to jde, opravte to na své straně. Server se naučil číst „rds", klient nemusel měnit nic.
- Rozlišujte „připojeno" a „hraje". Nejtišší výpadek je ten, o kterém Icecast neví.
Rádio OTAVA teď vysílá tak, že když jeden zdroj utichne, druhý naskočí dřív, než to někdo stihne zaregistrovat. A o tom to celé je — weby a aplikace, které fungují, na serverech, které nespadnou.