Skip to content

Latest commit

ย 

History

81 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

RadioSabbelNich

RadioSabbelNich

๐Ÿ‡ฌ๐Ÿ‡ง English version further below

Namensรคnderung: von RadioZapper รผber KeinSabbelRadio zu --> RadioSabbelNich

Zweiter Anlauf: KeinSabbelRadio war als Name auch nicht der groรŸe Wurf, deswegen heisst das Ding jetzt RadioSabbelNich. Diesmal soll es dabei bleiben.

RadioSabbelNich hรถrt mehrere Internetradio-Sender gleichzeitig fรผr dich mit und schaltet automatisch weiter, sobald irgendwo geredet wird. Moderation, Nachrichten, Werbung, Jingles. รœbrig bleibt (mรถglichst) nur Musik. Der ausgewรคhlte Sender wird per Icecast neu ausgestrahlt, sodass man ihn im ganzen (Tail-)Netz mit VLC, im Browser oder sonst einem Streaming-Client hรถren kann.

โš ๏ธ Nur privat, nur hinter VPN โ€” kein รถffentlicher Betrieb

RadioSabbelNich ist ausdrรผcklich nicht fรผr den รถffentlichen Betrieb gedacht. Icecast-Port (8000) und Web-Interface-Port (5000) gehรถren niemals direkt ins offene Internet (kein Port-Forwarding, kein รถffentlicher Reverse-Proxy) โ€” RadioSabbelNich lรคuft immer hinter einem VPN (Tailscale o.รค.), erreichbar nur fรผr Gerรคte im eigenen vertrauten Netz. Zwei konkrete Grรผnde:

  • Ressourcen: Ein offen erreichbarer Icecast-Mountpoint wird frรผher oder spรคter gefunden (Scanner, Streaming-Aggregatoren, Hotlinking) โ€” und dann zieht potenziell das halbe Internet unkontrolliert Bandbreite und Rechenzeit, ohne dass man das je wieder eingefangen bekommt.
  • Urheberrecht: RadioSabbelNich streamt fremde, lizenzierte Radioprogramme neu aus. Fรผr den privaten Eigenbedarf im eigenen (Tail-)Netz ist das eine Sache โ€” รถffentlich zugรคnglich gemacht, ist es eine unlizenzierte รถffentliche Wiedergabe urheberrechtlich geschรผtzter Inhalte. Es gibt reichlich Kanzleien, fรผr die genau das ein Geschรคftsmodell ist.

Web-Interface und Config-Seite haben zudem keinerlei Authentifizierung (siehe unten) โ€” ein weiterer Grund, warum "kurz mal รถffentlich erreichbar machen" keine gute Idee ist.

Wie die Erkennung funktioniert

  1. Silero VAD (neuronales Netz, spezialisiert auf Sprache-Erkennung) klassifiziert laufend ~1-Sekunden-Fenster des aktuellen Senders als Sprache oder Musik. Fรคllt VAD mal nicht (z.B. aus Umgebungsgrรผnden), springt automatisch eine einfachere Signal-Heuristik ein (Zero-Crossing-Rate/Spektrale Flachheit/Energie-Modulation).
  2. Hรคlt die Sprache-Erkennung einige Sekunden am Stรผck durch, schaltet RadioSabbelNich reihum zum nรคchsten aktivierten Sender, bis wieder Musik lรคuft.
  3. Parallel dazu lรคuft ein Audio-Fingerprinting (Shazam-artiges Constellation-Map-Verfahren): erkannte Sprache-Clips werden gehasht und mit einer SQLite-Datenbank bereits gehรถrter Clips verglichen. Ist ein Clip schon bekannt (z.B. ein wiederkehrender Werbespot oder Sender-Jingle), wird sofort umgeschaltet, ohne erst die volle Sprache-Erkennungszeit abzuwarten.

Beide Mechanismen sind nicht perfekt โ€” dafรผr gibt's im Web-Interface Korrektur-Knรถpfe (siehe unten).

Umgang mit toten Sendern (Watchdog)

Nicht jede Sender-URL bleibt dauerhaft abspielbar โ€” importierte Listen enthalten Karteileichen, und auch ein funktionierender Sender kann mal minutenlang nichts liefern. Damit das nicht die ganze Wiedergabe anhรคlt:

  • Liefert der aktuelle Sender drei Analysefenster in Folge gar nichts, fliegt er fรผr 5 Minuten aus der Rotation und RadioSabbelNich schaltet automatisch weiter (STREAM_FAILURE_LIMIT/STATION_DEAD_COOLDOWN in radiosabbelnich.py).
  • Stirbt ein Hintergrund-Puffer, wandert der Sender sofort auf dieselbe Sperrliste, statt im Sekundentakt neu verbunden zu werden.
  • Gesperrte Sender werden beim automatischen Weiterschalten รผbersprungen und nicht gepuffert. Nach Ablauf der 5 Minuten bekommen sie automatisch wieder eine Chance โ€” ein manueller Klick im Web-Interface hebt die Sperre sofort auf.

Ohne diesen Watchdog konnte ein einziger toter Sender den kompletten Player anhalten: real passiert mit einer importierten DASH-URL, die ffprobe beim Import korrekt als "hat Audio" durchwinkte, die ffmpeg aber nicht dauerhaft abspielen kann โ€” 3569 Reconnect-Versuche รผber 8,5 Stunden, Icecast-Mount die ganze Zeit weg.

Vorausschauendes Puffern & Playout-Delay

RadioSabbelNich hรคlt die nรคchsten Sender in Rotationsreihenfolge im Hintergrund bereits am Laufen und puffert von jedem die letzten prebuffer_seconds Sekunden vor (Default 10s, unter /config einstellbar, wirkt sofort ohne Neustart). Das dient zwei Zwecken gleichzeitig:

  1. Nahtloser Wechsel: ein Wechsel zu einem vorgepufferten Sender (automatisch oder manuell) รผbernimmt die schon laufende Quelle sofort, statt neu zu verbinden โ€” kein Reconnect-Ruckler.
  2. Hรถrer-Delay fรผr die Sprache-Erkennung: derselbe Puffer verzรถgert auch die Ausstrahlung des GERADE laufenden Senders um exakt prebuffer_seconds. Die Sprache-Erkennung (VAD/Heuristik/STT/ Fingerprint) lรคuft dabei auf frisch eingetroffenem Audio, das der Hรถrer erst nach dieser Verzรถgerung bekommt โ€” Moderation/Werbung kann dadurch VOR der Hรถrer-Ausgabe erkannt und weggeschaltet werden, statt erst danach. Kostet zusรคtzliche Bandbreite/CPU (ein zusรคtzlicher ffmpeg-Prozess pro gepuffertem Sender, parallel zum aktuellen; Default 5 Sender ร— 10s ist auf haushaltsรผblicher Hardware unkritisch).

Ein Wechsel รผbernimmt dabei die komplette Fenster-Reihe des Ziel-Puffers auf einen Schlag โ€” die Ausgabe lรคuft danach im selben Sekundentakt weiter wie vorher, keine Lรผcke, kein doppelt gesendetes Audio, keine kumulative Drift gegenรผber der echten Zeit (verifiziert: das Delay ist konstant, es wรคchst nicht mit jedem Zap).

Einschrรคnkung: Trifft ein Wechsel einen Sender, der gerade NICHT vorgepuffert ist (z.B. manueller Klick auรŸerhalb der nรคchsten prebuffer_count Sender in der Rotation, oder ein Notfall-Wechsel, weil alle Puffer-Kandidaten selbst tot sind), lรคuft dieser Sender ohne Delay weiter โ€” sofortige Reaktion auf den Klick, aber ohne den Vorausschau-Vorteil, bis der nรคchste Wechsel wieder einen vorgepufferten Sender trifft. Ein lรผckenloser รœbergang von 0 auf volle Verzรถgerung ist ohne Zeitdehnung/Pitch-Manipulation nicht mรถglich, deshalb bewusst nicht versucht.

Nachrichten-Pause verschiebt sich entsprechend: lรคuft der aktuelle Sender gerade mit vollem Delay, kommt die Nachrichten-MP3 bis zu prebuffer_seconds spรคter beim Hรถrer an als die tatsรคchliche :00/:30 โ€” die Fensterlรคnge selbst (window_minutes) bleibt davon unberรผhrt.

Nachrichten-Pause

Zur vollen und halben Stunde verlesen praktisch alle Radiosender Nachrichten. Statt dessen kann RadioSabbelNich fรผr ein kurzes Zeitfenster eine zufรคllige MP3 aus einem lokalen Ordner abspielen (z.B. eigene Jingles/Musikstรผcke von einem SMB-Mount) โ€” danach geht's automatisch mit dem pausierten Sender weiter, ganz normal.

Konfiguriert wird das รผber den news_break-Block in settings.json, einstellbar รผber die Formular-Sektion "๐Ÿ“ฐ Nachrichten-Pause" oberhalb der Senderliste auf der Config-Seite (/config) oder direkt per API:

"news_break": {
  "enabled": false,
  "mp3_folder": "/app/news_mp3",
  "window_minutes": 2.0,
  "enabled_hours": null
}
  • enabled โ€” Feature an/aus.
  • mp3_folder โ€” Container-interner Pfad (nicht der Host-Pfad!), auf der Config-Seite รผber eine Breadcrumb-Ordnerauswahl gesetzt (durch die Unterordner von /app/news_mp3 klicken statt den Pfad einzutippen). Der eigentliche Host-Ordner wird รผber NEWS_MP3_FOLDER in .env von auรŸen reingemountet (siehe docker-compose.yml), typischerweise ein SMB-Mount โ€” dafรผr braucht es einen Container-Neustart, kein Feld auf der Config-Seite. Die Auswahl durchsucht seit 2026-08-14 auch Unterordner, bis zu 5 Ebenen tief. Ordner fehlt/ist leer/enthรคlt (auch in den Unterordnern) keine MP3s/nicht lesbar โ†’ Feature wird fรผr dieses Zeitfenster einfach รผbersprungen, mit Logeintrag, kein Fehler. Unter dem Feld zeigt die Config-Seite zur Orientierung read-only den echten Host-Pfad an (aus NEWS_MP3_FOLDER durchgereicht) โ€” der Container kennt ihn sonst grundsรคtzlich nicht, Docker รผbersetzt Hostโ†’Container-Pfad nur einmalig beim Start.
  • window_minutes โ€” wie viele Minuten vor/nach :00 und :30 aktiv.
  • enabled_hours โ€” optional [start, end], z.B. [6, 22] fรผr "nur 6โ€“22 Uhr"; null = rund um die Uhr. Kein รœbernacht-Wraparound (22โ€“6 wird nicht unterstรผtzt).

Alternativ direkt per API setzen (z.B. fรผr Skripte):

curl -X POST http://<host>:5000/api/config/settings \
     -H 'Content-Type: application/json' \
     -d '{"news_break_enabled": true, "news_break_window_minutes": 2}'

Ein Zeitfenster wird hรถchstens einmal betreten โ€” lรคuft eine MP3 kรผrzer als das restliche Fenster, wird automatisch eine weitere zufรคllige MP3 nachgeladen (kein Repeat direkt hintereinander, sofern der Ordner mehr als eine Datei enthรคlt), bis window_minutes abgelaufen ist. Die gerade laufende MP3 wird dabei immer bis zu ihrem Ende gespielt, auch wenn window_minutes wรคhrenddessen ablรคuft โ€” die Pause dauert dadurch im Zweifel etwas lรคnger als eingestellt, statt eine MP3 mittendrin abzuwรผrgen. Erst danach geht's automatisch zurรผck zum pausierten Sender. Ein manueller Sender-Wechsel wรคhrend der Pause bricht sie sofort ab (eigene Entscheidung schlรคgt Automatik, wie รผberall sonst in RadioSabbelNich auch). Wรคhrend der Pause pausiert auch die automatische Sprache-Erkennung (VAD/Heuristik/ Fingerprint) โ€” die MP3 selbst enthรคlt u.U. Sprache, das soll nicht als "Moderation" auf dem eigentlichen Sender fehlgedeutet werden. Auf der Radio-Startseite zeigt eine Tag-Anzeige (seit 2026-08-15, per mutagen, format-รผbergreifend) wรคhrenddessen Titel/Interpret/Album/Jahr der laufenden MP3 statt nur des Dateinamens โ€” Details siehe "Player-Modus" unten (dieselbe Anzeige, gleiches Fallback-Verhalten).

Player-Modus (Grundgerรผst)

Erster Umsetzungsschritt der unten unter "Zukรผnftige Features" beschriebenen Musik-Library-Idee: ein eigenstรคndiger, persistierter Modus neben dem normalen Radio-Betrieb โ€” oben auf der Radio- UND der Player-Seite per gut sichtbarem Umschalter ("๐Ÿ“ป Radio" / "๐ŸŽต Player") wechselbar (die Funktion hieรŸ bis 2026-08-13 "Musiksammlung" โ€” intern, in settings.json/Code, heiรŸt der Modus weiterhin music, nur die Bezeichnung im Web-Interface wurde vereinfacht). Im Player-Modus ist die komplette automatische Erkennung (VAD/Heuristik/STT/Fingerprint) aus, nicht nur pausiert โ€” es lรคuft ausschlieรŸlich lokale Musik, nichts wird analysiert. Der Modus รผbersteht einen Container-Neustart (in settings.json gespeichert) โ€” seit 2026-08-15 startet dabei auรŸerdem automatisch die Wiedergabe (erster Track des konfigurierten Ordners), sowohl nach einem Neustart mit bereits gespeichertem Player-Modus als auch bei einem manuellen Wechsel Radioโ†’Player. Vorher blieb die Wiedergabe in beiden Fรคllen inaktiv, bis manuell auf โ–ถ getippt wurde โ€” der Modus selbst war zwar korrekt gemerkt, aber es kam kein Ton, bis jemand aktiv Play drรผckte.

Auf der eigenstรคndigen Seite /musik ("๐ŸŽต Player"):

  • Der ausgewรคhlte Musik-Ordner wird angezeigt โ€” seit 2026-08-13 der echte Host-Pfad (serverseitig aus dem Container-Pfad zurรผckรผbersetzt, per MUSIC_LIBRARY_FOLDER aus .env), vorher stand dort der technisch korrekte, aber fรผr den Nutzer bedeutungslose Container-Pfad (/app/music_library/...). Ein Knopf "Pfad รคndern" fรผhrt zur eigentlichen Ordnerauswahl auf der Config-Seite (siehe unten).
  • Zwei Gruppen von Buttons, "Kategorien" (schnell/langsam/rock/klassik) und "Favoriten" (Queen/Pavarotti) โ€” aktuell reine Platzhalter ohne Funktion, echte Filterung (Kategorien auf Metadaten/Tags wie BPM/Genre, Favoriten auf den Kรผnstler-Tag) kommt erst mit dem Musik-Scan (Phase 1 der Roadmap unten).
  • Ein groรŸer Play/Stop-Button und Zurรผck/Nรคchster โ€” spielt die Musikdateien im konfigurierten Ordner samt Unterordnern (seit 2026-08-14, bis zu 5 Ebenen tief), alphabetisch, endlos im Kreis, bis Stop gedrรผckt wird. Seit 2026-08-13 ist dieser eine Button auch der einzige sichtbare Knopf fรผrs tatsรคchliche Zuhรถren: ein unsichtbares <audio>-Element (ohne eigene Bedienleiste) folgt automatisch dem Wiedergabestatus. Vorher gab es zusรคtzlich einen nativen Browser-Player mit eigenem Play-Knopf, der unabhรคngig vom groรŸen Button reagierte โ€” zwei "Play"-Knรถpfe, die sich gegenseitig nicht kannten und sich dadurch in die Quere kamen.
  • Kein Banner-Bild mehr auf dieser Seite (seit 2026-08-13, aufgerรคumtere eigenstรคndige Optik statt der Radio-Seiten-Elemente).
  • Tag-Anzeige (seit 2026-08-15): unter dem Dateinamen/Fortschritt ("Track (i/total)") zeigt eine zweite/dritte Zeile die per mutagen ausgelesenen Metadaten โ€” "Interpret โ€“ Titel" und "Album (Jahr)", format-รผbergreifend (MP3, FLAC, OGG, M4A/AAC, WAV, APE). Kein Titel-Tag vorhanden โ†’ Dateiname als Fallback; fehlt Album/Jahr, entfรคllt die zweite Zeile komplett statt Platzhaltern wie "Album: โ€“". Dieselbe Anzeige lรคuft auf der Radio-Startseite mit, sobald eine Nachrichten-Pause-MP3 lรคuft (siehe "Nachrichten-Pause" oben).

Der Musik-Ordner wird โ€” wie der News-Break-MP3-Ordner โ€” auf der Config-Seite gesetzt, per Breadcrumb-Ordnerauswahl: durch die Unterordner des รผber MUSIC_LIBRARY_FOLDER (.env, gleiches Muster wie NEWS_MP3_FOLDER) gemounteten Verzeichnisses klicken, statt einen Pfad einzutippen. Beide Felder (News-Break-Ordner, Player-Root) nutzen dieselbe Komponente, speichern aber unabhรคngig voneinander.

STT-Sprachfilter

Silero VAD/die Signal-Heuristik erkennen "ist hier eine menschliche Stimme" โ€” auch gesungene Musik zรคhlt da oft fรคlschlich mit. Der STT-Sprachfilter (stt_filter.py) hรถrt stattdessen per Speech-to-Text mit, ob gerade zusammenhรคngender Text in der erwarteten Sprache zu erkennen ist, und liefert das als zusรคtzliches Signal fรผr die Switch-Entscheidung.

Zwei austauschbare Engines, nie gleichzeitig geladen:

  • Vosk โ€” kleines Kaldi-Modell, leichtgewichtig und auch auf einem Raspberry Pi gut nutzbar. Braucht ein eigenes Modell pro Sprache.
  • Whisper (รผber faster-whisper) โ€” genauer, aber deutlich ressourcenhungriger, selbst als "tiny"-Modell. Ein einziges geladenes Modell deckt beliebig viele Sprachen ab (der Sprachcode wird nur pro Analyse mitgegeben) โ€” bei Whisper kostet eine zusรคtzliche Sprache also kein zusรคtzliches RAM.

Mehrsprachigkeit: Sprache pro Sender-Kategorie

Welche Sprache fรผr einen Sender geprรผft wird, richtet sich nach seiner Kategorie (Lokal/Regional/National/International/โ€ฆ, siehe "Web-Interface" oben) โ€” nicht nach dem einzelnen Sender. Auf der Config-Seite gibt es dafรผr zwei neue Abschnitte unterhalb von "๐Ÿ—ฃ STT-Sprachfilter":

  • ๐ŸŒ STT-Sprachen โ€” legt an, welche Sprachen รผberhaupt zur Verfรผgung stehen: Sprachcode (Freitext, z.B. en, fr โ€” keine feste Liste, da Vosk-Modelle ohnehin selbst besorgt werden mรผssen), bei Engine "Vosk" ein Modellpfad, plus eine (empirisch zu ermittelnde, siehe unten) Konfidenz-Schwelle. Ein bereits vorhandener Sprachcode wird beim erneuten Eintragen aktualisiert statt doppelt angelegt. Jede Zeile zeigt zusรคtzlich den Ladezustand (โœ… geladen / โš  Fehlermeldung / noch nicht geladen) โ€” bei Vosk wird jedes Sprachmodell erst lazy beim ersten tatsรคchlichen Sample geladen, nicht schon beim Speichern.
  • ๐Ÿท Kategorie-Sprachen โ€” ordnet jeder der festen Kategorien eine der oben angelegten Sprachen zu. Kategorien ohne Auswahl gelten als Deutsch (de).

Bei Vosk sind aus RAM-Grรผnden (siehe schwache Hardware/Pi) nie mehr als 2 Sprachmodelle gleichzeitig geladen โ€” bei mehr konfigurierten Sprachen wird das am lรคngsten ungenutzte automatisch verdrรคngt (LRU) und beim nรคchsten Bedarf neu geladen. Wechselt ein Sender die erwartete Sprache (z.B. durch einen Kategoriewechsel), wird ein noch nicht abgelaufener STT-Befund der VORHERIGEN Sprache verworfen statt fรคlschlich weiterverwendet.

Zusรคtzliche Vosk-Modelle mounten: der mitgelieferte VOSK_MODEL_FOLDER-Mount in docker-compose.yml deckt genau EIN Modell ab (Default: Deutsch, /app/vosk-model-de). Fรผr eine weitere Sprache selbst eine zusรคtzliche Zeile in docker-compose.yml ergรคnzen, z.B.:

      - ${VOSK_MODEL_FOLDER_EN:-./data/vosk-model-en}:/app/vosk-model-en:ro

und den resultierenden Container-Pfad (/app/vosk-model-en) als Modellpfad bei "๐ŸŒ STT-Sprachen" eintragen โ€” danach docker compose up -d --build radiosabbelnich, damit der neue Mount aktiv wird.

Kalibrierungs-Wizard

Statt confidence_threshold blind zu raten, gibt es auf der Config-Seite unterhalb von "๐Ÿท Kategorie-Sprachen" den Abschnitt "๐Ÿงช Schwellwert-Kalibrierung" โ€” er reproduziert dieselbe Methode, mit der ursprรผnglich der Deutsch-Default (0.75) hergeleitet wurde (siehe oben), nur gefรผhrt statt manuell aus den Logs abgelesen:

  1. Sprachcode eintragen (bei Vosk muss die Sprache vorher mit Modellpfad unter "๐ŸŒ STT-Sprachen" angelegt sein, bei Whisper nicht nรถtig) und "๐Ÿงช Kalibrierung starten" klicken. Voraussetzung: STT-Filter und Sabbelfilter sind aktiv (sonst sampelt STT gar nicht, siehe oben).
  2. Manuell auf der Player-Seite einen Sender mit garantiert echtem Sprachtext dieser Sprache anschalten (z.B. eine Nachrichtenwelle) und ein paar Minuten laufen lassen โ€” die Wizard-Seite zeigt die Sample-Zahl sowie Konfidenz-Minimum/Maximum/Mittelwert live (Poll alle 2s).
  3. Auf "๐ŸŽต Musik-Stufe" umschalten und manuell auf einen Musiksender derselben Sprache wechseln, erneut ein paar Minuten sammeln lassen.
  4. Sobald beide Stufen Samples haben, erscheint ein Vorschlag (Grenze zwischen dem hรถchsten gemessenen Musik-Wert und dem niedrigsten gemessenen Sprache-Wert, mit Sicherheitsmarge Richtung Sprache-Seite) โ€” "รœbernehmen" speichert ihn direkt als confidence_threshold der Sprache. Trennen sich Sprache und Musik im gemessenen Sample NICHT sauber (รœberlappung), zeigt der Vorschlag eine Warnung statt ihn unkommentiert zu รผbernehmen โ€” dann helfen meist mehr Samples oder ein anderer Test-Sender.

Samples, bei denen STT gar keinen Text erkannt hat (Pause/Jingle/ Werbeblock wรคhrend der Sprache-Stufe, reine Instrumentalpassage wรคhrend der Musik-Stufe), zรคhlen NICHT in die Statistik โ€” leerer Text bedeutet "kein Urteil gebildet", nicht "mit niedriger Konfidenz erkannt". Wichtig bei der Senderwahl fรผr die Musik-Stufe: viele kommerzielle Radiosender haben erheblichen gesprochenen Anteil (Werbung, Moderation zwischen Songs) โ€” das kann trotzdem zu einer unsauberen Trennung fรผhren, auch ganz ohne Erkennungsfehler. Ein Sender mit mรถglichst wenig Wortanteil liefert bessere Ergebnisse.

Wichtig: Die Kalibrierung schaltet selbst NICHTS um โ€” welcher Sender gerade lรคuft, entscheidet ausschlieรŸlich die Player-Seite. Wรคhrend einer laufenden Kalibrierung ist auรŸerdem die automatische Sender-Umschaltung komplett pausiert (nicht nur fรผr die Kalibrierungs-Sprache), damit ein durch die erzwungene Test-Sprache verfรคlschtes STT-Ergebnis nicht mitten in der Kalibrierung einen Wechsel auslรถst โ€” der laufende Sender bleibt also stehen, bis die Kalibrierung beendet wird.

Konfiguration im Detail

Konfiguriert wird das รผber den stt_filter-Block in settings.json (Sprachen selbst รผber set_stt_language()/delete_stt_language() verwaltet, nicht direkt im Block editieren):

"stt_filter": {
  "enabled": false,
  "engine": "vosk",
  "whisper_model_size": "tiny",
  "sample_interval_seconds": 8.0,
  "combine_mode": "and",
  "languages": {
    "de": {"vosk_model_path": "/app/vosk-model-de", "confidence_threshold": 0.75}
  },
  "category_languages": {}
}
  • enabled โ€” Feature an/aus.
  • engine โ€” "vosk" oder "whisper", gilt GLOBAL fรผr alle konfigurierten Sprachen gleichzeitig (siehe oben, warum nie beide gemischt werden).
  • whisper_model_size โ€” z.B. "tiny", "base" (siehe faster-whisper-Dokumentation fรผr weitere GrรถรŸen), ebenfalls global. Modelle werden beim ersten Gebrauch automatisch von HuggingFace geladen und in einem dauerhaften Volume zwischengespeichert (kein manueller Download nรถtig, braucht aber beim ersten Aktivieren Internetzugriff und etwas Zeit).
  • sample_interval_seconds โ€” wie oft ein kurzer Clip (ca. 3s) zur Analyse genommen wird. Lรคuft kontinuierlich im Hintergrund, unabhรคngig vom aktuellen VAD-Ergebnis (blockiert den Hauptloop nie).
  • languages.<code>.vosk_model_path โ€” Container-interner Pfad (nicht der Host-Pfad!) zu einem entpackten Vosk-Modell dieser Sprache, siehe oben. Fรผr Deutsch gibt es passende Modelle unter alphacephei.com/vosk/models โ€” vosk-model-small-de-0.15 (~45 MB) fรผr schwache Hardware/Pi, vosk-model-de-0.21 (~1 GB) fรผr mehr Genauigkeit; fรผr andere Sprachen auf derselben Seite nach dem passenden Modell suchen.
  • languages.<code>.confidence_threshold โ€” ab welcher (Best-Effort-)Konfidenz ein Sample als "zusammenhรคngender Text in dieser Sprache" gilt. Der de-Default (0.75) ist empirisch gemessen, nicht geraten: 10 Live-Clips von Deutschlandfunk (Sprache) lagen nie unter 0.83 Konfidenz, 30 Live-Clips von drei Schlager-Sendern (gesungene deutsche Musik) im Schnitt bei 0.38 โ€” 0.75 liegt mit Sicherheitsabstand unter dem Sprache-Minimum. Fรผr jede weitere Sprache gilt dieselbe Methode: ein paar Minuten gegen einen bekannten Sprache- UND einen bekannten Musik-Sender dieser Sprache mithรถren, erkannte Texte/Konfidenzwerte landen dafรผr in logs/radiosabbelnich.log.
  • category_languages โ€” Kategorie โ†’ Sprachcode (siehe oben), รผber die Tabelle "๐Ÿท Kategorie-Sprachen" gepflegt.
  • combine_mode โ€” wie das STT-Ergebnis mit VAD/Heuristik verknรผpft wird: "and" (Default) verlangt, dass beide "Sprache" sagen โ€” das lรคsst einen GroรŸteil in dieser Sprache gesungener Musik (VAD ja, STT erkennt meist keinen zusammenhรคngenden Text) korrekt als Musik durchgehen. Kein Allheilmittel: bei klar/langsam gesungenem deutschem Schlager erkennt Vosk gelegentlich kurze, grammatisch plausible Wortfetzen mit hoher Konfidenz (bei obigem Test ~20% der Schlager-Clips trotz Schwelle 0.75) โ€” UND reduziert Fehl-Switches auf gesungene Musik deutlich, verhindert sie aber nicht zu 100%. "or" reicht, wenn eines der beiden Signale "Sprache" sagt โ€” fรคngt mehr echte Moderation, aber wieder anfรคlliger fรผr denselben Gesangs-Fall.

Modell nicht gefunden oder Ladefehler โ†’ nur die betroffene Sprache bleibt wirkungslos (Log-Meldung, Ladezustand auch auf der Config-Seite pro Sprache sichtbar), RadioSabbelNich lรคuft mit den รผbrigen Sprachen/Sendern normal weiter. Ein Absturz der Engine bei einem einzelnen Sample รผberspringt nur diesen einen Sample, nicht den Hauptprozess.

Sprache des Web-Interfaces

Player- und Config-Seite gibt es auf Englisch (im Code eingebaute Basissprache) und Deutsch (externes "Sprachpaket", siehe unten). Umschaltbar unter /config โ†’ "๐ŸŒ Sprache" (wirkt spรคtestens eine Sekunde spรคter, kein Neustart nรถtig โ€” die Seite lรคdt nach dem Speichern automatisch neu). Startwert fรผr eine frische Installation kommt aus UI_LANGUAGE in .env (Default en, leer lassen reicht ebenfalls) โ€” sobald einmal รผber die Config-Seite gespeichert, gewinnt danach immer diese Einstellung, auch nach einem Neustart des Containers.

รœbersetzt sind alle Texte, die im Browser sichtbar sind (Labels, Buttons, Meldungen). Log-Datei und Server-seitige Fehlermeldungen (z.B. bei einer ungรผltigen Einstellung) bleiben unabhรคngig von dieser Einstellung deutsch.

Weitere Sprachen nachrรผsten: eine Sprache auรŸer Englisch kommt aus einer eigenen Datei im Ordner language/ (z.B. language/Deutsch.lng fรผr Deutsch) โ€” analog zu einem Windows-Sprachpaket. Format: einfaches Key=Value, eine Zeile pro Text, # leitet einen Kommentar ein. Zwei Zeilen am Dateianfang sind Pflicht:

#!code=de
#!name=Deutsch

code ist der Maschinencode (taucht in UI_LANGUAGE/der gespeicherten Einstellung auf), name der Anzeigename im Sprachauswahl-Dropdown. Die Datei muss nicht vollstรคndig sein โ€” ein fehlender Text fรคllt automatisch auf die englische Basis zurรผck, kein Absturz. Eine neue .lng-Datei wirkt nach docker compose up -d --build radiosabbelnich (Sprachdateien werden wie der รผbrige Code beim Bauen ins Image รผbernommen, kein Bind-Mount).

Web-Interface

Erreichbar unter http://<host>:5000/:

  • Unter dem Banner-Bild steht klein die aktuell laufende Version (VERSION im Repo-Root, siehe Versionspflege in CLAUDE.md) โ€” auf der Player- und der Config-Seite.
  • โš™ oben rechts (fest positioniert, bleibt beim Scrollen sichtbar) โ€” fรผhrt zur Config-Seite (/config).
  • ๐Ÿ“ป Radio / ๐ŸŽต Player โ€” Modus-Umschalter oben auf der Radio- und der Player-Seite (siehe eigener Abschnitt weiter oben). Ein Klick auf den jeweils anderen Modus schaltet um UND springt auf die passende Seite (dort liegen die zugehรถrigen Bedienelemente).
  • Aktueller Sender + "Jetzt lรคuft" โ€” Titel/Interpret, falls der Sender ICY-Metadaten oder eine bekannte Alternativ-Quelle liefert
  • Eingebetteter Player โ€” direkt im Browser mithรถren, ohne extra App/Client
  • โ–ถ๏ธ VLC / ๐Ÿ“ฑ Handy โ€” zwei Icons unter der "Lรคuft gerade"-Box รถffnen jeweils ein QR-Code-Popup: โ–ถ๏ธ VLC fรผr die Stream-URL zum Eintragen in einen externen Player (StandardmรครŸig automatisch aus der Adresse gebildet, รผber die die Seite gerade aufgerufen wird; auf der Config-Seite unter "๐Ÿ”— Streaming-Adresse" fest hinterlegbar, falls die tatsรคchliche รถffentliche Adresse davon abweicht), ๐Ÿ“ฑ Handy fรผr die Adresse dieses Web-Interfaces selbst (praktisch, um die Seite auf einem zweiten Gerรคt zu รถffnen oder als PWA zu installieren, siehe unten). Jedes Popup zeigt zusรคtzlich die Adresse als Klartext samt "๐Ÿ“‹ Adresse kopieren"-Knopf. QR-Codes werden rein clientseitig erzeugt (kein zusรคtzlicher Request, keine externe Bibliothek โ€” lรคuft komplett offline im Browser).
  • Sender-Liste zum manuellen Umschalten
  • โšก ZAPPEN! โ€” hast du selbst erkannt, dass gerade geredet wird (die Automatik aber noch nicht reagiert hat)? Schaltet sofort weiter.
  • ๐Ÿ›‘ Zapping-Fehler โ€” hat die Fingerprint-Erkennung fรคlschlich umgeschaltet (z.B. ein kurzer Sender-รผbergreifender Sting รผber einem Musikbett)? Wirft den zugrundeliegenden Clip aus der Datenbank, damit er nicht weiter fรคlschlich erkannt wird, UND schaltet zurรผck zu dem Sender, der vor dem Fehl-Switch lief.
  • Sabbelfilter deaktivieren/aktivieren โ€” schaltet die komplette automatische Erkennung fรผr eine Weile aus (z.B. fรผr ein Hรถrspiel/ Feature auf einem sonst Musik-Sender), ohne dass RadioSabbelNich dazwischenfunkt. Aktueller Zustand direkt am Button erkennbar.
  • ๐Ÿคฅ Bullshitometer โ€” grรผner-zu-roter Balken, zeigt den aktuell gemessenen Sprache-Wert (VAD-Wahrscheinlichkeit bzw. Heuristik-Votum) live in Prozent, aktualisiert alle 3s. Rein informativ (nicht klickbar) โ€” friert grau ein, wรคhrend Nachrichten-Pause lรคuft oder der Sabbelfilter aus ist, weil dann gar nicht klassifiziert wird.
  • ๐Ÿ—ฃ STT-Balken โ€” gleiche Optik wie das Bullshitometer, zeigt aber die rohe Konfidenz des STT-Sprachfilters (siehe eigener Abschnitt oben), nicht die von VAD/Heuristik. Friert zusรคtzlich grau ein ("STT aus"), wenn der STT-Filter selbst deaktiviert ist oder noch kein frischer Befund vorliegt โ€” unabhรคngig vom Sabbelfilter-Zustand, da der STT-Filter eine eigene An/Aus-Einstellung hat.
  • ๐Ÿ”Ž Fingerprint-Anzeige โ€” anders als die beiden Balken oben kein Dauerwert, sondern ein kurz aufblitzendes Ereignis: ๐Ÿ”ด "Treffer: <Name>" bei einer erkannten Werbung/Jingle (lรถst den automatischen Wechsel aus), ๐ŸŸข "Gelernt" bei einem neuen, noch unbekannten Clip. Fรคllt 5s nach dem letzten Ereignis von selbst auf โšช "Idle" zurรผck.

Aktueller Sender, News-Break-Status und Sabbelfilter-Zustand kommen nicht nur per Intervall-Polling (alle 3s), sondern zusรคtzlich รผber einen Long-Poll (GET /api/status/wait) an โ€” ein Senderwechsel oder News-Break- รœbergang erscheint dadurch binnen Millisekunden statt erst beim nรคchsten Poll-Tick.

  • Hรถrer-รœbersicht โ€” wer gerade zuhรถrt (IP/Client/Verbindungsdauer)
  • โš™ Sender verwalten (/config) โ€” Sender hinzufรผgen, bearbeiten, lรถschen, per Haken (de)aktivieren, gruppiert nach Kategorie (Lokal/Regional/National/International/Global/Interstellar/Unsortiert). "Unsortiert" ist standardmรครŸig eingeklappt (zum Ausklappen anklicken) โ€” fรผllt sich nach einem Import mit hunderten Sendern und wรผrde die Seite sonst sprengen. Jede Kategorie hat einen "Alle deaktivieren"-Knopf (praktisch nach einem Import mit hunderten neuen Sendern). ร„nderungen wirken sofort, ohne Neustart.
  • ๐Ÿ“ป Sender-Import (auf der Config-Seite) โ€” lรคdt eine M3U-Playlist (Default: die Kodinerds-Kodi-Radioliste) und hรถrt bei jedem Sender ein paar Sekunden mit (parallel, mit Fortschrittsanzeige). รœbernommen wird nur, wer dabei durchgehend Audio liefert โ€” inklusive der letzten Sekunden des Prรผffensters. Das ist bewusst strenger als ein ffprobe-Blick beim Verbinden: DASH-/HLS-Quellen schรผtten gerne einen Fragment-Vorrat auf einen Schlag aus und verstummen danach fรผr immer (siehe "Umgang mit toten Sendern"). Neue Sender landen deaktiviert in der Kategorie "Unsortiert" โ€” was tatsรคchlich in die Rotation kommt, entscheidet der Haken auf der Config-Seite. Manueller Trigger, kein Auto-Import.
  • ๐Ÿ—‘ Clip-DB leeren (auf der Config-Seite) โ€” lรถscht alle gelernten Fingerprint-Clips (nicht die Senderliste), mit Sicherheitsabfrage.
  • ๐Ÿ’พ Ressourcen-Verbrauch (auf der Config-Seite) โ€” RAM (Python-Prozess
    • alle ffmpeg-Kindprozesse zusammen sowie einzeln aufgeschlรผsselt), CPU, Anzahl laufender ffmpeg-Prozesse sowie Festplattenverbrauch von Fingerprint-DB, Logdatei (inkl. rotierter Backups) und Whisper-Modell- Cache โ€” jeweils nur RadioSabbelNich selbst, nicht der ganze Host. Alle 5s aktualisiert.
  • ๐Ÿ“ฐ Nachrichten-Pause (auf der Config-Seite, oberhalb der Senderliste) โ€” siehe eigener Abschnitt oben.
  • ๐Ÿ—ฃ STT-Sprachfilter (auf der Config-Seite) โ€” siehe eigener Abschnitt oben.

Der rohe Icecast-Stream bleibt parallel unter http://<host>:8000/radiosabbelnich.mp3 erreichbar (z.B. fรผr VLC).

Als App installieren (PWA)

Die Player-Seite ist als Progressive Web App installierbar โ€” praktisch fรผr unterwegs, damit "Zappen" nicht erst einen Browser-Tab braucht. Unter Chrome/Android: Seite รถffnen โ†’ Menรผ (โ‹ฎ) โ†’ "Zum Startbildschirm hinzufรผgen" (bzw. Chrome zeigt das oft von selbst als Vorschlag an). Die installierte App lรคuft dann im eigenen Fenster ohne Adressleiste (display: standalone).

Auf der installierten/mobilen Ansicht gibt es zwei groรŸe Buttons "โฎ Zurรผck"/"Weiter โญ" fรผr den vorherigen/nรคchsten Sender in der konfigurierten Rotationsreihenfolge (alphabetisch, wie die normale Sender-Liste) โ€” ohne erst durch die ganze Liste scrollen zu mรผssen. Ein Klick zeigt den Ziel-Sender sofort an (optimistisches UI-Update), die Bestรคtigung vom Server kommt normalerweise binnen Millisekunden รผber denselben Long-Poll nach, der auch die normale Sender-Liste aktuell hรคlt.

Ein Service Worker (sw.js) cached die statische Oberflรคchen-Hรผlle (HTML-Shell, Icons, QR-Bibliothek) fรผrs Offline-ร–ffnen โ€” reine Live-Daten (/api/*, der Audio-Stream selbst) sind davon ausdrรผcklich ausgenommen, ohne Netzwerkverbindung zeigt die App also weiterhin ehrlich "Verbindung zum Server verloren" statt einen eingefrorenen alten Zustand. Icons unter icon-192.png/icon-512.png sind aktuell schlichte Platzhalter-Grafiken.

Android-App (eigenstรคndige Zweitumsetzung)

Im Unterverzeichnis android-app/ liegt eine native Android-App, die dasselbe Grundprinzip komplett lokal auf dem Handy umsetzt โ€” Kotlin/ExoPlayer/Vosk statt Python/ffmpeg/Silero, ohne Web-Wrapper und ohne jede Abhรคngigkeit von dieser Docker-Instanz. Sie ist seit dem 2026-08-08 im Sinne ihres Fahrplans fertig: Senderverwaltung mit Kategorien, Watchdog gegen tote Sender, Vorwรคrmung des nรคchsten Senders, M3U-/Kodi-Import, Nachrichten-Pause, Audio-Fingerprinting und mehrsprachiges STT samt Kalibrierungs-Wizard sind umgesetzt und im Emulator getestet.

Eigene Doku dort: android-app/README.md (Funktionsumfang, Installation, bekannte Grenzen โ€” u.a. doppelter Netzwerkverbrauch durch zwei Dekodierungen, kein HLS/DASH, Verteilung per eigenem Update-Server statt Play Store).

QR-Code zum APK-Download

Direkt-Download per QR-Code: mit dem Handy scannen, um die aktuelle Debug-APK (radiosabbelnich-latest.apk) direkt herunterzuladen โ€” der Link wird bei jedem Android-Build automatisch aktualisiert (siehe android-app/README.md, Abschnitt "Bauen und Testen"). Vor der Installation muss Android "Installation aus unbekannten Quellen" fรผr den verwendeten Browser/Dateimanager erlauben โ€” kein Play Store, keine Signaturprรผfung รผber die Debug-Signierung hinaus (siehe oben).

Architektur

Grafische Gesamtรผbersicht mit Diagrammen pro Subsystem: ARCHITECTURE.md.

Datei Zweck
python/radiosabbelnich.py Hauptprozess: Stream holen, klassifizieren, umschalten, Icecast-Output
python/speech_detector.py Silero-VAD-Wrapper mit Signal-Heuristik-Fallback
python/fingerprint.py Audio-Fingerprinting (Constellation-Map-Hashing) in SQLite
python/stations_store.py Laden/Speichern/CRUD der Senderliste (stations.json)
python/settings_store.py Laufzeit-Einstellungen (Puffer-Parameter, Import-URL, settings.json)
python/station_import.py M3U-Import: laden, parsen, parallel auf dauerhaften Audiofluss prรผfen
python/webui.py Eingebettetes Web-Interface (Player-Seite + Config-Seite)
python/logging_setup.py Zentrale Logging-Konfiguration (Konsole + rotierende Logdatei)
python/news_break.py Nachrichten-Pause: Zeitfenster-Logik + zufรคllige MP3-Auswahl
python/audio_tags.py Format-รผbergreifende Tag-Anzeige (Titel/Interpret/Album/Jahr) via mutagen, geteilt zwischen News-Break/Musik-Player-Live-Anzeige und dem Musik-Scan
python/music_library.py Musiksammlung-Modus: Dateien eines Ordners auflisten (rekursiv, bis zu 5 Ebenen)
python/music_scan.py Musik-Library-Scan (Phase 1): rekursiver ID3-Scan in eigene SQLite-DB
python/folder_browse.py Gemeinsame Breadcrumb-Ordnerauswahl (News-Break-Pfad + Musiksammlung-Root)
python/stt_filter.py STT-Sprachfilter: Vosk/Whisper-Engines, austauschbar, Zusatzsignal fรผr die Switch-Entscheidung
python/i18n.py Basissprache Englisch fรผrs Web-Interface + Lader fรผr language/*.lng-Sprachpakete (siehe "Sprache des Web-Interfaces")
language/*.lng Externe Sprachpakete (z.B. Deutsch.lng), Key=Value-Format
python/resource_monitor.py Ressourcen-Verbrauch (RAM/CPU/DB-GrรถรŸe) fรผrs "๐Ÿ’พ Ressourcen-Verbrauch" auf der Config-Seite
web/qrcode.js Vendorte QR-Code-Bibliothek (MIT, kazuhikoarase/qrcode-generator) fรผrs "๐Ÿ“ฑ QR-Code"-Popup
web/manifest.json PWA-Manifest (Name, Icons, display: standalone) fรผrs "Zum Startbildschirm hinzufรผgen"
web/sw.js Service Worker: cached die statische Oberflรคchen-Hรผlle fรผrs Offline-ร–ffnen, kein Audio/API-Caching
pics/icon-192.png, pics/icon-512.png PWA-Icons fรผrs Installieren als App (aktuell Platzhalter)
pics/favicon.ico Browser-Tab-Icon, quadratische Miniatur von radiosabbelnich.webp
pics/radiosabbelnich.webp Banner-Grafik auf Player-/Config-Seite und in diesem README
data/stations.json Senderliste (Name, URL, Kategorie, aktiv/inaktiv)
data/settings.json Laufzeit-Einstellungen, siehe settings_store.py
data/fingerprints.db, data/fingerprint_clips/ Fingerprint-Datenbank + gelernte Clip-Mitschnitte
data/logs/ Rotierende Logdatei (siehe "Logging" unten)
data/news_mp3/, data/vosk-model-de/, data/whisper_cache/ Standard-Mountziele fรผr NEWS_MP3_FOLDER/VOSK_MODEL_FOLDER/faster-whisper-Cache (รผberschreibbar in .env)
data/music_library/ Standard-Mountziel fรผr MUSIC_LIBRARY_FOLDER (รผberschreibbar in .env)
docker-compose.yml Icecast + RadioSabbelNich als zwei Services
radiosabbelnich.sh Alles-in-einem-Wrapper: check/start/stop/restart/status (Default)
CHANGELOG.md Verdichtete Versionshistorie, neueste zuerst (Details in SESSION.md)

Wie Prozess-Modell, Audio-Pfad und die einzelnen Module zusammenspielen (inklusive Diagrammen): siehe ARCHITECTURE.md.

Setup

git clone <repo-url> RadioSabbelNich
cd RadioSabbelNich
cp env.example .env      # Passwรถrter/Hostname eintragen
touch data/fingerprints.db    # muss als Datei existieren, siehe unten
touch data/music_library.db   # dito, fรผr den Musik-Library-Scan (siehe unten)
./radiosabbelnich.sh check   # optional: prรผft Docker/.env/MP3-Ordner/Ports vorab
./radiosabbelnich.sh start

./radiosabbelnich.sh check installiert bei Bedarf Docker, zeigt RAM/HD/ Internet-Status, prรผft ob .env vollstรคndig ausgefรผllt ist (inkl. Warnung vor unverรคnderten env.example-Platzhaltern), ob der in NEWS_MP3_FOLDER eingetragene Ordner existiert/lesbar ist/MP3s enthรคlt, und ob WEBUI_PORT/ICECAST_PORT/ICECAST_SSL_PORT frei sind โ€” lรคuft bereits RadioSabbelNich selbst auf diesen Ports, gilt das als ok; blockiert stattdessen ein anderer Docker-Container den Port, schlรคgt das Skript eine freie Alternative zum Eintragen in .env vor. Reine Diagnose (Exit- Code 1 bei Problemen), startet selbst nichts. ./radiosabbelnich.sh start fรผr den eigentlichen Start prรผft schlanker (RAM/HD/Internet, NEWS_MP3_FOLDER) und bricht bei einem kaputten/fehlenden Pfad vor docker compose up mit einer klaren Diagnose ab, statt Docker den rohen, oft kryptischen Mount-Fehler werfen zu lassen โ€” danach docker compose up -d --build.

Fรผr den NEWS_MP3_FOLDER-Check fragt radiosabbelnich.sh bewusst docker compose config statt .env selbst zu parsen: eine Shell und Docker Compose interpretieren z.B. Backslashes in .env-Werten unterschiedlich (siehe NEWS_MP3_FOLDER in env.example) โ€” ein per Shell "korrekt" gelesener Pfad kann also genau der kaputte Pfad sein, den Docker gleich als Mount-Quelle verwendet. docker compose config liefert garantiert den Wert, den Docker tatsรคchlich benutzt.

Das touch ist Pflicht, nicht Kosmetik: fingerprints.db hรคngt in docker-compose.yml als einzelne Datei im Container. Fehlt sie auf dem Host, legt Docker an der Stelle ein Verzeichnis an โ€” SQLite kann sie dann nicht รถffnen und der Container landet in einer Neustartschleife. (Die DB selbst ist gitignored, ein frischer Clone hat sie also nie.)

Danach stations.json nach Belieben anpassen โ€” entweder direkt in der Datei oder bequemer รผber http://<host>:5000/config.

Fรผr den laufenden Betrieb danach reicht ./radiosabbelnich.sh (ohne Argument = status, sonst check/start/stop/restart) statt sich docker compose-Befehle zu merken โ€” status zeigt Container-Zustand, lokale Port-Erreichbarkeit, RAM/HD sowie den aktuell laufenden Sender/ Track und die Hรถrerzahl, sofern das Web-Interface erreichbar ist. Zusรคtzlich zeigt status den konfigurierten ICECAST_HOSTNAME (die Adresse fรผr Hรถrer von auรŸen, nicht nur localhost) und warnt rot, falls Tailscale ausgeloggt/gestoppt ist (nur bei einem *.ts.net-Hostnamen relevant) oder gar kein Internet/DNS erreichbar ist (per Ping gegen hamburg.de geprรผft) โ€” beides Fรคlle, in denen der Stream lokal noch normal lรคuft, aber niemand von auรŸen mehr rankommt. Ein weiterer Abschnitt zeigt den NEWS_MP3_FOLDER-Pfad der Nachrichten-Pause samt Trefferzahl (schlankere Variante desselben Checks aus check).

Wichtige .env-Variablen

Variable Bedeutung
ICECAST_ADMIN_USER/_PASSWORD Icecast-Admin-Login (auch fรผr die Hรถrer-Abfrage im Web-Interface)
ICECAST_SOURCE_PASSWORD Passwort, mit dem RadioSabbelNich selbst auf Icecast pusht
ICECAST_HOSTNAME ร–ffentlicher Hostname fรผr den Icecast-Stream
ICECAST_PORT Host-Port fรผr den rohen Icecast-Stream (Default 8000)
ICECAST_LOCATION/ICECAST_ADMIN_EMAIL Server-Info-Felder in Icecasts icecast.xml
WEBUI_PORT Host-Port fรผr das Web-Interface (Default 5000)
TLS_CERT_FILE/TLS_KEY_FILE Host-Pfade zu PEM-Dateien fรผr HTTPS (optional, siehe unten)
ICECAST_SSL_PORT Host-Port fรผr den Icecast-Stream per HTTPS (Default 8443)
VOSK_MODEL_FOLDER Host-Ordner mit einem entpackten deutschen Vosk-Modell fรผr den STT-Sprachfilter (optional, siehe eigener Abschnitt)
UI_LANGUAGE Startsprache des Web-Interfaces: en (Basissprache) oder der Code eines Sprachpakets unter language/ wie de (optional, Default en โ€” siehe "Sprache des Web-Interfaces")

HTTPS/TLS (optional)

Ohne TLS_CERT_FILE/TLS_KEY_FILE laufen Web-Interface und Icecast-Stream wie bisher nur รผber HTTP โ€” kein Pflichtschritt.

Mit einem Zertifikat (z.B. per tailscale cert <hostname> erzeugt, ein .crt+.key-Paar):

  1. Beide Host-Pfade in .env eintragen (TLS_CERT_FILE/TLS_KEY_FILE).
  2. docker compose up -d --build โ€” der Icecast-Stream bekommt dann automatisch einen zusรคtzlichen HTTPS-Port (ICECAST_SSL_PORT, Default 8443) neben dem bisherigen HTTP-Port 8000, der unverรคndert weiterlรคuft โ€” bestehende Hรถrerverbindungen sind also nie betroffen.
  3. Fรผrs Web-Interface zusรคtzlich unter /config โ†’ "๐Ÿ”’ HTTPS" den Haken setzen (oder tls_enabled in settings.json) und den Container einmal neu starten. Wichtig: anders als beim Stream gibt es hier keinen Parallelbetrieb โ€” sobald aktiv, ist das Web-Interface nur noch รผber https:// erreichbar, alte http://-Lesezeichen auf Port 5000 laufen dann ins Leere.

Icecast selbst muss dafรผr kurz mit Root-Rechten starten (um die 0600-Zertifikatsdatei lesen zu kรถnnen) und gibt sie danach intern wieder ab โ€” Details dazu in CLAUDE.md.

Deploy-Befehle

# Neu bauen + starten
docker compose up -d --build radiosabbelnich

# Konsole mitlesen (nur die wichtigen Ereignisse)
docker compose logs -f radiosabbelnich

# Vollstรคndiges Debug-Log (VAD-Werte, Fingerprint-Details, HTTP-Requests)
tail -f data/logs/radiosabbelnich.log

# Fingerprint-Mitschnitte anhรถren (nach einem "Zapping-Fehler"-Verdacht)
ls data/fingerprint_clips/

Logging

Zwei Ziele mit unterschiedlichem Detailgrad:

  • Konsole (docker compose logs): nur Ereignisse, die man im Alltag sehen will โ€” Senderwechsel, Fingerprint-Treffer, Warnungen, Fehler.
  • data/logs/radiosabbelnich.log: immer auf DEBUG, unabhรคngig von der Konsole. Pro Analysefenster die VAD-Wahrscheinlichkeit bzw. die Heuristik-Features, jeder Fingerprint-Vergleich mit Match-Stรคrke und Abstand zur Schwelle, jeder HTTP-Request des Web-Interfaces, jeder gestartete/gestorbene Hintergrund-Puffer. Rotierend (5 ร— 10 MB), auf dem Host unter data/logs/ gemountet โ€” รผberlebt also Container-Neustarts.

Der Sinn der Trennung: wenn nachts etwas schiefgeht, will man die Details hinterher lesen kรถnnen, ohne den Container vorher zufรคllig im richtigen Modus gestartet zu haben. --verbose schiebt die DEBUG-Zeilen zusรคtzlich auf die Konsole, --log-file "" schaltet die Datei ab.

Bekannte Einschrรคnkungen

  • Kein Auth auf dem Web-Interface/Config-Seite โ€” siehe Warnung oben, unbedingt hinter VPN/Tailscale betreiben.
  • Nicht jeder Sender liefert brauchbare "Jetzt lรคuft"-Metadaten; das entscheidet der jeweilige Sender-Betreiber.
  • Fingerprint-Erkennung ist ein Best-Effort-Mechanismus (Constellation- Map-Hashing mit 2D-Landmarken-Peaks, siehe fingerprint.py) โ€” an 26 echten Mitschnitten aus dem Live-Betrieb verifiziert (0 Fehltreffer bei klarer Trennung zu echten Wiederholungen), gelegentliche Fehlalarme sind trotzdem nie ganz ausgeschlossen. Dafรผr gibt's den "Zapping-Fehler"-Knopf.
  • "โฎ Zurรผck"/"Weiter โญ" wรคhrend einer laufenden Nachrichten-Pause: die Pause kennt (bewusst, siehe CLAUDE.md) nur den pausierten Sender als virtuelle ID, nicht dessen Position in der Rotation โ€” ein Klick wรคhrend der Pause schaltet deshalb zum ersten Sender der Liste statt zum eigentlichen Nachbarn des pausierten Senders.

Zukรผnftige Features

Eigene Musik-Library & Kategorisierung (geplant)

Optionaler Modus als Ergรคnzung zum Stream-Switching: lokale Musiksammlung scannen, taggen und nach Kategorien abspielbar machen.

  • โœ… Umschaltbar per Toggle (Radio-Modus vs. Player-Modus, STT/VAD im Musik-Modus komplett aus) und ein minimaler Player (Play/Stop/ Zurรผck/Nรคchster รผber einen konfigurierbaren Ordner, seit 2026-08-14 rekursiv bis zu 5 Unterordner-Ebenen tief, keine Kategorisierung) sind umgesetzt โ€” siehe "Player-Modus (Grundgerรผst)" weiter oben.
  • โœ… Format-Unterstรผtzung erweitert (seit 2026-08-12): Scan UND Playback laufen jetzt รผber MP3 hinaus auch fรผr FLAC, OGG (Vorbis), M4A (MP4-Container), rohes ADTS-AAC, WAV und APE (Monkey's Audio, nur Text-Tags โ€” siehe unten). Playback brauchte keine ร„nderung (ffmpeg ist bereits format-agnostisch), Metadaten/Cover-Extraktion in music_scan.py dagegen schon: FLAC/OGG/MP4 legen Cover-Bilder an komplett unterschiedlichen Stellen ab (kein gemeinsames mutagen-API wie bei den Text-Tags), WAV wird von mutagen nicht "easy"-gewrappt (Tags mussten รผber die rohen ID3-Frames gelesen werden), und getaggtes rohes AAC wurde von mutagens Auto-Erkennung fรคlschlich als MP3 erkannt und crashte beim Frame-Sync โ€” an echten, per ffmpeg erzeugten und per mutagen getaggten Testdateien gefunden und behoben, nicht nur aus der Doku รผbernommen (siehe SESSION.md). APE-Cover werden bewusst nicht extrahiert (kein standardisiertes Feld dafรผr, kein mutagen-API) und die APE-Unterstรผtzung selbst ist mangels Encoder im Image nicht gegen eine echte .ape-Datei verifiziert โ€” Text-Tags sollten laut mutagen-Doku funktionieren, das steht aber noch aus.
  • โœ… Phase 1 umgesetzt: rekursiver Scan der Musiksammlung (music_scan.py, getrennt vom Player-Modul music_library.py) รผber ID3-Metadaten (mutagen) โ†’ eigene SQLite-DB music_library.db (Artist, Album, Titel, Genre, Jahr, Dateipfad, eingebettetes Cover als gecachte Datei falls vorhanden). Manueller Trigger per POST /api/library/scan (GET /api/library/scan/status fรผrs Polling, kein Cronjob) โ€” bewusst noch ohne UI-Anschluss in dieser Phase, siehe SESSION.md. Unverรคnderte Dateien (mtime+GrรถรŸe wie beim letzten Scan) werden beim erneuten Scan รผbersprungen, damit ein Re-Scan einer groรŸen Sammlung nicht jedes Mal wieder alle Dateien komplett neu einliest.
    • Quelle: Fileserver 192.168.1.10, per SMB auf SERVER gemountet unter /mnt/server/data
  • โœ… Phase 2 umgesetzt: schlanker Query-Layer (music_query.py, an Beets' Query-Syntax angelehnt, aber ohne echten Parser โ€” nur feste Artist-/Genre-Teilstring-Filter) direkt an den Musik-Player angebunden. Die Kategorie-/Favoriten-Buttons auf /musik sind damit grรถรŸtenteils funktionsfรคhig: Queen/Pavarotti filtern per Artist-Teilstring, rock/klassik per Genre-Teilstring (LIKE '%rock%' โ€” reine Freitext-ร„hnlichkeit, kein exaktes Genre-Mapping, deckt sich nicht mit jeder Schreibweise). schnell/langsam seit Phase 3 ebenfalls aktiv (BPM-Teilstring bzw. -Bereich, siehe unten). Ein Klick lรถst dieselbe POST /api/music/play-Route wie der normale Play-Knopf aus (optionaler query-Body statt eines zweiten Endpoints), ersetzt eine laufende Wiedergabe sofort durch die Query-Ergebnisliste und zeigt bei 0 Treffern eine klare Meldung statt nichts zu tun. Lรคuft Artist/Titel bekannt (aus der DB), zeigt "Jetzt lรคuft" Artist โ€“ Titel statt nur des Dateinamens โ€” auf /musik UND auf der Player-Seite.
  • โœ… Phase 3 (BPM-Teil) umgesetzt: BPM-Schรคtzung (music_bpm.py, aubio statt librosa โ€” deutlich leichtgewichtiger zur Laufzeit, siehe CLAUDE.md fรผr den Grund und einen nรถtigen Build-Patch) lรคuft im selben Scan-Durchlauf wie das ID3-Parsing (gleiche mtime/GrรถรŸe-Skip-Logik, nur ein 60s-Schnipsel statt des kompletten Tracks wird dekodiert: ~0,25s/Track gemessen). schnell (โ‰ฅ120 BPM) / langsam (โ‰ค90 BPM) sind feste Schwellwerte, dazwischen fรคllt bei beiden raus โ€” bekannte Grenze: Oktavfehler (halbe/doppelte Geschwindigkeit) sind ein generisches Problem jeder Beat-Tracking-Methode, an einer echten 402-Track-Sammlung gemessen fielen dadurch spรผrbar mehr Tracks unter "schnell" als musikalisch stimmen dรผrfte. Energy-Erkennung/Browse-UI aus der ursprรผnglichen Phase-3-Idee bleiben offen.
  • โœ… Duplikat-Erkennung umgesetzt (seit 2026-08-12): music_query. find_duplicates() gruppiert Tracks mit demselben normalisierten Artist+Titel-Paar (klein geschrieben, Whitespace getrimmt) โ€” bewusst reiner Metadaten-Abgleich, kein Audio-Fingerprint-Vergleich (der brรคuchte eigenen Analyse-Code wie music_bpm.py, auf Nutzerwunsch nicht Teil dieser Runde). Erkennt z.B. denselben Song als MP3 UND FLAC, aber nicht inhaltlich identisches Audio mit abweichenden Tags. Tracks ohne Artist/Titel werden ausgeschlossen (sonst wรผrden untaggte Dateien fรคlschlich als eine riesige Duplikat-Gruppe erscheinen). รœber GET /api/library/duplicates abrufbar (JSON, inkl. DateigrรถรŸe pro Treffer als Entscheidungshilfe) โ€” bewusst noch ohne UI-Anschluss und ohne Lรถsch-Aktion in dieser Phase (Nutzerentscheidung: erst nur anzeigen/melden), siehe SESSION.md. An der echten 402-Track-Sammlung des Nutzers verifiziert: genau eine echte Duplikat-Gruppe gefunden.
  • Enrichment (spรคterer Baustein, getrennt vom Scan): fehlende Cover/Lyrics nachtrรคglich รผber externe Quellen (z.B. MusicBrainz/Cover Art Archive, lrclib.net) ergรคnzen, langfristiges Ziel: alle Tracks mit Cover + Lyrics + Kategorie-Markierung
  • Ideenliste (ganz langfristig, unklar ob umgesetzt): eigener KI-"Moderator" fรผr Zwischenansagen zu externen Ereignissen (z.B. Termine, eingetroffene Mails, Klingel-Events)

Tech-Stack: Python, mutagen, SQLite, ggf. FastAPI fรผr Query-API. Referenz: Beets (Library-Manager) als Inspiration fรผr Datenmodell/Query-Sprache, kein 1:1-Einsatz.

iOS-App (Idee, noch nicht terminiert)

Native iOS-App als Pendant zur bestehenden Android-App (siehe "Android-App" weiter oben): wรผrde dieselbe Sender-Steuerung und ggf. Musiksammlung-Bedienung bieten wie die Android-Version, aber mit Swift/SwiftUI gebaut und รผber Xcode auf einem Mac kompiliert โ€” ein eigenstรคndiges Projekt mit eigenem Tech-Stack, analog zu android-app/. Bislang nur Idee, kein Zeitplan.


๐Ÿ‡ฉ๐Ÿ‡ช Deutsche Version weiter oben

RadioSabbelNich (English version)

RadioSabbelNich listens to several internet radio stations at once and automatically switches away the moment someone starts talking โ€” presenting, news, ads, jingles. What's left (ideally) is just music. The currently selected station is re-streamed via Icecast, so you can listen to it anywhere on your (Tail)net with VLC, in the browser, or any other streaming client.

โš ๏ธ Private use only, behind a VPN โ€” no public deployment

RadioSabbelNich is explicitly not meant for public deployment. The Icecast port (8000) and the web interface port (5000) must never be exposed directly to the open internet (no port forwarding, no public reverse proxy) โ€” RadioSabbelNich always runs behind a VPN (Tailscale or similar), reachable only from devices on your own trusted network. Two concrete reasons:

  • Resources: an openly reachable Icecast mount point will sooner or later be found (scanners, streaming aggregators, hotlinking) โ€” and then potentially half the internet starts pulling bandwidth and CPU time uncontrolled, in a way you can never fully rein back in.
  • Copyright: RadioSabbelNich re-streams other people's licensed radio programs. For private personal use inside your own (Tail)net that's one thing โ€” made publicly accessible, it's an unlicensed public performance of copyrighted content. There is no shortage of law firms for whom that's exactly a business model.

The web interface and config page also have no authentication whatsoever (see below) โ€” another reason "briefly making it publicly reachable" is a bad idea.

How detection works

  1. Silero VAD (a neural network specialized in speech detection) continuously classifies ~1-second windows of the current station as speech or music. If VAD isn't available (e.g. for environment reasons), a simpler signal heuristic automatically takes over (zero-crossing rate/spectral flatness/energy modulation).
  2. Once speech detection holds up for a few seconds in a row, RadioSabbelNich cycles to the next enabled station until music is playing again.
  3. In parallel, audio fingerprinting runs (a Shazam-style constellation-map approach): detected speech clips are hashed and compared against a SQLite database of clips already heard. If a clip is already known (e.g. a recurring ad spot or station jingle), RadioSabbelNich switches immediately instead of waiting out the full speech-detection time.

Neither mechanism is perfect โ€” that's what the correction buttons in the web interface are for (see below).

Handling dead stations (watchdog)

Not every station URL stays playable forever โ€” imported lists contain stale entries, and even a working station can go silent for minutes at a time. So this doesn't stall playback entirely:

  • If the current station delivers nothing for three analysis windows in a row, it's pulled from rotation for 5 minutes and RadioSabbelNich automatically switches on (STREAM_FAILURE_LIMIT/ STATION_DEAD_COOLDOWN in radiosabbelnich.py).
  • If a background buffer dies, its station is immediately put on the same block list instead of being reconnected every second.
  • Blocked stations are skipped during automatic switching and aren't buffered. After the 5 minutes are up they automatically get another chance โ€” a manual click in the web interface lifts the block right away.

Without this watchdog, a single dead station could stall the entire player: this actually happened with an imported DASH URL that ffprobe correctly flagged as "has audio" during import, but that ffmpeg can't play continuously โ€” 3569 reconnect attempts over 8.5 hours, Icecast mount silent the whole time.

Look-ahead buffering & playout delay

RadioSabbelNich keeps the next stations in rotation order running in the background and buffers the last prebuffer_seconds seconds of each (default 10s, configurable under /config, takes effect immediately, no restart needed). That buffer serves two purposes at once:

  1. Seamless switching: switching to a buffered station (automatically or manually) takes over the already-running source immediately instead of reconnecting โ€” no reconnect stutter.
  2. Listener delay for speech detection: the same buffer also delays the broadcast of the CURRENTLY playing station by exactly prebuffer_seconds. Speech detection (VAD/heuristic/STT/fingerprint) runs on freshly arrived audio that the listener only gets after this delay โ€” talk/ads can therefore be detected and switched away from BEFORE it reaches the listener, not just after. Costs extra bandwidth/CPU (one extra ffmpeg process per buffered station, running alongside the current one; the default of 5 stations ร— 10s is uncritical on typical home hardware).

A switch takes over the target buffer's entire window sequence in one go โ€” output continues afterwards in the same one-second cadence as before: no gap, no duplicated audio, no cumulative drift from real time (verified: the delay stays constant, it doesn't grow with every zap).

Limitation: if a switch lands on a station that is NOT currently buffered (e.g. a manual click outside the next prebuffer_count stations in rotation, or an emergency switch because all buffered candidates are themselves dead), that station plays without delay โ€” instant reaction to the click, but without the look-ahead benefit until the next switch hits a buffered station again. A gapless transition from 0 to full delay isn't possible without time-stretching/pitch manipulation, so it's deliberately not attempted.

News break timing shifts accordingly: if the current station is running with full delay, the news-break MP3 reaches the listener up to prebuffer_seconds later than the actual top/bottom of the hour โ€” the window length itself (window_minutes) is unaffected.

News break

Practically every radio station reads the news on the hour and half hour. Instead, RadioSabbelNich can play a random MP3 from a local folder for a short time window (e.g. your own jingles/music from an SMB mount) โ€” afterwards it automatically resumes the paused station, as normal.

Configured via the news_break block in settings.json, adjustable through the "๐Ÿ“ฐ Nachrichten-Pause" form section above the station list on the config page (/config), or directly via the API:

"news_break": {
  "enabled": false,
  "mp3_folder": "/app/news_mp3",
  "window_minutes": 2.0,
  "enabled_hours": null
}
  • enabled โ€” feature on/off.
  • mp3_folder โ€” a container-internal path (not the host path!), set on the config page via a breadcrumb folder picker (click through the subfolders of /app/news_mp3 instead of typing the path). The actual host folder is mounted in from outside via NEWS_MP3_FOLDER in .env (see docker-compose.yml), typically an SMB mount โ€” that needs a container restart, not a field on the config page. Since 2026-08-14 the picker also searches subfolders, up to 5 levels deep. Folder missing/empty/no MP3s (including in subfolders)/unreadable โ†’ the feature is simply skipped for that time window, with a log entry, no error. Below the field, the config page shows the real host path read-only for reference (passed through from NEWS_MP3_FOLDER) โ€” the container otherwise has no way to know it, Docker translates hostโ†’container path only once at startup.
  • window_minutes โ€” how many minutes before/after :00 and :30 the feature is active.
  • enabled_hours โ€” optional [start, end], e.g. [6, 22] for "only 6amโ€“10pm"; null = around the clock. No overnight wraparound (22โ€“6 is not supported).

Alternatively, set it directly via the API (e.g. for scripts):

curl -X POST http://<host>:5000/api/config/settings \
     -H 'Content-Type: application/json' \
     -d '{"news_break_enabled": true, "news_break_window_minutes": 2}'

A time window is served at most once โ€” if an MP3 finishes before the remaining window is over, another random MP3 is automatically loaded (no immediate repeat, as long as the folder has more than one file) until window_minutes has elapsed. The MP3 currently playing is always played to the end, even if window_minutes runs out while it's playing โ€” the break may end up running a bit longer than configured rather than cutting a track off mid-playback. Only after that does it automatically return to the paused station. A manual station switch during the break cancels it immediately (a manual decision beats automation, as everywhere else in RadioSabbelNich). During the break, automatic speech detection (VAD/heuristic/fingerprint) is also paused โ€” the MP3 itself may well contain speech, and that shouldn't be misread as "presenting" on the actual station. On the radio home page, a tag display (since 2026-08-15, via mutagen, format-agnostic) shows the playing MP3's title/artist/album/year instead of just the filename โ€” see "Player mode" below for details (same display, same fallback behavior).

Player mode (foundation)

First implementation step of the music library idea described further below under "Future features": a standalone, persisted mode alongside normal radio operation โ€” switchable on both the radio and the player page via a clearly visible toggle ("๐Ÿ“ป Radio" / "๐ŸŽต Player") (the feature was called "Music library" until 2026-08-13 โ€” internally, in settings.json/code, the mode is still named music, only the web interface label was simplified). In player mode, all automatic detection (VAD/heuristic/STT/fingerprint) is off, not just paused โ€” only local music plays, nothing gets analyzed. The mode survives a container restart (stored in settings.json) โ€” since 2026-08-15 it also automatically starts playback (first track of the configured folder), both after a restart with the player mode already saved and on a manual switch from radio to player. Before, playback stayed inactive in both cases until โ–ถ was tapped manually โ€” the mode itself was correctly remembered, but no sound played until someone actively hit play.

On the standalone /musik page ("๐ŸŽต Player"):

  • The selected music folder is displayed โ€” since 2026-08-13 the real host path (translated server-side from the container path, via MUSIC_LIBRARY_FOLDER from .env), previously it showed the technically correct but meaningless-to-the-user container path (/app/music_library/...). A "Change path" button leads to the actual folder picker on the config page (see below).
  • Two button groups, "Categories" (schnell/langsam/rock/klassik) and "Favorites" (Queen/Pavarotti) โ€” currently pure placeholders with no function; real filtering (categories on metadata/tags like BPM/genre, favorites on the artist tag) arrives with the music scan (roadmap phase 1 below).
  • A big play/stop button plus back/next โ€” plays the music files in the configured folder including subfolders (since 2026-08-14, up to 5 levels deep), alphabetically, looping forever until stop is pressed. Since 2026-08-13 this single button is also the only visible control for actually listening: a hidden <audio> element (no control bar of its own) automatically follows the playback state. Before, there was also a native browser player with its own play button reacting independently from the big button โ€” two "play" buttons that didn't know about each other and got in each other's way.
  • No more banner image on this page (since 2026-08-13, a tidier, standalone look instead of the radio page's elements).
  • Tag display (since 2026-08-15): below the filename/progress line ("Track (i/total)"), a second/third line shows the metadata read via mutagen โ€” "Artist โ€“ Title" and "Album (Year)", format-agnostic (MP3, FLAC, OGG, M4A/AAC, WAV, APE). No title tag โ†’ falls back to the filename; missing album/year โ†’ that second line is omitted entirely instead of a placeholder like "Album: โ€“". The same display runs on the radio home page whenever a news-break MP3 is playing (see "News break" above).

The music folder is set on the config page, just like the news break MP3 folder, via a breadcrumb folder picker: click through the subfolders of the directory mounted via MUSIC_LIBRARY_FOLDER (.env, same pattern as NEWS_MP3_FOLDER) instead of typing a path. Both fields (news break folder, music library root) use the same component but save independently of each other.

STT speech filter

Silero VAD/the signal heuristic detect "is there a human voice here" โ€” sung music often counts as a false positive there too. The STT speech filter (stt_filter.py) instead listens via speech-to-text for whether coherent text in the expected language is currently audible, and feeds that in as an additional signal for the switch decision.

Two interchangeable engines, never loaded at the same time:

  • Vosk โ€” a small Kaldi model, lightweight and usable on a Raspberry Pi. Needs its own model per language.
  • Whisper (via faster-whisper) โ€” more accurate, but noticeably more resource-hungry, even as the "tiny" model. A single loaded model covers any number of languages (the language code is just passed per analysis) โ€” with Whisper, an extra language costs no extra RAM.

Multi-language: language per station category

Which language is checked for a station depends on its category (Local/Regional/National/International/โ€ฆ, see "Web interface" above) โ€” not the individual station. The config page has two new sections for this below "๐Ÿ—ฃ STT-Sprachfilter":

  • ๐ŸŒ STT-Sprachen โ€” sets up which languages are available at all: language code (free text, e.g. en, fr โ€” no fixed list, since Vosk models have to be sourced manually anyway), a model path for engine "Vosk", plus an (empirically determined, see below) confidence threshold. Entering an existing language code again updates it instead of duplicating it. Each row also shows the load state (โœ… loaded / โš  error message / not loaded yet) โ€” with Vosk, each language model is loaded lazily on its first actual sample, not already when saved.
  • ๐Ÿท Kategorie-Sprachen โ€” assigns one of the languages configured above to each of the fixed categories. Categories without a selection default to German (de).

With Vosk, never more than 2 language models are loaded at once (for RAM reasons, see weak hardware/Pi) โ€” with more configured languages, the least recently used one is evicted automatically (LRU) and reloaded on next demand. If a station's expected language changes (e.g. through a category change), a not-yet-expired STT reading from the PREVIOUS language is discarded instead of being reused incorrectly.

Mounting additional Vosk models: the bundled VOSK_MODEL_FOLDER mount in docker-compose.yml covers exactly ONE model (default: German, /app/vosk-model-de). For another language, add your own extra line to docker-compose.yml, e.g.:

      - ${VOSK_MODEL_FOLDER_EN:-./data/vosk-model-en}:/app/vosk-model-en:ro

and enter the resulting container path (/app/vosk-model-en) as the model path under "๐ŸŒ STT-Sprachen" โ€” then docker compose up -d --build radiosabbelnich so the new mount takes effect.

Calibration wizard

Instead of guessing confidence_threshold, the config page has a "๐Ÿงช Schwellwert-Kalibrierung" section below "๐Ÿท Kategorie-Sprachen" โ€” it reproduces the same method originally used to derive the German default (0.75, see above), just guided instead of reading it off the logs by hand:

  1. Enter a language code (for Vosk, the language must already be set up with a model path under "๐ŸŒ STT-Sprachen" first; not needed for Whisper) and click "๐Ÿงช Start calibration". Requirement: the STT filter and chatter filter must be active (otherwise STT doesn't sample at all, see above).
  2. Manually switch to a station with guaranteed real speech in that language on the player page (e.g. a news channel) and let it run for a few minutes โ€” the wizard page shows the sample count as well as confidence min/max/average live (polled every 2s).
  3. Switch to the "๐ŸŽต Musik-Stufe" and manually switch to a music station in the same language, again let it collect for a few minutes.
  4. Once both stages have samples, a suggestion appears (the boundary between the highest measured music value and the lowest measured speech value, with a safety margin toward the speech side) โ€” "Apply" saves it directly as that language's confidence_threshold. If speech and music don't separate cleanly in the measured sample (overlap), the suggestion shows a warning instead of being applied silently โ€” usually more samples or a different test station help.

Samples where STT recognized no text at all (pause/jingle/ad break during the speech stage, a purely instrumental passage during the music stage) do NOT count toward the statistics โ€” empty text means "no judgment formed", not "recognized with low confidence". Important when picking the music-stage station: many commercial radio stations have a substantial spoken share (ads, DJ links between songs) โ€” that alone can cause an unclean separation, with no recognition error involved. A station with as little talk as possible gives better results.

Important: calibration itself never switches anything โ€” which station is playing is decided exclusively on the player page. While a calibration is running, automatic station switching is also completely paused (not just for the calibration language), so that an STT result distorted by the forced test language can't trigger a switch mid- calibration โ€” the running station stays put until calibration ends.

Configuration in detail

Configured via the stt_filter block in settings.json (languages themselves managed via set_stt_language()/delete_stt_language(), don't edit the block directly):

"stt_filter": {
  "enabled": false,
  "engine": "vosk",
  "whisper_model_size": "tiny",
  "sample_interval_seconds": 8.0,
  "combine_mode": "and",
  "languages": {
    "de": {"vosk_model_path": "/app/vosk-model-de", "confidence_threshold": 0.75}
  },
  "category_languages": {}
}
  • enabled โ€” feature on/off.
  • engine โ€” "vosk" or "whisper", GLOBAL for all configured languages at once (see above for why the two are never mixed).
  • whisper_model_size โ€” e.g. "tiny", "base" (see the faster-whisper docs for further sizes), also global. Models are automatically downloaded from HuggingFace on first use and cached in a persistent volume (no manual download needed, but first activation needs internet access and some time).
  • sample_interval_seconds โ€” how often a short clip (~3s) is taken for analysis. Runs continuously in the background, independent of the current VAD result (never blocks the main loop).
  • languages.<code>.vosk_model_path โ€” a container-internal path (not the host path!) to an unpacked Vosk model for that language, see above. German models are available at alphacephei.com/vosk/models โ€” vosk-model-small-de-0.15 (~45 MB) for weaker hardware/Pi, vosk-model-de-0.21 (~1 GB) for more accuracy; for other languages, look for the matching model on the same site.
  • languages.<code>.confidence_threshold โ€” the (best-effort) confidence above which a sample counts as "coherent text in that language". The de default (0.75) is empirically measured, not guessed: 10 live clips from Deutschlandfunk (speech) never dropped below 0.83 confidence, 30 live clips from three Schlager stations (sung German music) averaged 0.38 โ€” 0.75 sits safely below the speech minimum. The same method applies to any further language: listen in for a few minutes against a known speech AND a known music station in that language; detected text/confidence values are logged to logs/radiosabbelnich.log for this.
  • category_languages โ€” category โ†’ language code (see above), managed via the "๐Ÿท Kategorie-Sprachen" table.
  • combine_mode โ€” how the STT result is combined with VAD/ heuristic: "and" (default) requires both to say "speech" โ€” this lets a large share of music sung in that language (VAD says yes, STT usually detects no coherent text) correctly pass through as music. Not a silver bullet: with clearly/slowly sung German Schlager, Vosk occasionally detects short, grammatically plausible word fragments with high confidence (~20% of Schlager clips in the test above despite the 0.75 threshold) โ€” "and" noticeably reduces false switches on sung music, but doesn't eliminate them 100%. "or" is enough if either signal says "speech" โ€” catches more actual presenting, but is again more prone to that same singing case.

Model not found or load error โ†’ only that language stays ineffective (log entry, load state also visible per language on the config page), RadioSabbelNich keeps running normally with the remaining languages/ stations. A crash of the engine on a single sample only skips that one sample, not the main process.

Web interface language

The player and config pages are available in English (the base language, built into the code) and German (an external "language pack", see below). Switch it under /config โ†’ "๐ŸŒ Sprache" (takes effect within about a second, no restart needed โ€” the page reloads automatically after saving). The starting value for a fresh install comes from UI_LANGUAGE in .env (default en, leaving it empty works too) โ€” once saved via the config page, that setting always wins afterwards, even after restarting the container.

Everything visible in the browser is translated (labels, buttons, messages). The log file and server-side error messages (e.g. for an invalid setting) stay German regardless of this setting.

Adding more languages: any language besides English lives in its own file under the language/ folder (e.g. language/Deutsch.lng for German) โ€” similar to a Windows language pack. Format: simple Key=Value, one line per text, # starts a comment. Two lines are required at the top of the file:

#!code=de
#!name=Deutsch

code is the machine code (shows up in UI_LANGUAGE/the saved setting), name is the display name in the language dropdown. The file doesn't need to be complete โ€” a missing text automatically falls back to the English base instead of crashing. A new .lng file takes effect after docker compose up -d --build radiosabbelnich (language files are baked into the image at build time like the rest of the code, no bind mount).

Web interface

Reachable at http://<host>:5000/:

  • The currently deployed version is shown in small text below the banner image (VERSION at the repo root, see version tracking in CLAUDE.md) โ€” on both the player and config page.
  • โš™ top right (fixed position, stays visible while scrolling) โ€” leads to the config page (/config).
  • ๐Ÿ“ป Radio / ๐ŸŽต Player โ€” mode toggle at the top of the radio and the player page (see the dedicated section further up). Clicking the other mode switches to it AND jumps to the matching page (that's where the corresponding controls live).
  • Current station + "now playing" โ€” title/artist, if the station provides ICY metadata or a known alternative source
  • Embedded player โ€” listen right in the browser, no extra app/client needed
  • โ–ถ๏ธ VLC / ๐Ÿ“ฑ Phone โ€” two icons below the "now playing" box each open a QR code popup: โ–ถ๏ธ VLC for the stream URL to enter into an external player (by default derived automatically from the address the page is currently being accessed with; can be pinned on the config page under "๐Ÿ”— Streaming-Adresse" if the actual public address differs), ๐Ÿ“ฑ Phone for the address of this web interface itself (handy for opening the page on a second device or installing it as a PWA, see below). Each popup also shows the address as plain text with a "๐Ÿ“‹ Copy address" button. QR codes are generated entirely client-side (no extra request, no external library โ€” works fully offline in the browser).
  • Station list for manual switching
  • โšก ZAP! โ€” noticed someone talking yourself (before automation reacted)? Switches immediately.
  • ๐Ÿ›‘ Zap error โ€” did fingerprint detection switch away incorrectly (e.g. a short cross-station sting over a music bed)? Throws the underlying clip out of the database so it won't be misdetected again, AND switches back to the station that was playing before the false switch.
  • Disable/enable chatter filter โ€” turns off all automatic detection for a while (e.g. for a radio drama/feature on an otherwise music station) without RadioSabbelNich interfering. Current state is visible directly on the button.
  • ๐Ÿคฅ Bullshit-o-meter โ€” a green-to-red bar showing the currently measured speech value (VAD probability or heuristic vote) live in percent, updated every 3s. Purely informational (not clickable) โ€” freezes gray while a news break is running or the chatter filter is off, because nothing is being classified then.
  • ๐Ÿ—ฃ STT bar โ€” same look as the bullshit-o-meter, but shows the raw confidence of the STT speech filter (see its own section above) instead of VAD/heuristic. Also freezes gray ("STT off") when the STT filter itself is disabled or no fresh reading is available yet โ€” independent of the chatter filter state, since the STT filter has its own on/off setting.
  • ๐Ÿ”Ž Fingerprint indicator โ€” unlike the two bars above, not a continuous value but a briefly flashing event: ๐Ÿ”ด "Match: <name>" on a recognized ad/jingle (triggers the automatic switch), ๐ŸŸข "Learned" on a new, previously unknown clip. Falls back to โšช "Idle" on its own 5s after the last event.

Current station, news-break status and chatter-filter state arrive not just via interval polling (every 3s) but additionally via long polling (GET /api/status/wait) โ€” a station switch or news-break transition appears within milliseconds instead of waiting for the next poll tick.

  • Listener overview โ€” who's currently listening (IP/client/ connection duration)
  • โš™ Manage stations (/config) โ€” add, edit, delete stations, (de)activate via checkbox, grouped by category (Local/Regional/ National/International/Global/Interstellar/Unsorted). "Unsorted" is collapsed by default (click to expand) โ€” it fills up with hundreds of stations after an import and would otherwise blow up the page. Each category has a "disable all" button (handy after importing hundreds of new stations). Changes take effect immediately, no restart needed.
  • ๐Ÿ“ป Station import (on the config page) โ€” downloads an M3U playlist (default: the Kodinerds Kodi radio list) and listens to each station for a few seconds (in parallel, with a progress indicator). Only stations that deliver audio continuously โ€” including the last seconds of the check window โ€” are kept. This is deliberately stricter than an ffprobe glance on connect: DASH/HLS sources like to dump a fragment supply all at once and then go silent forever (see "Handling dead stations"). New stations land disabled in the "Unsorted" category โ€” what actually joins the rotation is decided via the checkbox on the config page. Manual trigger, no auto-import.
  • ๐Ÿ—‘ Clear clip DB (on the config page) โ€” deletes all learned fingerprint clips (not the station list), with a confirmation prompt.
  • ๐Ÿ’พ Resource usage (on the config page) โ€” RAM (Python process + all ffmpeg child processes combined, plus a breakdown), CPU, number of running ffmpeg processes, and disk usage of the fingerprint DB, log file (including rotated backups), and Whisper model cache โ€” all for RadioSabbelNich itself, not the whole host. Refreshed every 5s.
  • ๐Ÿ“ฐ News break (on the config page, above the station list) โ€” see its own section above.
  • ๐Ÿ—ฃ STT speech filter (on the config page) โ€” see its own section above.

The raw Icecast stream also remains reachable in parallel at http://<host>:8000/radiosabbelnich.mp3 (e.g. for VLC).

Installing as an app (PWA)

The player page can be installed as a Progressive Web App โ€” handy on the go, so "zapping" doesn't need a browser tab first. On Chrome/ Android: open the page โ†’ menu (โ‹ฎ) โ†’ "Add to home screen" (Chrome often suggests this on its own). The installed app then runs in its own window without an address bar (display: standalone).

On the installed/mobile view there are two large buttons "โฎ Back"/ "Next โญ" for the previous/next station in the configured rotation order (alphabetical, like the normal station list) โ€” without having to scroll through the whole list. A tap shows the target station immediately (optimistic UI update); server confirmation normally follows within milliseconds via the same long poll that also keeps the regular station list current.

A service worker (sw.js) caches the static UI shell (HTML shell, icons, QR library) for offline opening โ€” actual live data (/api/*, the audio stream itself) is explicitly excluded, so without a network connection the app still honestly shows "connection to server lost" instead of a frozen stale state. The icons at icon-192.png/ icon-512.png are currently plain placeholder graphics.

Android app (separate second implementation)

The android-app/ subdirectory contains a native Android app that implements the same idea entirely on the phone โ€” Kotlin/ExoPlayer/ Vosk instead of Python/ffmpeg/Silero, no web wrapper and no dependency on this Docker instance whatsoever. As of 2026-08-08 it is complete with respect to its own roadmap: station management with categories, a watchdog for dead stations, pre-warming of the next station, M3U/Kodi import, the news break, audio fingerprinting and multilingual STT including a calibration wizard are all implemented and tested in the emulator.

It has its own documentation (in German): android-app/README.md โ€” feature list, installation and known limitations (among them doubled network usage from two independent decodes, no HLS/DASH, and distribution via a self-hosted update server rather than the Play Store).

QR code for the APK download

Direct download via QR code: scan with your phone to download the current debug APK (radiosabbelnich-latest.apk) directly โ€” the link is updated automatically with every Android build (see android-app/README.md, "Bauen und Testen" section). Android must be allowed to "install from unknown sources" for whichever browser/file manager you use before installing โ€” no Play Store, no signature check beyond the debug signing (see above).

Architecture

File Purpose
python/radiosabbelnich.py Main process: fetch stream, classify, switch, Icecast output
python/speech_detector.py Silero VAD wrapper with signal-heuristic fallback
python/fingerprint.py Audio fingerprinting (constellation-map hashing) in SQLite
python/stations_store.py Load/save/CRUD for the station list (stations.json)
python/settings_store.py Runtime settings (buffer parameters, import URL, settings.json)
python/station_import.py M3U import: download, parse, check for continuous audio in parallel
python/webui.py Embedded web interface (player page + config page)
python/logging_setup.py Central logging config (console + rotating log file)
python/news_break.py News break: time-window logic + random MP3 selection
python/audio_tags.py Format-agnostic tag display (title/artist/album/year) via mutagen, shared between the news-break/music-player live display and the music scan
python/music_library.py Music library mode: list a folder's files (recursive, up to 5 levels)
python/music_scan.py Music library scan (phase 1): recursive ID3 scan into its own SQLite DB
python/folder_browse.py Shared breadcrumb folder picker (news break path + music library root)
python/stt_filter.py STT speech filter: interchangeable Vosk/Whisper engines, additional signal for the switch decision
python/i18n.py English base language for the web interface + loader for language/*.lng language packs (see "Web interface language")
language/*.lng External language packs (e.g. Deutsch.lng), Key=Value format
python/resource_monitor.py Resource usage (RAM/CPU/DB size) for the "๐Ÿ’พ Resource usage" section on the config page
web/qrcode.js Vendored QR code library (MIT, kazuhikoarase/qrcode-generator) for the "๐Ÿ“ฑ QR code" popup
web/manifest.json PWA manifest (name, icons, display: standalone) for "Add to home screen"
web/sw.js Service worker: caches the static UI shell for offline opening, no audio/API caching
pics/icon-192.png, pics/icon-512.png PWA icons for installing as an app (currently placeholders)
pics/favicon.ico Browser tab icon, a square thumbnail of radiosabbelnich.webp
pics/radiosabbelnich.webp Banner graphic on the player/config page and in this README
data/stations.json Station list (name, URL, category, active/inactive)
data/settings.json Runtime settings, see settings_store.py
data/fingerprints.db, data/fingerprint_clips/ Fingerprint database + learned clip recordings
data/logs/ Rotating log file (see "Logging" below)
data/news_mp3/, data/vosk-model-de/, data/whisper_cache/ Default mount targets for NEWS_MP3_FOLDER/VOSK_MODEL_FOLDER/the faster-whisper cache (overridable in .env)
data/music_library/ Default mount target for MUSIC_LIBRARY_FOLDER (overridable in .env)
docker-compose.yml Icecast + RadioSabbelNich as two services
radiosabbelnich.sh All-in-one wrapper: check/start/stop/restart/status (default)
CHANGELOG.md Condensed version history, newest first (details in SESSION.md)

RadioSabbelNich and the web interface run in the same process (web server as a background thread) โ€” no separate service, no IPC needed, just shared in-memory state.

Audio flows internally as stereo PCM (Icecast output); the analysis pipeline (VAD/heuristic/fingerprint) deliberately only computes on a mono downmix to save CPU time.

Setup

git clone <repo-url> RadioSabbelNich
cd RadioSabbelNich
cp env.example .env      # enter passwords/hostname
touch data/fingerprints.db    # must exist as a file, see below
touch data/music_library.db   # same, for the music library scan (see below)
./radiosabbelnich.sh check   # optional: pre-checks Docker/.env/MP3 folder/ports
./radiosabbelnich.sh start

./radiosabbelnich.sh check installs Docker if needed, shows RAM/disk/ internet status, checks whether .env is fully filled in (including a warning about unchanged env.example placeholders), whether the folder set in NEWS_MP3_FOLDER exists/is readable/contains MP3s, and whether WEBUI_PORT/ICECAST_PORT/ICECAST_SSL_PORT are free โ€” if RadioSabbelNich itself is already running on those ports, that counts as fine; if a different Docker container is blocking the port instead, the script suggests a free alternative to enter in .env. Pure diagnostics (exit code 1 on problems), starts nothing itself. ./radiosabbelnich.sh start does the actual start with a leaner check (RAM/disk/internet, NEWS_MP3_FOLDER), then docker compose up -d --build.

The touch is mandatory, not cosmetic: fingerprints.db is mounted in docker-compose.yml as a single file inside the container. If it's missing on the host, Docker creates a directory there instead โ€” SQLite then can't open it and the container ends up in a restart loop. (The DB itself is gitignored, so a fresh clone never has it.)

Afterwards, adjust stations.json as you like โ€” either directly in the file or more conveniently via http://<host>:5000/config.

For day-to-day operation afterwards, ./radiosabbelnich.sh (no argument = status, otherwise check/start/stop/restart) saves you from remembering docker compose commands โ€” status shows container state, local port reachability, RAM/disk, plus the currently playing station/track and listener count, if the web interface is reachable. It also shows the configured ICECAST_HOSTNAME (the address listeners use from outside, not just localhost) and prints a red warning if Tailscale is logged out/stopped (only relevant for a *.ts.net hostname) or if there's no internet/DNS at all (checked via a ping to hamburg.de) โ€” both cases where the stream still runs fine locally but nobody outside can reach it anymore. Another section shows the news break's NEWS_MP3_FOLDER path along with a file count (a leaner version of the same check from check).

Important .env variables

Variable Meaning
ICECAST_ADMIN_USER/_PASSWORD Icecast admin login (also used for the listener query in the web interface)
ICECAST_SOURCE_PASSWORD Password RadioSabbelNich itself uses to push to Icecast
ICECAST_HOSTNAME Public hostname for the Icecast stream
ICECAST_PORT Host port for the raw Icecast stream (default 8000)
ICECAST_LOCATION/ICECAST_ADMIN_EMAIL Server info fields in Icecast's icecast.xml
WEBUI_PORT Host port for the web interface (default 5000)
TLS_CERT_FILE/TLS_KEY_FILE Host paths to PEM files for HTTPS (optional, see below)
ICECAST_SSL_PORT Host port for the Icecast stream over HTTPS (default 8443)
VOSK_MODEL_FOLDER Host folder with an unpacked German Vosk model for the STT speech filter (optional, see its own section)
UI_LANGUAGE Starting language of the web interface: en (base language) or the code of a language pack under language/ such as de (optional, default en โ€” see "Web interface language")

HTTPS/TLS (optional)

Without TLS_CERT_FILE/TLS_KEY_FILE, the web interface and Icecast stream keep running over plain HTTP as before โ€” not a required step.

With a certificate (e.g. generated via tailscale cert <hostname>, a .crt+.key pair):

  1. Enter both host paths in .env (TLS_CERT_FILE/TLS_KEY_FILE).
  2. docker compose up -d --build โ€” the Icecast stream then automatically gets an additional HTTPS port (ICECAST_SSL_PORT, default 8443) alongside the existing HTTP port 8000, which keeps running unchanged โ€” existing listener connections are never affected.
  3. For the web interface, additionally check the box under /config โ†’ "๐Ÿ”’ HTTPS" (or set tls_enabled in settings.json) and restart the container once. Important: unlike the stream, there is no parallel operation here โ€” once enabled, the web interface is only reachable via https://, old http:// bookmarks on port 5000 then lead nowhere.

Icecast itself has to briefly start with root privileges for this (to be able to read the 0600 certificate file) and drops them again internally afterwards โ€” details on that are in CLAUDE.md.

Deploy commands

# Rebuild + restart
docker compose up -d --build radiosabbelnich

# Follow the console (only the important events)
docker compose logs -f radiosabbelnich

# Full debug log (VAD values, fingerprint details, HTTP requests)
tail -f data/logs/radiosabbelnich.log

# Listen to fingerprint recordings (after a suspected "zap error")
ls data/fingerprint_clips/

Logging

Two destinations with different levels of detail:

  • Console (docker compose logs): only the events you want to see day-to-day โ€” station switches, fingerprint matches, warnings, errors.
  • data/logs/radiosabbelnich.log: always at DEBUG, independent of the console. Per analysis window, the VAD probability or heuristic features, every fingerprint comparison with match strength and distance to the threshold, every HTTP request to the web interface, every background buffer started/died. Rotating (5 ร— 10 MB), mounted on the host under data/logs/ โ€” so it survives container restarts.

The point of the split: if something goes wrong overnight, you want to be able to read the details afterwards, without having had to accidentally start the container in the right mode beforehand. --verbose additionally pushes the DEBUG lines to the console, --log-file "" turns the file off.

Known limitations

  • No auth on the web interface/config page โ€” see the warning above, make sure to run it behind a VPN/Tailscale.
  • Not every station delivers usable "now playing" metadata; that's up to the respective station operator.
  • Fingerprint detection is a best-effort mechanism (constellation-map hashing with 2D landmark peaks, see fingerprint.py) โ€” verified against 26 real recordings from live operation (0 false positives with clear separation from actual repeats), but occasional false positives are still never fully ruled out. That's what the "zap error" button is for.
  • "โฎ Back"/"Next โญ" during an ongoing news break: the break (deliberately, see CLAUDE.md) only knows the paused station as a virtual ID, not its position in the rotation โ€” a click during the break therefore switches to the first station in the list instead of the actual neighbor of the paused station.

Future features

Own music library & categorization (planned)

Optional mode alongside stream switching: scan a local music collection, tag it, and make it playable by category.

  • โœ… Switchable via toggle (radio mode vs. player mode, STT/VAD fully off in music mode) and a minimal player (play/stop/back/next over a configurable folder, recursive up to 5 subfolder levels since 2026-08-14, no categorization) are implemented โ€” see "Player mode (foundation)" further up.
  • โœ… Expanded format support (since 2026-08-12): scan AND playback now go beyond MP3 to FLAC, OGG (Vorbis), M4A (MP4 container), raw ADTS AAC, WAV, and APE (Monkey's Audio, text tags only โ€” see below). Playback needed no changes (ffmpeg was already format-agnostic), but metadata/cover extraction in music_scan.py did: FLAC/OGG/MP4 store cover art in completely different places (no shared mutagen API like for the text tags), WAV isn't "easy"-wrapped by mutagen (tags had to be read via the raw ID3 frames instead), and tagged raw AAC was misdetected as MP3 by mutagen's auto-detection and crashed on frame sync โ€” found and fixed against real ffmpeg-generated, mutagen-tagged test files, not just assumed from the docs (see SESSION.md). APE cover art is deliberately not extracted (no standardized field, no mutagen API for it), and APE support itself is not verified against a real .ape file since the image's ffmpeg has no encoder to create one โ€” text tags should work per the mutagen docs, but that's still unconfirmed.
  • โœ… Phase 1 implemented: recursive scan of the music collection (music_scan.py, separate from the player module music_library.py) via ID3 metadata (mutagen) โ†’ its own SQLite DB music_library.db (artist, album, title, genre, year, file path, embedded cover cached as a file if present). Manually triggered via POST /api/library/scan (GET /api/library/scan/status for polling, no cron job) โ€” deliberately without UI hookup yet in this phase, see SESSION.md. Unchanged files (same mtime+size as the last scan) are skipped on a re-scan, so re-scanning a large collection doesn't re-read every file from scratch each time.
    • Source: file server 192.168.5.101, SMB-mounted on Dockfish under /mnt/eimer/data
  • โœ… Phase 2 implemented: a lean query layer (music_query.py, modeled on Beets' query syntax but without a real parser โ€” just a handful of fixed artist/genre substring filters) hooked directly into the music player. The category/favorite buttons on /musik are now mostly functional: Queen/Pavarotti filter by artist substring, rock/klassik by genre substring (LIKE '%rock%' โ€” plain text similarity, not an exact genre mapping, so it won't catch every spelling). schnell/langsam ("fast"/"slow") are active too since phase 3 (BPM substring/range, see below). A click reuses the same POST /api/music/play route as the regular play button (an optional query body instead of a second endpoint), replaces any running playback immediately with the query results, and shows a clear message on 0 hits instead of doing nothing. Once artist/title are known (from the DB), "now playing" shows artist โ€“ title instead of just the filename โ€” on /musik AND on the player page.
  • โœ… Phase 3 (BPM part) implemented: BPM estimation (music_bpm.py, aubio instead of librosa โ€” much lighter at runtime, see CLAUDE.md for why and for a required build patch) runs in the same scan pass as the ID3 parsing (same mtime/size skip logic, only a 60s snippet gets decoded instead of the whole track: ~0.25s/track measured). schnell (โ‰ฅ120 BPM) / langsam (โ‰ค90 BPM) are fixed thresholds, anything in between falls under neither โ€” known limitation: octave errors (half/ double tempo) are a generic problem of any beat-tracking method; measured against a real 402-track collection, noticeably more tracks ended up under "fast" than would musically make sense. Energy detection/browse UI from the original phase 3 idea remain open.
  • โœ… Duplicate detection implemented (since 2026-08-12): music_query.find_duplicates() groups tracks with the same normalized artist+title pair (lowercased, whitespace trimmed) โ€” deliberately metadata-only, no audio fingerprint comparison (that would need its own analysis code like music_bpm.py, out of scope for this round by user request). Catches e.g. the same song as MP3 AND FLAC, but not audio content that's identical despite differing tags. Tracks without artist/title are excluded (otherwise untagged files would falsely show up as one giant duplicate group). Available via GET /api/library/duplicates (JSON, includes file size per hit as a decision aid) โ€” deliberately without a UI hookup and without a delete action in this phase (user's choice: report only for now), see SESSION.md. Verified against the user's real 402-track collection: found exactly one genuine duplicate group.
  • Enrichment (later building block, separate from the scan): fill in missing covers/lyrics afterwards from external sources (e.g. MusicBrainz/Cover Art Archive, lrclib.net), long-term goal: every track with cover + lyrics + category tag
  • Idea list (very long-term, unclear if it'll ever be built): a custom AI "moderator" for announcements about external events (e.g. appointments, incoming mail, doorbell events)

Tech stack: Python, mutagen, SQLite, possibly FastAPI for the query API. Reference: Beets (library manager) as inspiration for the data model/query language, not a 1:1 adoption.

iOS app (idea, not yet scheduled)

Native iOS app as a counterpart to the existing Android app (see "Android app" further up): would offer the same station control and possibly music library operation as the Android version, but built with Swift/SwiftUI and compiled via Xcode on a Mac โ€” a standalone project with its own tech stack, analogous to android-app/. Idea only so far, no timeline.

About

Spielt Radiosender via Internet Stream und zappt sobald gesabbelt wird.

Resources

Stars

8 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages