Die Visualizer-Odyssee: Von Fake-Charts zum nativen Audio-Proxy
Fünf Akte: vom geerbten Web-Visualizer über Fake-Daten in Safari und einen iOS-Prototyp bis zum nativen Audio-Proxy, der am Ende immer läuft.
Teil der Serie "Behind the App" — Wie aus einem hübschen Feature ein Architektur-Fundament wurde
Das Spektrum lügt nicht — außer es ist gefälscht
Wer einen Webradio-Player aufmacht, erwartet zwei Dinge: einen Play-Knopf und Balken, die im Takt der Musik wackeln. Letzteres ist ein Visualizer, und auf den ersten Blick ist das ein einfaches UI-Element. Ein paar Balken, eine Audio-Analyse, ein bisschen Reanimated, fertig.
Auf den zweiten Blick ist es das, woran ich vermutlich die meisten Wochenenden eines ganzen Jahres verschenkt habe. Und der Witz an der Geschichte: Am Ende war der Visualizer gar nicht das eigentliche Feature. Das eigentliche Feature war alles, was nebenbei entstanden ist.
Dieser Post erzählt die Geschichte in fünf Akten. Inklusive der Phase, in der ich Fake-Daten in einen Chart geschoben habe, weil Safari sich querstellte.

Akt 1: Das Erbe aus dem Web-Player
Im ersten Post der Serie habe ich erzählt, dass die App aus einem Fork eines React-Webplayers entstanden ist. Dieser Webplayer hatte bereits einen Audio-Visualizer — und der lief im Browser tatsächlich. Web Audio API, AnalyserNode, FFT, fertig. Auf Chrome und Firefox sah das wunderschön aus.
Auf Safari sah es nach gar nichts aus.
Der Grund ist eine Sicherheitsentscheidung, die in der Theorie sinnvoll ist und in der Praxis hier wehtut: Bei Cross-Origin-Streams (und Radio-Streams sind fast immer Cross-Origin) verweigert Safari den Zugriff auf die rohen Audio-Daten über AnalyserNode. Der Stream spielt — aber man kommt nicht an die FFT-Werte. Kein CORS-Header der Welt hilft, weil viele Icecast-Server keinen setzen und das Hinzufügen serverseitig nicht in meiner Hand liegt.
Damit war klar: Auf Safari würde der Visualizer per Browser-API nie funktionieren. Und damit auch nicht in einer WebView. Und damit auch nicht "mal eben" in einer React-Native-App.
Akt 2: Die Fake-Daten-Phase
Der erste Commit, der in der nativen App das Wort visualization enthält, ist vom Januar 2025 und heißt unbescheiden: Implement fake player visualization.
Das ist genau das, wonach es klingt. Eine Charting-Bibliothek, ein bisschen Random und Sinus, ein wenig Glättung — und das Ergebnis sieht erstaunlich überzeugend aus. Wenn man die Musik nicht hört, würde man schwören, das sei echt. Wenn man sie hört, fällt einem schnell auf, dass die Balken auch dann tanzen, wenn gerade gar kein Song läuft. Oder wenn ein Sender komplett still ist.
Ich habe das in der ersten Web-Version sogar online gehabt. Die Commit-Message dazu — Try custom visualizer with fake data in safari — fasst die Selbstironie ganz gut zusammen.
Warum so weit gehen, dass man wirklich Fake-Daten anzeigt? Weil ein leerer Bereich auf einem Player-Bildschirm wie ein Bug aussieht. Weil Visualizer "Musik" signalisieren, auch wenn sie inhaltlich nichts beitragen. Und weil ich gehofft habe, dass mir bis zum Native-Build schon noch eine Idee kommen würde.
Was sich nicht bewahrheitet hat. Aber zumindest die Optik.
Akt 3: Die Playback-Engine ist eine Blackbox — also bauen wir einen Proxy
Sobald aus dem Webplayer eine native App wurde, stand die Audio-Engine fest: eine fertige Playback-Bibliothek, wie sie jede ernsthafte Audio-App nutzt. Hintergrund-Audio, Lock-Screen-Steuerung, CarPlay, Android Auto — alles, was man braucht, liegt da drin. Aber so eine Bibliothek gibt einem keine PCM-Daten zurück. Sie spielt den Stream ab. Punkt. Es gibt kein Event "hier sind die Samples der letzten 50ms".
Es gibt drei plausible Wege aus dieser Sackgasse:
- Die Bibliothek forken und den Audio-Pfad anzapfen. Anstrengend, riskant, und ich wäre für immer auf meinen eigenen Fork festgenagelt.
- Eigenen Player nebenher betreiben, der nur analysiert. Doppelter Download, doppelter Decode, doppelte Latenz — und Audio-Stutter beim Umschalten zwischen Senderwechsel und Visualizer-Start.
- Den Stream selbst durchschleifen. Der Audio-Strom geht nicht direkt vom Sender zum Player, sondern macht einen Umweg über meinen eigenen, app-internen HTTP-Server. Auf dem Weg lese ich mit.
Variante 3 klang nach Overengineering, bis ich angefangen habe, sie aufzuschreiben. Da fiel auf: Wir reden hier nicht über einen Web-Service, der irgendwo deployed wird. Wir reden über einen lokalen TCP-Server auf 127.0.0.1, an einem zufälligen Port, der genau eine Verbindung bedienen muss: die App selbst, die ihren eigenen Stream konsumiert.
Auf iOS war das ein überschaubares Stück Swift. NWListener lauscht auf einem lokalen Port, URLSession holt den Upstream-Stream vom Icecast-Server, die Daten werden in eine temporäre Datei für die Analyse geschrieben und an die App-eigene Verbindung weitergegeben. Nebenbei strippe ich ICY-Metadaten aus dem Stream heraus, damit AVPlayer den Bytestrom nicht mit Songtitel-Text durchsetzt sieht.
Der iOS-MVP hat funktioniert. Und zwar so gut, dass ich für einen Abend dachte: Das war's. Feature shipped.
Akt 4: Android ist ... anders
Android lief im Prinzip genauso wie iOS. Lokaler HTTP-Server, Stream durchschleifen, mitlesen — strukturell identisch. Andere Sprache, andere Bibliotheken, gleiche Idee.
Praktisch eine komplett andere Show.
Auf schwächeren Geräten ging dem Build erstmal der Speicher aus. Bei manchen Sendern produzierte der Decoder ein nervtötendes Knacken. Andere Sender klangen nach ein paar Minuten plötzlich wie unter Wasser. Ein paar Wochen lang fühlte sich Android-Entwicklung wie ein Karneval kleiner Audio-Defekte an — jede Woche eine neue Sorte Knistern, immer auf einem anderen Gerät.
Was am Ende geholfen hat, war weniger Magie als Geduld: gnadenlos profilen, kleine Puffer statt großer, dem Audio-Thread Vorrang vor allem anderen geben, und für jedes Stream-Format den passenden Decoder-Pfad aufmachen statt einen einzigen "der wird's schon irgendwie schaffen"-Code zu erzwingen. Besonders Opus-Streams (z. B. radioparadise.com) waren ein eigenes Kapitel: ohne dedizierte Behandlung blieb der Visualizer da kommentarlos schwarz, bis irgendwann ein eigener Decoder-Pfad nachgezogen wurde.
Dann noch die langweilige, aber wichtige Erkenntnis: 60 Bilder pro Sekunde sind für tanzende Balken Verschwendung. Bei knapp unter 30 sieht das Auge keinen Unterschied — die CPU schon. Der Commit dazu trägt den nüchternen Titel both now 28fps instead of 60.
Bis hierhin war der Visualizer ein Feature, das auf iOS solide lief, auf Android in mehreren Iterationen gerade gezogen werden musste — und das ich am Ende eines fast einjährigen Prozesses standardmäßig deaktiviert habe. Disable visualizer by default, im Februar 2026. Weil die meisten Nutzer in der App Radio hören wollen, nicht Mathematik in Echtzeit visualisieren — und weil es am Ende des Tages immer noch ein bisschen Akkuleistung kostet. Der Visualizer ist heute ein Opt-in-Setting unter den Player-Optionen.
Klingt nach einer mageren Bilanz für ein Jahr Arbeit. Bis Akt 5.
Akt 5: Der Plot-Twist — der Proxy läuft immer
Während der Visualizer in einem Default-Off-Setting verschwand, passierte mit dem Proxy etwas Interessantes: Er wurde nützlich. Nicht nur für die FFT.
ICY-Metadaten. Icecast-Streams schicken Songtitel als kleine Text-Blöcke mitten im Audio-Stream. Wenn man die nicht herausstrippt, hört man bei manchen Streams einen kurzen Knack alle paar Sekunden (HE-AAC ist da besonders empfindlich). Wenn man sie herausstrippt und mitliest, hat man kostenlos eine Live-Now-Playing-Info. Beides macht der Proxy.
HE-AAC-Frame-Stabilität. Selbst ohne Visualizer war das saubere Frame-Alignment, das der Proxy nebenbei erzwingt, ein deutlicher Stabilitätsgewinn bei bestimmten Sendern. Die "Unterwasser-Sound"-Effekte verschwanden komplett.
Recording-Tap. Sobald der Stream sowieso durch meinen Code läuft, ist Aufnehmen kein neues Feature mehr — es ist ein zweiter Write-Aufruf. Mehr dazu im nächsten Post.
Shazam-Andocken. Auch ShazamKit braucht PCM-Daten. Und der Proxy hat sie. Auch dazu mehr im nächsten Post.
Irgendwann im Mai 2026 stand der Commit Always use proxy, independent of visualizer auf der Liste. Der Visualizer-Schalter steuert den Visualizer. Der Proxy läuft trotzdem. Ein paar Zeilen Code im Helper, die die Logik umstellen — aber konzeptuell ein Kippmoment:
if (Platform.OS !== 'web' && mediaType === 'radio' && !isHls) {
const proxyUrl = await ExpoAudioVisualizer.startProxy(validation.finalUrl);
if (proxyUrl) trackUrl = proxyUrl;
}
Aus dem "Visualizer-Helper" wurde der zentrale Audio-Pfad. Aus einem Feature wurde Infrastruktur.
Fazit: Wenn die Architektur sich selbst findet
Ich habe im Post über das Backend geschrieben, dass eine App immer ein System ist. Hier ist die Mikrovariante davon: Eine Architektur-Entscheidung muss nicht von oben kommen. Sie kann auch dadurch entstehen, dass man ein konkretes Problem so gut löst, dass die Lösung größer wird als das ursprüngliche Problem.
Der Plan war: "Ich brauche FFT-Daten für einen Visualizer." Das Ergebnis ist: ein lokaler HTTP-Proxy, der den Audio-Pfad jeder Radio-Wiedergabe in der App formt, ICY-Metadaten extrahiert, HE-AAC-Frames stabilisiert und nebenbei Aufnahme und Song-Erkennung möglich macht. Der Visualizer selbst — das, wofür alles begann — ist heute ein Opt-in-Toggle, das viele Nutzer gar nicht einschalten werden.
Das ist keine besonders elegante Story. Ich kann mir gut vorstellen, dass ein erfahrener Audio-Engineer von Anfang an gesagt hätte: "Bau einen Proxy, alles andere ergibt sich." Aber so läuft das selten. Man fängt mit dem an, was sichtbar ist, und entdeckt das Fundament erst, wenn man tief genug gegraben hat.
Im nächsten Post nehme ich mir die direkteste Konsequenz aus dieser Architektur vor: Stream-Aufnahme. Wenn der Audio-Pfad sowieso durch die App läuft — warum nicht mitschneiden? Und wenn man schon mitschneidet — warum nicht ShazamKit andocken? Aus einem Visualizer-Baustein werden zwei neue Features.
Radio Adfree ist eine werbefreie Radio-App für iOS und Android mit über 40.000 Sendern, Song-Erkennung und Podcasts.