Stream-Aufnahme und Shazam Songerkennung: Wenn der Proxy mehr kann als geplant
Feature-Emergenz: Der Proxy leitet alle Audio-Daten durch – warum nicht mitschneiden? Und wieso nicht auch das ShazamKit damit versorgen? Aus einem Visualizer-Baustein werden zwei neue Features.
Teil der Serie "Behind the App" — Wie aus einem Visualizer-Baustein zwei neue Features wurden
Ein Versprechen aus dem letzten Post
Im Visualizer-Post habe ich geschrieben: "Sobald der Stream sowieso durch meinen Code läuft, ist Aufnehmen kein neues Feature mehr — es ist ein zweiter Write-Aufruf."
Das war eine schöne Pointe. Sie stimmt nur so halb.
Die ehrliche Version: Der Proxy hat die Aufnahme-Funktion nicht als fertige Abzweigung geliefert. Was er geliefert hat, war das Wissen, wie man auf iOS und Android einen unendlichen HTTP-Audio-Stream mit eigenen Händen durch die App bewegt. Und dieses Wissen hat sich als das eigentliche Kapital herausgestellt — erst für die Aufnahme, dann für Shazam. Dazwischen lag allerdings ein Wochenende, an dem ich gelernt habe, warum "einfach den Stream herunterladen" in React Native nicht funktioniert.
Der naive Plan: Aufnahme ist doch nur ein Download
Die Idee selbst ist so alt wie das Radio daneben: Was früher die Record-Taste am Kassettendeck war, sollte in der App ein Knopf im Player sein — die laufende Sendung für später festhalten. Das Planungsdokument dazu vom 14. November 2025 ist noch im Repo, und es ist rückblickend ein kleines Comedy-Stück. Der Ansatz: Wenn der Nutzer auf "Aufnehmen" tippt, lädt die App den Stream einfach parallel zur Wiedergabe herunter. react-native-blob-util macht den Download, die Datei landet im Dokumentenverzeichnis, fertig. Unter "Vorteile" steht dort wörtlich: "Keine nativen Module nötig" und "Einfache Implementation (1-2 Tage)".
Einen Tag später existierte ein zweites Dokument mit dem Titel "Recording Feature - Final Conclusion". Dazwischen lagen fünf gescheiterte Anläufe.
Fünf Anläufe, null Dateien
Getestet habe ich auf einem echten iPhone gegen einen realen Stream (Radio 88.6 aus Österreich). Der Ablauf war jedes Mal derselbe, und er war jedes Mal absurd: Verbindung steht, Status 200, die Progress-Callbacks feuern fröhlich vor sich hin — "Receiving data: X bytes". Und auf der Platte: nichts. Keine Datei. Auch nach fünf Sekunden Polling nicht. Auch mit der damals neuen Expo-FileSystem-v19-API nicht. Auch mit WritableStream nicht, auch mit manuellem Chunk-Schreiben über ReadableStream nicht — dessen getReader() ist in React Native schlicht nicht vollständig implementiert.
Die Ursache ist im Nachhinein offensichtlich, vorher war sie es nicht: Alle React-Native-Datei-Bibliotheken sind für endliche Downloads gebaut. Sie puffern die ankommenden Daten im Speicher und schreiben erst auf die Platte, wenn der Download "fertig" ist — EOF empfangen oder Content-Length erreicht. Ein Radio-Stream hat aber kein Ende. Kein EOF, keine Content-Length, kein "fertig". Der Puffer wächst, die Datei entsteht nie, und irgendwann crasht die App mit Out-of-Memory.
Progress-Callbacks bedeuten also nicht, dass irgendetwas gespeichert wird. Das ist die Lektion, die ich aus diesem Wochenende mitgenommen habe, und sie steht seitdem als Merksatz in der Doku: "Progress callbacks ≠ File written".
112 Zeilen Kotlin später
Die Lösung war am Ende genau das, was das Planungsdokument unter "verworfen, zu komplex, 2-3 Wochen" einsortiert hatte: ein natives Modul. Nur dass es keine drei Wochen gebraucht hat, sondern einen Abend. Der Commit Add native recording, remove podcast view vom 15. November 2025 enthält das komplette Android-Modul — 112 Zeilen Kotlin. Die iOS-Seite ist ähnlich kompakt: URLSession holt die Bytes, ein OutputStream schreibt sie sofort und ungepuffert in die Datei, stopRecording() bricht den Request ab und schließt die Datei. Kein Re-Encoding, keine Formatmagie — die Aufnahme ist der Original-Stream, Byte für Byte.
Warum ging das so schnell? Weil das exakt der Datenpfad ist, den der Visualizer-Proxy schon konnte. Stream öffnen, Bytes entgegennehmen, sofort weiterschreiben statt puffern — dieselbe Bewegung, nur mit einer Datei statt einer lokalen Socket-Verbindung am anderen Ende. Der Proxy hat der Aufnahme nicht seine Leitung geliehen, sondern seine Blaupause.
Das ist auch die Stelle, an der ich die Pointe aus dem letzten Post korrigieren muss: Die Aufnahme zapft den Proxy nicht an. Sie öffnet eine eigene, zweite Verbindung zum Sender. Das kostet doppelte Bandbreite, und es war trotzdem die richtige Entscheidung — der Proxy lebt am Lebenszyklus der Wiedergabe, eine Aufnahme soll aber auch dann stabil weiterlaufen, wenn im Player gezappt, gepuffert oder pausiert wird. Zwei getrennte Verbindungen bedeuten zwei getrennte Fehlerdomänen.
Dass die beiden sich trotzdem kennen müssen, hat mich im Februar 2026 noch einmal eingeholt: Fix recording for proxied stations. Wenn eine Station nur über den Backend-Proxy erreichbar ist (etwa wegen iOS-inkompatibler TLS-Cipher — die Geschichte gab es im Backend-Post), muss auch der Recorder die validierte Proxy-URL benutzen und nicht die Original-URL, die auf dem Gerät gar nicht funktioniert. Seitdem läuft die Aufnahme-URL durch dieselbe Validierungslogik wie die Player-URL.
Shazam, Akt 1: Das Mikrofon hört nichts
Parallel zur Aufnahme gab es seit Februar 2025 ein zweites Feature auf der Wunschliste: Songerkennung. Auf iOS liefert Apple dafür ShazamKit, und der erste Wurf war schnell gebaut — AVAudioEngine an, Mikrofon auf, die Umgebungsgeräusche an SHSession verfüttern. Auf dem Schreibtisch mit Lautsprecher-Wiedergabe funktionierte das sogar.
Nur war "auf dem Schreibtisch mit Lautsprecher" ziemlich genau der einzige Fall, in dem es funktionierte. Die Mikrofon-Variante hatte eine ganze Kaskade von Problemen, und das gemeinste davon zeigte sich im Auto: Sobald die App die Audio-Session auf playAndRecord umstellte, wechselten Bluetooth-Geräte vom hochwertigen A2DP-Profil in das Telefonie-Profil HFP. Das Autoradio zeigte die App plötzlich als laufenden Anruf an, der Klang fiel auf Mono-Telefonqualität ab. Dazu kam duckOthers, das ausgerechnet den Stream leiser drehte, den man identifizieren wollte. Und die grundsätzliche Absurdität: Wenn die Musik über Bluetooth-Lautsprecher läuft, kann das Gerätemikrofon sie physikalisch gar nicht hören.
Der eigentliche Denkfehler stand am Anfang: Die App will keinen Song aus der Umgebung erkennen. Sie will ihren eigenen Stream erkennen — und dessen Daten hat sie längst in digitaler Form. Ein Mikrofon ist dafür die falsche Abstraktionsebene.
Shazam, Akt 2: Der Stream weiß es selbst
Der Umbau im Februar 2026 (Use URLSession + AVAudioFile to feed audio data to the audio engine) war ein kompletter Rewrite des iOS-Moduls. Vorher gab es noch einen gescheiterten Zwischenschritt, der eine eigene Fußnote verdient: ein stummgeschalteter AVPlayer mit MTAudioProcessingTap, der die dekodierten PCM-Daten abgreifen sollte. Klang elegant, scheiterte an einem Detail mit einer gewissen Ironie — ein Player, der auf lautlos steht, rendert gar kein Audio, also feuern auch die Tap-Callbacks nie.
Die funktionierende Lösung ist vergleichsweise unspektakulär, und in der internen Doku steht der entscheidende Satz: Die Bytes werden "via OutputStream in eine Temp-Datei geschrieben — bewährtes Muster aus dem radio-recorder-Modul". Da ist sie wieder, die Kette: Der Proxy lieferte das Muster für den Recorder, der Recorder lieferte es für Shazam. URLSession lädt den Stream, alle zwei Sekunden wird die Temp-Datei kopiert und mit AVAudioFile geöffnet, das MP3 und AAC von selbst nach PCM dekodiert. Die PCM-Frames gehen in 4096er-Blöcken an SHSession.matchStreamingBuffer(). Wichtig dabei: ShazamKits didNotFind bricht nicht ab — bei inkrementell gefüttertem Audio matchen frühe Teilsignaturen naturgemäß noch nicht. Erst nach 20 Sekunden ohne Treffer gibt es eine saubere "kein Match"-Antwort.
Nebeneffekt des Umbaus: keine Mikrofon-Berechtigung mehr, keine Audio-Session-Eingriffe, kein Bluetooth-Profilwechsel. Die Erkennung funktioniert jetzt gerade dann, wenn die Musik über Bluetooth läuft — vorher der hoffnungsloseste Fall.
Android: Apple liefert ein SDK, den Rest liefert man selbst
Die Überraschung bei der Android-Umsetzung: Es gibt ein offizielles ShazamKit-SDK für Android, direkt von Apple, als AAR-Bibliothek. Die Ernüchterung: Das SDK übernimmt nur das Matching. Alles davor — Stream laden, dekodieren, im richtigen Format anliefern — ist Eigenleistung, und "das richtige Format" ist wörtlich zu nehmen: PCM, 16 Bit, mono, 44100 Hz, sonst kein Match.
Auf iOS erledigt AVAudioFile die Dekodierung in einer Zeile. Auf Android wurde daraus ein eigener StreamDecoder mit MediaExtractor und MediaCodec — und der wuchs innerhalb von drei Februar-Tagen von der ersten Fassung auf das Dreifache, weil die Realität der Radio-Streams nachlieferte. MediaExtractor versteht rohe ADTS/HE-AAC-Streams nicht, also braucht es manuelles Frame-Parsing. HE-AAC hat obendrein eine Spezialität: Der ADTS-Header behauptet 22050 Hz, der Decoder liefert wegen Spectral Band Replication tatsächlich 44100 Hz — wer dem Header glaubt, füttert Shazam mit halbierter Abspielgeschwindigkeit. Und die Anlieferung muss mit rund 12 Millisekunden Abstand pro Audio-Chunk getaktet sein, sonst läuft der interne Ring-Puffer des SDK über und es matcht wieder nichts.
Ein Detail noch, weil es ein hübsches Sicherheits-Pattern ist: Das Android-SDK braucht einen Apple-Developer-Token (ein JWT, maximal 180 Tage gültig). Der steckt nicht in der App, wo ihn jeder Decompiler finden würde, sondern liegt beim Backend und wird zur Laufzeit über einen geschützten Endpoint geholt. Token-Rotation ohne App-Update inklusive.
Ab da war der Rest Produktarbeit: Der Shazam-Treffer bekam ein Result-Modal, eine Spotify-Suche, Apple-Music- und YouTube-Links, einen Like-Button, eine History. Später wanderte der Erkennen-Knopf noch ins CarPlay- und Android-Auto-Interface. Aus "welcher Song läuft gerade?" wurde ein kleines Ökosystem — aber das ist Stoff für einen eigenen Post.

Fazit: Feature-Emergenz, ehrlich betrachtet
Die glatte Version dieser Geschichte wäre: Der Proxy leitet alle Daten durch, also zweigt man sie einmal für die Aufnahme und einmal für Shazam ab. So steht es sinngemäß im Teaser zu diesem Post, und so einfach war es nicht. Aufnahme und Shazam öffnen jeweils eigene Verbindungen, und beide mussten sich ihre eigenen Plattform-Gemeinheiten erst erarbeiten.
Was tatsächlich emergiert ist, war nicht die Leitung, sondern die Kompetenz. Vor dem Proxy war "unendlicher Audio-Stream, byteweise, in eigener Kontrolle" ein Problem, an dem ein ganzes Wochenende mit fünf Bibliotheks-Anläufen scheitern konnte. Nach dem Proxy war es ein bekanntes Muster, das sich in einem Abend auf eine Datei (Recorder) und später auf einen Erkennungs-Puffer (Shazam) übertragen ließ. Zwei Features, die die App heute mitprägen, existieren, weil ein drittes, das inzwischen standardmäßig ausgeschaltet ist, den Weg dafür freigeräumt hat.
Wenn es aus dieser Folge eine übertragbare Lektion gibt, dann die: Der Wert eines mühsamen Features liegt manchmal nicht im Feature, sondern in dem, was man auf dem Weg dorthin lernen musste. Man sieht das den Roadmaps vorher leider nie an.
Im nächsten Post geht es um das älteste Protokoll im ganzen Stack: ICY-Metadaten. Songtitel, die mitten im Audio-Strom stecken, ein Standard aus den späten Neunzigern, und die Frage, warum "einfach den Titel anzeigen" bei Radio-Streams ein Minenfeld ist.
Radio Adfree ist eine werbefreie Radio-App für iOS und Android mit über 40.000 Sendern, Song-Erkennung und Podcasts.