๐ฌ๐ง English version further below
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.
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.
- 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).
- Hรคlt die Sprache-Erkennung einige Sekunden am Stรผck durch, schaltet RadioSabbelNich reihum zum nรคchsten aktivierten Sender, bis wieder Musik lรคuft.
- 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).
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_COOLDOWNinradiosabbelnich.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.
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:
- Nahtloser Wechsel: ein Wechsel zu einem vorgepufferten Sender (automatisch oder manuell) รผbernimmt die schon laufende Quelle sofort, statt neu zu verbinden โ kein Reconnect-Ruckler.
- 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.
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_mp3klicken statt den Pfad einzutippen). Der eigentliche Host-Ordner wird รผberNEWS_MP3_FOLDERin.envvon auรen reingemountet (siehedocker-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 (ausNEWS_MP3_FOLDERdurchgereicht) โ 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).
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_FOLDERaus.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.
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.
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:round 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.
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:
- 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).
- 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).
- Auf "๐ต Musik-Stufe" umschalten und manuell auf einen Musiksender derselben Sprache wechseln, erneut ein paar Minuten sammeln lassen.
- 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_thresholdder 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.
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 inlogs/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.
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).
Erreichbar unter http://<host>:5000/:
- Unter dem Banner-Bild steht klein die aktuell laufende Version
(
VERSIONim Repo-Root, siehe Versionspflege inCLAUDE.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).
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.
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).
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).
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.
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).
| 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") |
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):
- Beide Host-Pfade in
.enveintragen (TLS_CERT_FILE/TLS_KEY_FILE). 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.- Fรผrs Web-Interface zusรคtzlich unter
/configโ "๐ HTTPS" den Haken setzen (odertls_enabledinsettings.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 รผberhttps://erreichbar, altehttp://-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.
# 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/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 unterdata/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.
- 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.
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.pydagegen 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-Modulmusic_library.py) รผber ID3-Metadaten (mutagen) โ eigene SQLite-DBmusic_library.db(Artist, Album, Titel, Genre, Jahr, Dateipfad, eingebettetes Cover als gecachte Datei falls vorhanden). Manueller Trigger perPOST /api/library/scan(GET /api/library/scan/statusfรผ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
- Quelle: Fileserver 192.168.1.10, per SMB auf SERVER gemountet
unter
- โ
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/musiksind 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 dieselbePOST /api/music/play-Route wie der normale Play-Knopf aus (optionalerquery-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/musikUND 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 wiemusic_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). รberGET /api/library/duplicatesabrufbar (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.
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 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.
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.
- 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).
- Once speech detection holds up for a few seconds in a row, RadioSabbelNich cycles to the next enabled station until music is playing again.
- 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).
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_COOLDOWNinradiosabbelnich.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.
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:
- Seamless switching: switching to a buffered station (automatically or manually) takes over the already-running source immediately instead of reconnecting โ no reconnect stutter.
- 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.
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_mp3instead of typing the path). The actual host folder is mounted in from outside viaNEWS_MP3_FOLDERin.env(seedocker-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 fromNEWS_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).
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_FOLDERfrom.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.
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.
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:roand 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.
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:
- 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).
- 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).
- Switch to the "๐ต Musik-Stufe" and manually switch to a music station in the same language, again let it collect for a few minutes.
- 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.
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 tologs/radiosabbelnich.logfor 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.
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).
Reachable at http://<host>:5000/:
- The currently deployed version is shown in small text below the
banner image (
VERSIONat the repo root, see version tracking inCLAUDE.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).
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.
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).
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).
| 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.
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).
| 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") |
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):
- Enter both host paths in
.env(TLS_CERT_FILE/TLS_KEY_FILE). 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.- For the web interface, additionally check the box under
/configโ "๐ HTTPS" (or settls_enabledinsettings.json) and restart the container once. Important: unlike the stream, there is no parallel operation here โ once enabled, the web interface is only reachable viahttps://, oldhttp://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.
# 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/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 underdata/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.
- 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.
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.pydid: 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.apefile 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 modulemusic_library.py) via ID3 metadata (mutagen) โ its own SQLite DBmusic_library.db(artist, album, title, genre, year, file path, embedded cover cached as a file if present). Manually triggered viaPOST /api/library/scan(GET /api/library/scan/statusfor 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
- Source: file server 192.168.5.101, SMB-mounted on Dockfish under
- โ
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/musikare 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 samePOST /api/music/playroute as the regular play button (an optionalquerybody 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/musikAND 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 likemusic_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 viaGET /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.
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.
