✅ Fertig im Sinne des Fahrplans: alle acht Phasen aus
RadioSabbelNich_Android_Fahrplan.mdsind umgesetzt und live getestet, zuletzt Phase 8 (Review + Feinschliff) am 2026-08-08. Es sind keine geplanten Ausbaustufen mehr offen - die App wird im Alltag benutzt, nicht mehr Stück für Stück aufgebaut. Was sie bewusst NICHT kann, steht vollständig unter "Bekannte Grenzen / offene Punkte" am Ende (u.a. doppelter Netzwerkverbrauch, kein HLS/DASH, keine Play-Store-Reife) - daran ändert der Fertig-Status nichts.Verlauf/Begründungen:
SESSION.mdin diesem Verzeichnis, ältere Einträge bis 2026-08-07 in../SESSION.md.
Eigenstaendige Android-App, die dasselbe Grundprinzip wie das
RadioSabbelNich-Docker-Projekt (../) auf dem Handy abbildet: Radiostream
abspielen und per Vosk (Speech-to-Text) grob erkennen, ob gerade Sprache
oder Musik laeuft, und bei Sprache automatisch weiterschalten. Kein
Web-Wrapper, keine Abhaengigkeit von der Docker-Instanz - reines
natives Kotlin/Android.
Ursprünglich bewusst minimal gestartet (drei hartcodierte Sender, kein
Watchdog/Ban-System, kein News-Break, keine Settings-UI) und dann entlang
des Feature-Parität-Fahrplans (RadioSabbelNich_Android_Fahrplan.md) auf den
heutigen Stand gewachsen: Senderverwaltung, Watchdog, Vorwärmung,
M3U-Import, Nachrichten-Pause, Audio-Fingerprinting, Song-Erkennung
(inkl. AudD-Cloud-Lookup) und mehrsprachiges STT inklusive
Kalibrierungs-Wizard - siehe Feature-Liste unten. Weiterhin
bewusst kein Ban-System, das eine Sperre über einen App-Neustart hinweg
merkt (siehe "Bekannte Grenzen").
- Persistente Senderverwaltung (
model/StationRepository.kt, Vorbildstations_store.pyim Docker-Projekt) - Sender als{id, name, url, category, enabled}, gespeichert als JSON-Datei (filesDir/ stations.json), nicht mehr im Code hartcodiert. Eigener Bildschirm "⚙️ Sender verwalten" (station/StationManagementActivity.kt): Liste gruppiert nach Kategorie (Lokal/National/International/Unsortiert, feste Reihenfolge, auch leere Kategorien sichtbar), Enabled-Checkbox pro Sender, Bearbeiten/Loeschen-Buttons, ein wiederverwendeter Add/Edit-Dialog. Mindestens 1 Sender muss bestehen bleiben (Loeschsperre, exakte Paritaet zum Docker-Vorbild). Rotation = aktivierte Sender alphabetisch nach Name, wie im Docker-Projekt. Beim allerersten Start (keinestations.jsonvorhanden) werden die bisherigen 3 Sender (Deutschlandfunk/1LIVE/SWR3) als Startbestand geschrieben statt geloescht - fuer Bestandsnutzer aendert sich beim Update auf diese Version sichtbar nichts. - Wiedergabe ueber ExoPlayer (media3 1.4.1)
- Mehrsprachiges STT (
stt/SttSettings.kt,vosk/VoskModelManager.kt,vosk/VoskModelCache.kt, siehe eigener Architektur-Abschnitt unten) - eigener Bildschirm "🌐 STT-Sprachen verwalten": beliebig viele Sprachen als Freitext (Code + Vosk-Modell-Download-URL, kein festes Dropdown), jede einzeln herunterladbar/loeschbar (mindestens eine muss bleiben). Zuordnung Kategorie → Sprache (nicht Sender → Sprache, wie im Docker-Projekt) ueber eine zweite Sektion "🏷 Kategorie-Sprachen" (ein Dropdown pro fester Kategorie). Vorbelegt mit "de" und derselben Modell-URL wie bisher (vosk-model-small-de-0.15, ~45MB) - bestehende Installationen mit bereits heruntergeladenem Modell brauchen keinen Migrationsschritt. Fehlt fuer die aktuelle Kategorie ein heruntergeladenes Modell, zeigt die Startseite einen Hinweis ("⚠ Kein Modell für „…" – Analyse pausiert.") statt automatisch zu wechseln. Kalibrierungs-Wizard (stt/CalibrationActivity.kt, "Kalibrieren"-Button pro heruntergeladener Sprache): erzwingt die gewaehlte Sprache fuer den gerade laufenden Sender, sammelt beim Antippen von "🗣 Das ist Sprache"/"🎵 Das ist Musik" den Live-Rohwert (StreamAnalyzer.speechRatioSamples, ein Wert pro 0.5s- Haeppchen) in zwei Listen und schlaegt darausratioToConfirmSpeech/ratioToConfirmMusicfuer diese Sprache vor- live neu berechnet bei jedem neuen Sample, mit Warnung statt Vorschlag, falls sich Sprache-/Musik-Samples noch ueberlappen. Automatisches Umschalten bleibt waehrend einer Session aus, Sender koennen aber jederzeit ueber die Startseite gewechselt werden. Die Session endet erst mit "Fertig"/Zurueck (nicht schon beim Wegwischen des Bildschirms) - solange weist die Startseite ausdruecklich darauf hin, dass das automatische Zappen gerade aus ist.
- Parallele Analyse: eine zweite, unabhaengige Dekodierung desselben
Streams (MediaExtractor/MediaCodec) wird auf 16kHz-Mono resampelt und
laufend in Vosk (
Recognizer.acceptWaveForm) gefuettert; erkannter Text (nicht-leer) = Sprache-Signal fuer den jeweiligen 0.5s-Chunk - Geglaetteter Status (
StreamAnalyzer.kt): gleitendes Mehrheitsvotum ueber die letztenSMOOTHING_WINDOW_SECONDS=4.0Sekunden statt Einzel-Chunk-Anzeige, mit Hysterese (ratioToConfirmSpeech/ratioToConfirmMusic, Defaults 0.65/0.30 - seit Phase 7 pro Sprache konfigurierbar und ueber den Kalibrierungs-Wizard messbar, siehe unten) gegen Flackern nahe der 50%-Grenze. Ersetzt eine fruehere strikte "N Sekunden ohne Unterbrechung"-Serie, die bei ganz normalen kurzen Sprechpausen zu haeufig zurueckgesetzt wurde (live beobachtet). - Automatisches Umschalten (
PlaybackService.kt): bei bestaetigter Sprache springt die App zum naechsten Sender im Ring - wie das Docker-Projekt schaltet sie WEG von Sprache/Moderation, HIN zu Musik. Der Ring liestStationRepository.activeStations()bei jedem Versuch frisch (kein Request/Pop-Mechanismus wie im Docker-Projekt noetig, siehe Architektur-Abschnitt unten). Cooldown pro Sender (STATION_COOLDOWN_SECONDS=60): ein wegen Sprache verlassener Sender wird beim Ringdurchlauf fuer diese Zeit uebersprungen, statt sofort wieder dran zu sein (Moderation/Gesang ist ja vermutlich noch nicht vorbei) - endet automatisch mit der Zeit ODER sobald der Sender selbst wieder Musik bestaetigt. Obergrenze gegen Endlosschleife: sind entweder alle Sender einmal ohne Treffer probiert ODER alle uebrigen gerade im Cooldown, folgt eine kurze Pause (AUTO_SWITCH_PAUSE_SECONDS=20) statt endlos weiterzuspringen. Reagiert auch auf Aenderungen aus der Verwaltungs-Activity: wird der GERADE LAUFENDE Sender dort deaktiviert oder geloescht, schaltet der Service automatisch auf den ersten aktivierten Sender weiter (oder stoppt sauber, falls keiner mehr aktiv ist) - live getestet, siehe unten. - Watchdog gegen tote/nicht antwortende Sender (
PlaybackService.kt, Vorbilddead_until/alive_stations()im Docker-Projekt) - einPlayer.Listeneran ExoPlayer erkennt zwei Faelle:onPlayerError()(hartes Signal, sofortige Sperre) und ununterbrochenesPlayer.STATE_BUFFERINGlaenger alsBUFFERING_TIMEOUT_SECONDS=15(deckt "verbindet, liefert aber nie Daten" ab, das nie einen Error ausloest). Eigene Sperr-Map (deadUntil,STATION_DEAD_LOCK_SECONDS= 300= 5 Min., wie im Docker-Projekt), bewusst getrennt vom Sprache-Cooldown oben (StationLockReason.SPEECH_COOLDOWNvs..DEAD) - beide Sperrgruende sind in der Play-Liste unterscheidbar ("⏸ Pause wegen Sprache" / "⚠ Antwortet nicht"). Sind ALLE aktiven Sender durch irgendeinen der beiden Gruende gesperrt, werden beide Sperrlisten geleert und einmal neu versucht, statt haengenzubleiben (wiedead_until.clear()im Docker-Projekt). Manuelle Sender-Wahl (manualPlay()) hebt beide Sperren fuer den gewaehlten Sender auf - expliziter Nutzerwunsch schlaegt Automatik. - Foreground Service mit Notification, damit Wiedergabe+Analyse weiterlaufen, wenn die App im Hintergrund ist; UI zeigt den aktuellen Sender live mit, auch wenn der automatische Wechsel ihn geaendert hat
- Einfache UI: Senderliste mit Play-Buttons (nur aktivierte Sender, flach, kein Kategorie-Gruppieren - das ist Aufgabe der Verwaltungs-Activity), Stop-Button, Statusanzeige, Buttons "Sender verwalten"/"STT-Sprachen verwalten"
- Optik an das Web-Interface angeglichen (
MainActivity.kt): Banner-Bild (dieselbe Datei wie im Web-Interface) und Türkis-Akzentfarbe (#1ABC9C). Live-Balken "🤥 Bullshitometer" unter den Steuerbuttons zeigt die rohe Sprache-Wahrscheinlichkeit (StreamAnalyzer.speechRatio, VOR der Hysterese) mit demselben grün→rot-Farbverlauf wie im Web. Button "⚡ ZAPPEN!" neben "■ Stopp" fuer den manuellen Sofort-Wechsel (ruft dieselbe Ring-Logik wie ein automatisch erkannter Sprache-Treffer auf). Kein separater STT-Meter (Android hat nur einen Detektor, kein VAD+STT- Kombi wie das Docker-Projekt). Chip "🔎 Fingerprint" zeigt das letzte Fingerprint-Ereignis (siehe unten). - Vorgewärmter Kandidat für lückenlosere Wechsel (siehe eigener Architektur-Abschnitt unten) - ein zweiter, paralleler ExoPlayer hält immer den laut Ringlogik wahrscheinlichsten nächsten Sender bereits vorbereitet. Trifft ein Wechsel (automatisch, ZAPPEN! oder Watchdog) diesen Kandidaten - der Regelfall - läuft er praktisch ohne Verbindungslücke statt mit dem bisherigen Kaltstart.
- Sender-Import aus einer M3U-Playlist (
importer/StationImporter.kt, Vorbildstation_import.py) - Textfeld "Sender-Import" in der Verwaltungs-Activity, vorbelegt mit derselben Default-Playlist wie im Docker-Projekt (Kodinerds-Kodi-Radioliste,http://bit.ly/kn-kodi-radio, frei aenderbar). Laedt die Playlist, parst#EXTINF-Eintraege, filtert gegen bereits vorhandene Sender (Name/URL) UND Duplikate innerhalb der Playlist selbst, uebernimmt den Rest deaktiviert in die Kategorie "Unsortiert" - genau wie beim Docker-Projekt landet ein Import nie ungefragt in der laufenden Rotation. Bewusst OHNE Erreichbarkeitspruefung beim Import selbst (siehe "Architektur in Kuerze" unten fuer die Begruendung). Dafuer ein separater Button "🔍 Unsortierte Sender pruefen" (importer/StationReachabilityChecker.kt), der genau diese Pruefung fuer alle Sender in "Unsortiert" nachtraegt und nicht erreichbare Sender mit einem Badge "⚠ nicht erreichbar" markiert - rein informativ, es wird nichts automatisch geloescht. - Nachrichten-Pause / News-Break (
newsbreak/NewsBreak.kt,newsbreak/NewsBreakSettings.kt, siehe eigener Architektur-Abschnitt unten) - zur vollen/halben Stunde spielt statt des Radiosenders fuer ein konfigurierbares Zeitfenster (Default 2 Minuten, wie im Docker-Projekt) eine zufaellige MP3 aus einem selbst gewaehlten Ordner (Storage Access Framework). Neue Sektion "📰 Nachrichten-Pause" auf der Startseite: Aktiviert-Schalter, "📁 Ordner waehlen", Fensterlaenge in Minuten. Mehrere Dateien pro Fenster werden nachgeladen, bis das Fenster um ist (nicht nur eine, siehe Docker-Projekt-Historie), dieselbe Datei kommt nicht direkt zweimal hintereinander. Danach automatische Rueckkehr zum vorher laufenden Sender - ein manueller Sendertipp oder "⚡ ZAPPEN!" waehrend der Pause beendet sie sofort. Zwei optionale Zusatzfeatures (Android-Pendants zu den gleichnamigen Docker-Features, siehe dessenARCHITECTURE.md), beide Default AUS, in derselben Sektion:- Sprache-Gate: die Pause startet nicht mehr rein zeitbasiert, sondern erst, wenn zusaetzlich zum normalen Zeitfenster ein engeres Toleranzfenster (Minuten) erreicht UND eine konfigurierbare Sprache-Serie (Sekunden) am Stück erkannt wurde - verhindert einen Start mitten in laufender Musik.
- Werbeblock-Vorbuffering: in den letzten X Sekunden der laufenden Pause-MP3 verbindet die App im Hintergrund schon stumm mit dem pausierten Sender. Ist er beim Pause-Ende erkennbar schon wieder bei Musik, wird diese bereits laufende Verbindung direkt uebernommen statt neu zu verbinden (kein Vorspulen - geht bei Livestreams nicht -, sondern doppelte Nutzung der ohnehin verstreichenden Pause-Zeit).
- Audio-Fingerprinting (
fingerprint/Fingerprint.kt,fingerprint/FingerprintDb.kt, Vorbildfingerprint.py- siehe eigener Architektur-Abschnitt unten fuer die volle Herleitung) - erkennt wiederkehrende Jingles/Werbespots per Constellation-Map-Verfahren (echte 2D-Landmarken in Zeit UND Frequenz, NICHT "lautester Bin pro Frame" - siehe Docker-Projekt-Historie fuer den Grund) auf dem ohnehin laufenden 16kHz-Analysestrom, kein zweiter Decode-Pfad. Bei Wiedererkennung sofortiger Wechsel (dieselbe Ring-Logik wie ein Sprache-Treffer), sonst wird der Clip in einer lokalen SQLite-DB gelernt. Chip "🔎 Fingerprint" zeigt das letzte Ereignis, Button "🛑 Zapping-Fehler" nimmt einen fälschlichen Treffer zurück (Clip aus der DB werfen, kein automatisches Umschalten rückgängig machen). - Song-Erkennung (
songfingerprint/, Vorbildsong_fingerprint.pyim Docker-Projekt - siehe eigener Architektur-Abschnitt unten) - erkennt wiederholte Musikstücke lokal (Adaption desselben Constellation-Map- Verfahrens wie beim Audio-Fingerprinting oben, eigene DBsong_fingerprints.db) und identifiziert unbekannte optional per AudD-Cloud-Lookup (Titel/Interpret/Album/Jahr/Länge - Länge kommt aus zusätzlich angeforderten Spotify-/Apple-Music-Daten, AudDs Kernantwort liefert sie nicht). Cloud-Lookup ist ein eigener, standardmäßig AUS-geschalteter Schalter ("Identify unknown songs via AudD") + Textfeld für den API-Token auf der Startseite, ganz unabhängig von der immer laufenden lokalen Erkennung. Chip "🎵 Song" zeigt den aktuell erkannten Song (inkl. Länge in Klammern, z.B. "Artist – Titel (3:17)", falls bekannt), sonst "🔍 noch nicht erkannt" (läuft ein Sender, aber noch kein Titel bekannt) oder "–" (nichts läuft) - analog zur gleichnamigen Debug-Anzeige im Docker-Web-Interface, ohne deren dritten "pausiert (keine Hörer)"-Zustand (kein Icecast-Publikum auf Android). Button "🗑 Song-DB leeren" setzt die Erkennung zurück. - "⏭ Andere Pause-MP3"-Knopf (
PlaybackService. manualNewsBreakSkip()) - nur während einer laufenden Nachrichten- Pause aktiv, wählt eine andere zufällige MP3 aus demselben Ordner, die Pause selbst läuft weiter (anders als "⚡ ZAP!", das die Pause komplett beendet). WiederverwendetplayNextNewsBreakFile()unverändert - dieselbe Funktion, die auch beim natürlichen Dateiende die nächste MP3 wählt, inklusive der dort schon eingebauten Wiederholungs-Vermeidung. - Build-Zeitstempel in der UI (
Build: YYYY-MM-DD HH:MMdirekt unter dem App-Titel,BuildConfig.BUILD_TIME) - entsteht automatisch bei jedem Build. Zweck: von aussen erkennbar, ob eine gerade installierte APK noch ein aelterer Stand ist (Anlass: Auto-Switch schien auf einem Test-Handy "nicht zu funktionieren" - war eine veraltete Installation). - Update-Mechanismus mit konfigurierbarer Adresse (
update/ UpdateManager.kt, siehe eigener Abschnitt unten) - Textfeld "Update- Server:" auf der Startseite (Defaulthttps://blarks.de/ radio/update, oeffentlich erreichbar, kein VPN noetig). Button "Nach Update suchen" prueft den dort eingetragenen Server, laedt bei Bedarf die neue APK und stoesst den System-Installer an. Kein Play Store, keine Signatur-Pruefung ueber die Debug-Signierung hinaus. - Mehrsprachige UI (Englisch Default, Deutsch auf deutschsprachigen
Geraeten) - Standard-Android-Ressourcenmechanismus (
res/values/= Englisch,res/values-de/= Deutsch), automatische Auswahl nach Geraete-Locale, kein manueller Umschalter in der App noetig.
Deutschlandfunk (Sprache) gestartet → nach ~15s bestaetigt "Sprache"
erkannt → automatisch zu 1LIVE gewechselt → dort ueber 1 Minute stabil
"🎵 Musik", kein Nachflackern, kein weiteres Springen. Keine Abstuerze/
Exceptions im Logcat. Details und weitere Durchlaeufe siehe
../SESSION.md, Eintrag "2026-08-07 — Android RadioSabbelNich MVP".
Zweiter Durchlauf (nach Ergaenzung des Build-Zeitstempels, gleicher
Emulator): Deutschlandfunk → 1LIVE → SWR3 → Deutschlandfunk → 1LIVE →
SWR3 im Kreis, jeweils nach "Sprache erkannt" - alle drei Sender lieferten
also irgendwann einen bestaetigten Sprache-Treffer (bei 1LIVE/SWR3
vermutlich Gesang, siehe "Bekannte Grenzen" unten), der Zaehler wurde
aber zwischendurch durch Musik-Phasen immer wieder auf 0 zurueckgesetzt,
bevor die Obergrenze (AUTO_SWITCH_PAUSE_SECONDS) je griff. Zeigt: die
Umschalt-Logik selbst funktioniert zuverlaessig, das haeufige Springen in
diesem Fall kam von echtem Radioinhalt (Musik+Moderation gemischt), nicht
von einem Bug.
Dritter Durchlauf (Senderverwaltung, frischer Install): die 3
geseedeten Sender erscheinen identisch zu vorher unter "National" in
beiden Bildschirmen. Neuer Sender ("SWR3 Test", Kategorie "International")
per Dialog angelegt → erscheint sofort in der flachen Play-Liste UND in
der Verwaltungs-Activity, adb shell run-as com.radiozapper.mvp cat files/stations.json zeigt korrektes, menschenlesbares JSON mit
generierter id swr3-test. 1LIVE gestartet, waehrend es lief in der
Verwaltungs-Activity deaktiviert → Logcat: "Senderliste geaendert, '1LIVE
...' nicht mehr aktiv - schalte auf 'Deutschlandfunk ...'" - automatischer
Wechsel funktioniert. Sender geloescht bis auf einen einzigen uebrig →
Loeschversuch auf dem letzten liefert korrekt "Mindestens ein Sender muss
konfiguriert bleiben." statt die Liste zu leeren. App per force-stop
beendet und neu gestartet → aller Stand (inkl. des angelegten Testsenders)
persistiert korrekt. Keine Abstuerze in der gesamten Sequenz (Crash-Log-
Buffer nach jedem Schritt leer geprueft).
Vierter Durchlauf (Watchdog): Sender mit nicht routbarer IP ueber die
Verwaltungs-Activity angelegt, abgespielt → nach exakt
BUFFERING_TIMEOUT_SECONDS (15s) Logcat "Kein Fortschritt seit 15s -
'TOT-Test' antwortet nicht" → automatischer Wechsel zum naechsten Sender,
Play-Liste zeigt "⚠ Antwortet nicht" unter dem toten Sender. Manuelles
erneutes Antippen hebt die Sperre sofort auf (Badge verschwindet,
"Verbinde…" startet neu), nach weiteren 15s erneut korrekt gesperrt.
Eskalationstest: BEIDE aktiven Sender auf tote URLs gesetzt → Logcat
"Alle 2 aktiven Sender gesperrt - hebe beide Sperrlisten auf und probiere
erneut" statt Haengenbleiben - danach stabiler ~15s-Zyklus zwischen beiden
(weiterhin toten) Sendern, kein Absturz (erwartetes Verhalten, wenn
tatsaechlich alles tot ist). Regressionscheck: URL zurueckgesetzt, 25s
stabil "🎵 Musik", kein Fehlalarm auf einem gesunden Sender.
- Kein Ban-System, das eine Sperre ueber einen App-Neustart hinweg merkt (der Watchdog unten ist reines In-Memory-Timing, siehe "Bekannte Grenzen")
- Kein Play-Store-taugliches Icon (einfaches Vektor-Icon)
- Keine Fehlerbehandlung fuer jeden Edge Case (z.B. Stream ohne erkennbaren Audio-Track, Wechsel des Mobilfunknetzes mitten im Stream)
Fertige Debug-APK liegt nach jedem Build zusaetzlich an einem festen,
leicht auffindbaren Pfad (siehe ../CLAUDE.md, Abschnitt
"Android-Prototyp"):
android-app/radiosabbelnich.apk
(daneben weiterhin auch unter dem von Gradle erzeugten
app/build/outputs/apk/debug/app-debug.apk - identischer Inhalt, aber
tief verschachtelt und gitignored).
Per USB (ADB, falls Entwickleroptionen aktiv sind):
adb install -r android-app/radiosabbelnich.apkAlternativ die APK-Datei per Kabel/Cloud aufs Handy kopieren und dort oeffnen - Android fragt dann nach der Berechtigung "Installation aus unbekannten Quellen erlauben" fuer die jeweilige App (Dateimanager/ Browser), aus der heraus installiert wird.
Beim ersten Start fragt die App nach der Notification-Berechtigung (Android 13+, sonst erscheint keine Notification und der Foreground Service kann vom System eher beendet werden) und zeigt den Button zum Modell-Download an - erst danach liefert die Sprache/Musik-Erkennung etwas (ohne Modell laeuft nur die Wiedergabe, Status bleibt "Gestoppt").
Fehlermeldung "Es liegt ein Problem mit der App-Datei vor" beim
Installieren, obwohl der Download vorher erfolgreich war: fast immer
ein Signatur-Konflikt mit einer bereits auf dem Gerät vorhandenen
älteren/andersartig installierten Version derselben App (z.B. eine ganz
frühe Testinstallation von einem anderen Rechner mit einem anderen
Debug-Schlüssel) - Android akzeptiert dann kein Update, meldet das aber
auf vielen Geräten nur als diese unspezifische Datei-Fehlermeldung statt
eines klaren Signatur-Hinweises. Live so aufgetreten und bestätigt (siehe
SESSION.md, 2026-08-08): App komplett deinstallieren, danach neu
installieren (nicht als Update) behebt es zuverlässig. Voraussetzung
für künftige nahtlose Updates ist danach derselbe Debug-Signierschlüssel
über die Zeit (~/.android/debug.keystore auf dem Build-Host, siehe
"Update-Mechanismus" unten).
cd android-app
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk radiosabbelnich.apk # lokal, adb install
STAMP=$(date '+%Y%m%d-%H%M%S')
APK_NAME="radiosabbelnich-${STAMP}.apk"
cp radiosabbelnich.apk "$APK_NAME"
echo "{\"buildTime\": \"$(date '+%Y-%m-%d %H:%M')\", \"apkFile\": \"$APK_NAME\"}" > version.json
scp "$APK_NAME" version.json strato:/srv/www/blarks.de/radio/update/
rm "$APK_NAME"
# Zusaetzlich unter festem Namen hochladen -- NICHT fuer UpdateManager
# (der liest ausschliesslich version.json/apkFile, siehe unten), sondern
# als stabiles Ziel fuer den QR-Code im Haupt-README, der sonst nach
# jedem Build auf eine veraltete Zeitstempel-Datei zeigen wuerde.
scp radiosabbelnich.apk strato:/srv/www/blarks.de/radio/update/radiosabbelnich-latest.apkDer Upload versorgt den Update-Mechanismus (siehe eigener Abschnitt unten) mit einem aktuellen Versions-Stempel und der passenden Datei - ohne ihn denkt die App weiterhin, der alte Stand sei der neueste, bzw. findet gar keine neue Datei zum Herunterladen.
Benoetigt Android SDK (local.properties mit sdk.dir=... oder
ANDROID_HOME/ANDROID_SDK_ROOT gesetzt) sowie Internetzugriff beim
ersten Build (Gradle-Wrapper-Distribution + Abhaengigkeiten aus
google()/mavenCentral()).
Fuer echte Laufzeit-Tests (nicht nur Kompilieren) steht auf diesem Host
seit 2026-08-07 ein Android-Emulator zur Verfuegung (~/Android/Sdk,
AVD test_device, API 34 google_apis x86_64, headless/ressourcenschonend
konfiguriert). Kurzform:
emulator -avd test_device -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect &
adb wait-for-device
adb install -r radiosabbelnich.apk
adb logcat -s PlaybackService:* # zeigt jeden Sprache/Musik-WechselRadioSabbelNichApplication.kt- einziger Zweck:StationRepository.init()garantiert VOR jeder Komponente aufrufen (Application.onCreate()laeuft immer zuerst) - kein "wer zuerst dran ist, ruft init() auf"-Muster in den einzelnen Komponenten, das ein neuer Einstiegspunkt leicht vergessen koennte.model/Station.kt-data class Station(id, name, url, category, enabled).idist stabil (einmal vergeben, ueberlebt Umbenennungen), Rotation/Cooldown referenzieren ausschliesslich darueber.model/Categories.kt- feste Kategorienliste (Lokal/National/International/Unsortiert, reduziertes Set ggue. den 7 Kategorien des Docker-Projekts), selbst weiterhin nur im Code aenderbar.model/StationRepository.kt- Singleton, haelt die Senderliste alsStateFlow<List<Station>>, persistiert als JSON-Datei (filesDir/ stations.json, atomarer Schreibvorgang viaFiles.move(..., ATOMIC_MOVE)). CRUD (addStation/updateStation/setEnabled/deleteStation), ID-Vergabe per Slugify+Kollisionssuffix (analogstations_store.pys_slugify/_unique_id), Startbestand nur wenn die Datei noch nicht existiert.station/StationManagementActivity.kt- eigener Bildschirm fuer CRUD, kategorie-gruppierte Liste (volles Neu-Aufbauen bei jeder Aenderung, kein RecyclerView - Datenmenge klein), Add/Edit-AlertDialog, plus die Import-/Check-Sektion (siehe eigener Abschnitt unten).importer/StationImporter.kt- laedt/parst eine M3U-Playlist und uebernimmt neue Sender perStationRepository.bulkAdd()(siehe eigener Abschnitt unten fuer Details).importer/StationReachabilityChecker.kt- separater, manuell ausgeloester Erreichbarkeits-Check fuer Sender der Kategorie "Unsortiert" (siehe eigener Abschnitt unten).vosk/VoskModelManager.kt- Download+Entpacken des Vosk-Modells nachfilesDir, Fortschritt alsStateFlow<ModelState>playback/StationLockReason.kt- Enum{SPEECH_COOLDOWN, DEAD}fuer die beiden unterscheidbaren Sperrgruende (siehePlaybackService).playback/PlaybackService.kt- Foreground Service, haelt ExoPlayer (Wiedergabe, mitC.WAKE_MODE_NETWORK- nutzt die laengst deklarierteWAKE_LOCK-Berechtigung, damit bei ausgeschaltetem Display nicht die CPU schlafen geht) UNDStreamAnalyzer(Analyse); reagiert auf den geglaetteten Status mit automatischem Umschalten, auf Aenderungen ausStationRepository.stations(siehe oben) UND auf ExoPlayer-Fehler/ Buffering-Timeouts (Watchdog, siehe eigener Abschnitt unten); exponiertcurrentStation/status/speechRatio/lockedStationsals StateFlows fuer die UI;manualSkip()fuer den "⚡ ZAPPEN!"-Button (ruft intern dieselbeattemptAutoSwitch()-Logik wie ein automatisch erkannter Sprache-Treffer auf); Notification viaNotificationCompat/ServiceCompat.startForegroundanalysis/StreamAnalyzer.kt- eigene MediaExtractor/MediaCodec- Dekodierung desselben Stream-URLs nur fuer die Analyse (unabhaengig vom ExoPlayer der Wiedergabe), Downmix+Resample auf 16kHz-Mono (MonoResampler.kt), Fuetterung von Vosk, gleitendes Mehrheitsvotum mit Hysterese fuer den geglaetteten Sprache/Musik-Status. Der rohe Anteil VOR der Hysterese (speechRatio) wird zusaetzlich als eigenerStateFlow<Double?>exponiert - Basis fuers "🤥 Bullshitometer" in der UI (null= idle/noch kein volles Glaettungsfenster).MainActivity.kt- bindet an den Service, zeigt Senderliste (reaktiv ausStationRepository), Status, Bullshitometer und Modell-Fortschritt live an, Buttons zur Verwaltungs-Activity und fuer ZAPPEN!/Stopp
Das Docker-Projekt (../CLAUDE.md, Abschnitt "Audio-Pfad") nutzt einen
einzigen ffmpeg-Prozess mit zwei Ausgabe-Pipes (Wiedergabe + Analyse aus
derselben Dekodierung). Dieses MVP macht das bewusst NICHT nach: ExoPlayer
und StreamAnalyzer oeffnen unabhaengig voneinander je eine eigene
Verbindung zum selben Stream-URL und dekodieren beide komplett selbst.
Vorteil: kein Eingriff in ExoPlayers internen Audio-Pfad noetig (kein
custom AudioProcessor/RenderersFactory), deutlich weniger
Fehlerflaeche. Preis: der Stream laeuft effektiv doppelt ueber das Netz
(Mobilfunk-Datenvolumen!). Falls das MVP weitergefuehrt wird, waere das
Anzapfen des ExoPlayer-Audio-Pfads via custom AudioProcessor der
naheliegende naechste Schritt.
StationRepository speichert als flache JSON-Datei (org.json, schon
anderswo in der App genutzt, keine neue Dependency), bewusst nicht als
Room-Datenbank. Begruendung: das Vorbild stations_store.py ist selbst
ein flacher JSON-Store, keine SQL-Datenbank - Konsistenz zum Vorbild und
Angemessenheit an die Datenmenge (Dutzende Sender, keine Relationen,
keine komplexen Queries) zeigen in dieselbe Richtung. Room brächte einen
Compiler/KSP-Dependency, Entity/DAO-Boilerplate und Migrations-
Versionierung - fuer diese Groessenordnung unverhaeltnismaessig. Anders
als stations_store.py (muss auf direktes Schreiben ausweichen, weil
stations.json dort einzeln in Docker gebindmountet ist und
os.replace() ueber einen Mountpoint mit "Device or resource busy"
scheitert) kann die Android-Variante echt atomar schreiben: Temp-Datei
im selben Verzeichnis + Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)
filesDirist normaler lokaler Speicher ohne diese Einschraenkung, eine bewusste Verbesserung gegenueber dem Original, keine blinde Kopie.
Vorbild: dead_until/alive_stations() im Docker-Projekt (siehe dessen
CLAUDE.md, "Watchdog gegen tote Sender"), technisch aber an ExoPlayer
angepasst statt 1:1 uebersetzt. Zwei Erkennungswege, beide in
PlaybackServices Player.Listener:
onPlayerError()- hartes Signal, sperrt sofort. Kein eigener Reconnect-Zaehler wieSTREAM_FAILURE_LIMITim Docker-Projekt: ExoPlayer versucht bei transienten Fehlern bereits intern zu reconnecten, bevor der Callback ueberhaupt feuert.- Ununterbrochenes
Player.STATE_BUFFERINGlaenger alsBUFFERING_TIMEOUT_SECONDS=15- deckt "verbindet, liefert aber nie Daten" ab, das nie einen Error ausloest (der eigentliche BBC-Radio- Scotland-Fall aus dem Docker-Projekt: DASH-Manifest technisch gueltig, aber dauerhaft leer). Timer startet bei Eintritt inSTATE_BUFFERING, bricht bei jedem anderen Zustand ab - deckt automatisch auch die anfaengliche "Verbinde…"-Phase mit ab. - NICHT mehr am Watchdog:
StreamAnalyzers eigenesPlaybackStatus.ERRORhaengte bis zum Phase-8-Review ebenfalls an diesem Mechanismus ("praktisch kostenlose zweite Bestaetigung"). Das war falsch - ein Analyse-Fehler heisst "die Analyse konnte nicht laufen" (nicht ladbares Vosk-Modell, Container, denMediaExtractornicht versteht, kein Audio-Track), nicht "der Sender ist tot". Bei einem kaputten Modell traf das in Sekunden jeden Sender der Kategorie, bis alle gesperrt waren und die Eskalation in eine Dauer-Schnellrotation lief. Analyse-Fehler laufen jetzt ueberhandleAnalyzerError(), siehe eigener Abschnitt unten.
Eigene Sperr-Map deadUntil (STATION_DEAD_LOCK_SECONDS=300, 5 Min. wie
im Docker-Projekt), bewusst getrennt von stationCooldownUntil (Sprache-
Cooldown) - StationLockReason haelt beide Gruende unterscheidbar,
sowohl fuer die Sperr-Logik als auch fuer die UI. Eine gemeinsame Auswahl-
Funktion (nextAvailableStation()/findNextOrEscalate()) behandelt
beide Sperrgruende einheitlich, inkl. der "alle gesperrt"-Eskalation
(beide Maps leeren statt haengenzubleiben, wie dead_until.clear()).
Manuelle Sender-Wahl (manualPlay(), von MainActivity statt play()
aufgerufen) hebt beide Sperren fuer den gewaehlten Sender auf.
Seit dem Phase-8-Review (siehe SESSION.md, Review-Befund 2): der
StreamAnalyzer meldet eine Klartext-Ursache ueber analyzerError, statt
ueber PlaybackStatus.ERROR den Watchdog auszuloesen. PlaybackService
startet die Analyse daraufhin bis zu ANALYZER_MAX_RETRIES=3-mal im Abstand
von ANALYZER_RETRY_SECONDS=15 neu (ein abgerissener Analyse-Stream faengt
sich damit von selbst wieder). Danach laeuft der Sender bewusst OHNE
Sprach-/Musik-Erkennung weiter, und die Startseite zeigt "⚠ Analyse gestoppt:
… (Wiedergabe laeuft weiter)". Wiedergabe und Sperr-Logik bleiben davon
unberuehrt - ueber "Sender tot" entscheidet ausschliesslich der ExoPlayer
(Fehler-Callback bzw. Buffering-Timeout, siehe Watchdog-Abschnitt oben).
Zusaetzlich hat der Analyzer eine laufende Nummer (generation): ein durch
stop()/start() abgeloester Lauf kann seine blockierenden MediaCodec-/
MediaExtractor-Aufrufe nicht sofort abbrechen und laeuft noch kurz aus -
Status, Fehler und Fingerprint-Treffer eines solchen Nachzueglers werden
verworfen, damit sie nicht dem laengst laufenden naechsten Sender
zugeschrieben werden.
Phase 3 aus dem Fahrplan. Bis dahin machte jeder Wechsel in play() einen
kompletten Kaltstart (player.stop() → neue MediaItem → prepare() →
Buffering), spürbare Lücke von 1-3s. Bewusst KEIN Pool mehrerer
vorgewärmter Kandidaten wie PrebufferedSource/prebuffer_count im
Docker-Projekt (siehe dessen CLAUDE.md) - der Ressourcenverbrauch
mehrerer gleichzeitiger Dauerstreams stünde für die auf dem Handy erwartete
Sendermenge in keinem Verhältnis zum Zusatznutzen gegenüber der
einfacheren Variante unten.
refreshPreload() bereitet über die bereits bestehende
nextAvailableStation()-Funktion (siehe Watchdog-Abschnitt oben) immer
GENAU den Sender vor, den die Ringlogik gerade als nächsten wählen würde -
ein zweiter, paralleler ExoPlayer (preloadedPlayer) puffert im
Hintergrund (playWhenReady=false). Rein reaktiv aufgerufen (kein
Polling-Timer wie sync_prebuffer() im Docker-Projekt), an den drei
Stellen, an denen sich einer der Einflussfaktoren ändert: aktueller Sender
(Ende von play()), Sperren (Ende von refreshLockedStationsSnapshot() -
deckt dadurch alle deren Aufrufer ab) und Senderliste (Ende von
handleStationListChanged()).
Da attemptAutoSwitch() (automatisch UND der ZAPPEN!-Button) sowie
handlePlaybackFailure() (Watchdog) exakt dieselbe Auswahl-Logik
verwenden, trifft play() in der überwiegenden Mehrheit der Fälle genau
diesen vorgewärmten Kandidaten und übernimmt ihn (Player-Instanzen tauschen,
alter Player wird released) statt eines Kaltstarts. manualPlay() (Sender
in der Liste antippen) profitiert nur zufällig, wenn der angetippte Sender
zufällig der vorhergesagte ist.
Randfall, beim Testen live aufgetreten: ein Player.Listener feuert
onPlaybackStateChanged nur bei KÜNFTIGEN Zustandswechseln, nicht
rückwirkend für den Zustand zum Zeitpunkt von addListener(). War der
übernommene Kandidat beim Wechsel noch nicht STATE_READY, würde der
BUFFERING_TIMEOUT_SECONDS-Watchdog nie zu laufen anfangen - die
Timer-Start-Logik ist deshalb in eine eigene armBufferingWatchdog()
extrahiert, die play() nach einer Übernahme explizit erneut aufruft,
falls der übernommene Player noch buffert. Ein eigener, minimaler
preloadFailureListener verwirft einen fehlgeschlagenen Kandidaten NUR aus
der Vorwärmung (clearPreload()) - löst bewusst NICHT den
deadUntil/Watchdog-Mechanismus aus, solange der Sender noch nicht
current ist.
Vorbild: station_import.py im Docker-Projekt (siehe dessen CLAUDE.md,
Abschnitt "Sender-Import"). StationImporter.runImport() laedt die
Playlist-URL (Default identisch zum Docker-Projekt: die Kodinerds-Kodi-
Radioliste, editierbar per Textfeld, in SharedPreferences gespeichert wie
bei UpdateManager), parst #EXTINF:...,Name + folgende URL-Zeile 1:1
nach demselben Muster wie station_import.parse_m3u, filtert Duplikate
(gegen die bestehende Senderliste UND innerhalb der Playlist selbst, je
nach Name UND URL) und uebernimmt den Rest per neuem
StationRepository.bulkAdd() (ein einziger Schreibvorgang fuer die ganze
Batch statt einem pro Sender) deaktiviert in die Kategorie "Unsortiert".
HttpURLConnection folgt Redirects nur INNERHALB desselben Protokolls
automatisch - die Default-URL (bit.ly/...) leitet per 301 von http://
auf https:// um, was beim ersten echten Test kommentarlos die
Redirect-Antwortseite statt der Playlist lieferte (siehe SESSION.md,
2026-08-08). fetchM3u() folgt Location-Headern deshalb von Hand,
protokolluebergreifend inklusive.
Bewusst OHNE den 8s-Audiofluss-Check des Docker-Vorbilds
(check_reachable()) waehrend des Imports selbst - bei einer Playlist mit
hunderten Eintraegen waere das auf dem Handy zu langsam/akkuintensiv, UND
unnoetig: importierte Sender landen ohnehin deaktiviert in "Unsortiert",
eine kaputte URL darin ist harmlos bis zur bewussten Aktivierung durch den
Nutzer (anders als im Docker-Projekt, wo der Check verhindert, dass ein
toter Sender in die LAUFENDE Rotation gelangt). Stattdessen ein separater,
manuell ausgeloester Button "🔍 Unsortierte Sender pruefen"
(StationReachabilityChecker.checkCategory(), beschraenkt auf Kategorie
"Unsortiert") mit einer eigenstaendigen MediaExtractor/MediaCodec-
Dekodierschleife (kein Vosk noetig) - konzeptionell identisch zu
check_reachable(): Wall-Clock-begrenztes Zeitfenster
(CHECK_WINDOW_MS=8000), verlangt wird Audio bis in die letzten
CHECK_TAIL_MS=3000 hinein, nicht nur "kam ueberhaupt was". Begrenzte
Parallelitaet per Kotlin-Semaphore (CHECK_CONCURRENCY=3, deutlich
weniger als die 10 im Python-Vorbild - MediaCodec-Dekodierung ist auf dem
Handy teurer, und nebenbei laeuft meist noch Wiedergabe + Vosk-Analyse im
selben Prozess). Ergebnis ist rein informativ (unreachableIds, nur
In-Memory fuer die aktuelle Sitzung) - ein Badge "⚠ nicht erreichbar" in
der Verwaltungs-Activity, kein automatisches Loeschen: das bleibt eine
bewusste Entscheidung ueber den bestehenden Loeschen-Button (ein
falsch-negativer Check, z.B. durch eine kurze Netzwerkstoerung, wuerde
sonst einen echten Sender unwiderruflich entfernen).
Vorbild: news_break.py + dessen Einbindung im Docker-Projekt (siehe
dessen CLAUDE.md, Abschnitt "Nachrichten-Pause"). newsbreak/NewsBreak.kt
ist reine Domaenenlogik (activeSlot(): naechstgelegene :00/:30-Grenze,
aktiv wenn innerhalb der Fensterlaenge; pickRandom(): Zufallsauswahl mit
RECENT_HISTORY_SIZE=3-Ausschluss) - kennt weder Android/SAF noch
PlaybackService, exakt dieselbe Trennung wie im Vorbild und aus
demselben Grund: die Audio-Umschaltung (Sender pausieren, MP3 abspielen,
zurueckschalten) gehoert in PlaybackService, wo die gesamte
Switch-Infrastruktur schon existiert.
newsbreak/NewsBreakSettings.kt haelt den SAF-Ordnerzugriff
(ACTION_OPEN_DOCUMENT_TREE, takePersistableUriPermission() fuer
Persistenz ueber App-Neustarts hinweg - neue Dependency
androidx.documentfile) und die Einstellungen (enabled/windowMinutes,
Default false/2.0 wie im Docker-Projekt). Bewusst OHNE die dortige
enabled_hours-Ruhezeiten-Option (Nutzerentscheidung - kleinere UI, passt
zum bisherigen "bewusst minimal"-Ansatz, spaeter ergaenzbar).
PlaybackService.kt haelt _currentStation waehrend einer Pause bewusst
unveraendert auf dem pausierten Sender (wie current im Docker-Projekt) -
Ringberechnung, Cooldown/Dead-Maps und die Vorwaermung (siehe oben) laufen
dadurch mit korrekten Daten weiter, ohne einen einzigen Sonderfall dafuer
zu brauchen. Ein periodischer Tick (startNewsBreakTicker(), alle 15s
waehrend der Wiedergabe) ersetzt die "jeder Hauptloop-Durchlauf"-Pruefung
des Docker-Projekts - Android hat keinen vergleichbaren Dauer-Takt.
Mehrere Dateien pro Fenster werden nachgeladen (advanceNewsBreak()), bis
das Fenster um ist - der historische Docker-Bug "News-Break spielte nur
eine MP3" ist damit von vornherein ausgeschlossen (live im Emulator
verifiziert, siehe SESSION.md). manualPlay()/manualSkip()
(ZAPPEN!) rufen interruptNewsBreak() zuerst auf und beenden eine
laufende Pause damit sofort - explizite Nutzerentscheidung schlaegt
Automatik, wie ueberall sonst in dieser Klasse.
Der playerListener unterscheidet dabei zwischen "MP3 hat ein Problem"
(advanceNewsBreak(): naechste Datei laden bzw. Pause beenden) und
"Sender hat ein Problem" (regulaerer Watchdog oben) - sonst wuerde ein
MP3-Fehler faelschlich den pausierten, aber gesunden Sender als tot
markieren. Kein -re/realtime-Sonderfall noetig (anders als im
Docker-Projekt, dessen ffmpeg-Pipe eine lokale Datei sonst in
Sekundenbruchteilen durchreicht) - ExoPlayer spielt eine lokale Datei
ohnehin in ihrem eigenen Tempo ab, unabhaengig von der Quelle.
Vorbild: require_speech_in_window im Docker-Projekt (seit 2026-08-21,
siehe dessen ARCHITECTURE.md, Abschnitt "Sprache-Gate für den
Pause-Start"). Optional (NewsBreakConfig.requireSpeechInWindow, Default
AUS): die Pause startet nicht mehr rein zeitbasiert
(NewsBreak.activeSlot()), sondern erst, wenn zusaetzlich ein engeres
Toleranzfenster (speechGateWindowMinutes) erreicht UND
speechGateStreakSeconds Sekunden Sprache am Stück erkannt wurden
(PlaybackService.speechGateActive()/checkNewsBreak()).
Kein Duplikat der Datumsmathematik: die enge Pruefung ruft
NewsBreak.activeSlot() unveraendert mit cfg.copy(windowMinutes = speechGateWindowMinutes) auf - exakt derselbe Trick wie im Docker-Vorbild
({**news_break_cfg, "window_minutes": speech_gate_window_minutes}).
Der Streak selbst kommt aus StreamAnalyzer.rawSpeechStreakSeconds - dem
bereits vorhandenen rohen, ungeglaetteten Chunk-Zaehler, der intern schon
fuers Fingerprint-Timing mitlaeuft (siehe unten), hier zusaetzlich in
kontinuierlichen Sekunden veroeffentlicht. Bewusste Abweichung vom
Docker-Vorbild: dort ist speech_streak eine Anzahl diskreter 1s-Fenster
(WINDOW_SECONDS=1.0), hier eine Sekunden-Dauer (0.5s-Chunks) - beide
Defaults (3) meinen inhaltlich dasselbe, die Sekunden-Einheit ist nur
robuster gegen eine andere Chunk-Groesse.
Dieselbe Race wie im Docker-Vorbild (dort live gefunden, siehe dessen
ARCHITECTURE.md): die normale Skip-Logik (handleStatusForAutoSwitch()
bei PlaybackStatus.SPEECH, handleFingerprintOutcome() bei Match)
wuerde den Sender sonst fast immer schon wegschalten, BEVOR das Gate
ueberhaupt einen Streak sehen kann. Fix: beide pruefen zusaetzlich
!speechGateActive(cfg), bevor sie attemptAutoSwitch() aufrufen -
solange das enge Fenster aktiv ist, soll sustained speech als
"wahrscheinlich die Nachrichten-Anmoderation" gelten, nicht als "Werbung,
weg damit". Android hat keinen Docker-Sabbelfilter-Toggle
(filter_enabled) - der entsprechende Bypass im Vorbild entfaellt
ersatzlos, es gibt nichts, das die Analyse pausieren koennte.
Vorbild: ad_prebuffer_enabled/AdSkipPrebuffer im Docker-Projekt (seit
2026-08-21, siehe dessen ARCHITECTURE.md, Abschnitt
"Werbeblock-Vorbuffering"). Optional
(NewsBreakConfig.adPrebufferEnabled, Default AUS): nach der Pause-MP3
folgt auf vielen Sendern erst ein Werbeblock, bevor wieder Musik laeuft -
der Hoerer bekaeme den live komplett ab. Ein Live-Stream laesst sich nicht
schneller als Echtzeit lesen, "vorspulen" geht nicht - der Trick ist
deshalb, die ohnehin verstreichende Pause-Zeit doppelt zu nutzen: in den
letzten adPrebufferLeadSeconds der Pause-MP3 schon im Hintergrund auf
den pausierten Sender verbinden und mitklassifizieren.
Docker "übernimmt" dafuer eine bereits offene ffmpeg-Pipe als neue
Playback-Quelle. ExoPlayer erlaubt kein Andocken an eine fremde
Decoder-Pipeline - das 1:1-Nachbauen waere unverhaeltnismaessig riskant
gewesen. Stattdessen nutzt playback/AdSkipPrebuffer.kt dasselbe
Vorwaermungsmuster, das die App fuer den wahrscheinlichsten naechsten
Ringkandidaten ohnehin schon hat (preloadedPlayer/refreshPreload()):
ein zweiter, stummer ExoPlayer + eine eigene StreamAnalyzer-Instanz
verbinden sich auf den PAUSIERTEN (Resume-)Sender selbst statt auf den
Ring-Nachfolger. readyForPromotion (= der geglaettete Analyse-Status
steht auf MUSIC) nutzt dieselbe, bereits getunte Hysterese wie ueberall
sonst im Projekt statt eines eigenen rohen Streak-Zaehlers wie im
Docker-Vorbild (dessen music_confirm_windows).
Ist der Hintergrund-Reader beim Pause-Ende bereit, uebernimmt
PlaybackService.resumeFromNewsBreak() seinen bereits verbundenen Player
direkt (Listener umhaengen, Lautstaerke von stumm auf hoerbar) statt kalt
neu zu verbinden - dieselbe Warm-Uebernahme wie bei preloadedPlayer in
play(). Trifft der Hintergrund-Reader nicht mehr genau das tatsaechliche
Resume-Ziel (Sender waehrend der Pause geaendert) oder ist er nicht
rechtzeitig fertig, faellt der Code auf den normalen play()-Pfad zurueck
- keine Verschlechterung gegenueber dem Verhalten ohne dieses Feature.
Bewusste Abweichung vom Docker-Vorbild: Docker nutzt dafuer bewusst NUR
VAD (kein STT), weil eine zweite Vosk-artige Instanz dort RAM verdoppeln
wuerde. Android hat gar kein VAD - Vosk/StreamAnalyzer ist das einzige
verfuegbare Klassifikationswerkzeug im Projekt (siehe oben, "Zwei
unabhaengige Dekodierungen") und wird hier bewusst dafuer wiederverwendet.
Kostet eine DRITTE parallele Dekodierung, aber nur fuer die kurze
Lead-Zeit kurz vor Pause-Ende (siehe "Bekannte Grenzen" unten fuer die
Ticker-Granularitaet, mit der dieses Fenster erkannt wird). Bewusst OHNE
FingerprintDb fuer diese Hintergrund-Instanz: ein Treffer auf einem
stummen Stream liesse sich keinem sinnvollen Nutzer-Erlebnis zuordnen.
Vorbild: fingerprint.py im Docker-Projekt (siehe dessen Moduldoc fuer
die volle Herleitung). Pflicht-Ausgangspunkt war der dortige 2D-Peak-Fix,
nicht der urspruengliche naive Ansatz ("lautester Bin pro Frame"), der im
Praxistest 351 von 351 verschiedenen Sprache-Clips fälschlich als
identisch erkannte - menschliche Sprache hat generisch aehnliche
Formant-Energie, das macht "lautester Bin" zu einem content-UNABHAENGIGEN
Merkmal. Erst echte lokale Maxima in einer Zeit-Frequenz-Nachbarschaft
("Landmarken": Onsets, markante Toene) trennen zuverlaessig.
Kein zweiter Decode-Pfad: laeuft auf dem bereits vorhandenen
16kHz-Mono-Analysestrom aus StreamAnalyzer.kt (demselben, der an Vosk
geht), nicht auf einer zusaetzlichen 44.1kHz-Dekodierung wie im
Docker-Projekt - die diskriminative Energie liegt weit unter der
8kHz-Nyquist-Grenze bei 16kHz. Alle Frame-/Nachbarschafts-Konstanten
wurden proportional neu hergeleitet, nicht 1:1 aus Python kopiert:
| Python (44.1kHz) | Bedeutung | Android (16kHz) |
|---|---|---|
FRAME_SIZE=1024 (23ms) |
FFT-Fenster | FRAME_SIZE=512 (32ms) |
HOP_SIZE=512 (11.6ms) |
Frame-Vorschub | HOP_SIZE=256 (16ms) |
MIN_FREQ_HZ=200 |
Netzbrumm-Ausschluss | 200 (absoluter Hz-Wert) |
PEAK_NEIGHBORHOOD_TIME=5 (58ms) |
Zeit-Nachbarschaft | 5 (80ms) |
PEAK_NEIGHBORHOOD_FREQ=15 (645Hz) |
Frequenz-Nachbarschaft | 21 (656Hz) |
PEAK_AMP_MIN_FACTOR=3.0 |
Schwelle (dimensionslos) | 3.0 |
FAN_VALUE=5 |
Hashes/Anker (dimensionslos) | 5 |
TARGET_ZONE_FRAMES=40 (465ms) |
Max. Peak-Paar-Abstand | 30 (480ms) |
MIN_HASH_MATCHES=25 |
Treffer-Schwelle | 25 (Startwert, siehe "Bekannte Grenzen") |
fingerprint/Fingerprint.kt ist reine Algorithmus-Logik (eigene
Radix-2-Cooley-Tukey-FFT, 2D-lokale-Maxima-Peak-Erkennung, Hash-Bildung
"$f1-$f2-$dt") - bewusst KEINE externe FFT-Bibliothek wie JTransforms,
passt zum durchgehenden Minimal-Dependency-Stil dieses Projekts (das
Docker-Vorbild vermeidet aus demselben Grund scipy). Kennt weder
Android-SQLite noch PlaybackService. fingerprint/FingerprintDb.kt
nutzt SQLiteOpenHelper (Android-Bordmittel) statt Room - dieselbe
Begruendung wie die JSON-Datei-statt-Room-Entscheidung bei der
Senderliste, nur in die andere Richtung (hier rechtfertigt der indizierte
Hash-Lookup echtes SQL, aber keine Room-Boilerplate fuer zwei simple
Tabellen). matchOrLearn()/deleteClip()/clearAll() folgen 1:1
FingerprintDBs Vorbild, inkl. desselben Voting-Mechanismus
((clip_id, delta)-Zaehlung fuer konsistenten Zeitversatz). Anders als das
Vorbild begrenzt sich die DB selbst (seit dem Phase-8-Review, siehe
SESSION.md): matchOrLearn() lernt jeden ungematchten 2s-Sprachclip mit
mehreren hundert Hash-Zeilen, auf dem Handy waechst das sonst unbegrenzt.
Ab MAX_CLIPS=500 werden die nach last_seen aeltesten Clips in Batches
verdraengt - ein Clip, der lange nicht mehr wiedererkannt wurde, war
offensichtlich kein wiederkehrender Jingle. Zusaetzlich der Knopf
"🗑 Fingerprint-DB leeren" auf der Startseite (mit Rueckfrage) fuer einen
bewussten Neuanfang.
StreamAnalyzer zaehlt fuers Fingerprint-Timing einen zusaetzlichen
ROHEN, ungeglaetteten Speech-Streak mit (FINGERPRINT_TRIGGER_CHUNKS=4 =
2s, identisch zu Pythons FINGERPRINT_TRIGGER_SECONDS=2) - unabhaengig
von der bestehenden Glaettung/Hysterese (4s-Anlaufzeit), sonst waere der
erste Check unnoetig spaet dran. Ergebnis kommt als einmaliges
FingerprintOutcome-Ereignis ueber ein SharedFlow zurueck (bewusst
nicht StateFlow - ein Treffer ist kein Dauerzustand). PlaybackService
reagiert auf Match mit demselben attemptAutoSwitch() wie ein
Sprache-Treffer (identische Semantik zu do_switch("Bekannte Werbung/ Jingle erkannt"), erbt den bestehenden Cooldown automatisch). Pausiert
implizit waehrend einer Nachrichten-Pause, weil analyzer.stop() dort
bereits die komplette Analyse-Coroutine abbricht.
Vorbild: song_fingerprint.py im Docker-Projekt (siehe dessen Moduldoc/
ARCHITECTURE.md, Abschnitt "Song-Erkennung", für die volle Herleitung
inkl. Phase 1/Phase 2). Wichtiger Unterschied: das Docker-Projekt
nutzt dort bewusst Chromaprint (fpcalc, fertiges Debian-Paket) statt
eines eigenen Constellation-Map-Verfahrens - Letzteres wäre laut dessen
Moduldoc "eine deutlich größere Baustelle" gewesen. Auf Android gibt es
kein entsprechendes vorgefertigtes Kompilat ohne NDK/JNI-Aufwand, aber
das genau dafür geeignete Constellation-Map-Verfahren existiert hier
bereits (siehe "Audio-Fingerprinting" oben) - Wiederverwendung statt
Neuentwicklung: SongFingerprintDb.matchOrLearn() ruft
Fingerprint.fingerprintClip() (dieselbe Funktion wie fürs
Sprache-Fingerprinting) unverändert mit denselben Algorithmus-/
Peak-Konstanten auf, nur der Match-Schwellwert unterscheidet sich
(MIN_HASH_MATCHES=140 statt 25 - Song-Schnipsel sind mit 11.0s
deutlich länger als die 2s-Sprache-Clips, für die der Original-Wert
kalibriert wurde: 25 * 11.0/2.0 ≈ 137.5, aufgerundet). Kein eigener
SongFingerprint.kt-Wrapper - da aktuell alle Peak-/Hash-Parameter mit
Fingerprint.kt identisch sind, wäre eine reine 1:1-Alias-Klasse nur
unnötige Indirektion gewesen.
UNKALIBRIERTER PLATZHALTER (siehe "Bekannte Grenzen" unten): anders
als beim ursprünglichen Sprache-Fingerprinting (dort ein echter
351-Paar-Cross-Match-Test) gibt es für Musik auf Android keinen
Testkorpus. Sowohl MIN_HASH_MATCHES als auch der rohe
Übereinstimmungs-Zähler selbst (statt eines auf die Überlappungslänge
normierten Verhältnisses, wie es Dockers Chromaprint-similarity()
nutzt) sind bewusste Vereinfachungen, keine Messwerte. Live im Emulator
gegen den echten SWR3-Stream beobachtet: neue Songs mit ~1000-1400
Hashes, Zufalls-Übereinstimmungen zwischen verschiedenen Songs im
niedrigen einstelligen Bereich (2, 5, 6 von jeweils >1000 Hashes) - ein
deutlicher Abstand zur Schwelle 140, aber ohne einen echten Wiederholungs-
Testfall bislang nicht als "Trefferseite" verifiziert.
Puffer + Cooldown statt echtem Ringpuffer: StreamAnalyzer.kt sammelt
PCM nur, solange der GEGLÄTTETE Status MUSIC ist (Docker-Pendant:
label == "music"), in einen fest dimensionierten SONG_SNIPPET_SAMPLES-
Puffer (11.0s bei 16kHz, bewusst identisch zu song_recognition. snippet_seconds im Docker-Projekt - unter AudDs 12s-Limit). Ist der
Puffer voll, läuft ein Check, danach songCooldownChunks Chunks Pause
(mirrors interval_seconds=45.0) - anders als Dockers echter Sliding-
Ringpuffer zählt der Cooldown hier nur während MUSIC-Chunks herunter
(bewusste Vereinfachung, siehe Code-Kommentar in StreamAnalyzer.kt:
eine längere Moderation zwischen zwei Songs "pausiert" den Intervall-
Countdown mit, statt real weiterzulaufen - unkritisch, da hier kein
Icecast-Publikum wie beim Docker-Hörer-Gate dranhängt).
AudD-Cloud-Lookup (AudDClient.kt): HttpURLConnection (kein
OkHttp/Retrofit, siehe importer/StationImporter.kt als bestehendes
Muster in diesem Projekt), Multipart-Body + WAV-Header von Hand gebaut
(kein javax.sound.sampled auf Android, anders als Pythons wave-Modul),
JSON-Antwort per org.json (Android-SDK-Bordmittel). Multipart-Feld
return=apple_music,spotify zusätzlich zu api_token/file (Nutzer-
Wunsch, nachträglich ergänzt) NUR wegen der Songlänge -- AudDs
Kernantwort liefert sie nicht, parseDurationSeconds() liest
result.spotify.duration_ms bzw. result.apple_music.durationInMillis
(live geprüft, praktisch identisch, Spotify bevorzugt). Läuft nur bei einem
echten lokalen Cache-Miss (Learned) UND aktiviertem
"Identify unknown songs via AudD"-Schalter UND gesetztem Token
(SongRecognitionSettings.kt, SharedPreferences-Textfeld auf der
Startseite, gleiches Muster wie die Update-Server-URL in
UpdateManager.kt - Token bewusst nicht in stations.json). Eigener
AUDD_MIN_INTERVAL_MS=60_000-Cooldown direkt in AudDClient, unabhängig
vom lokalen Song-Cooldown - zweite Sicherheitsebene gegen
Kontingent-Verbrauch bei einem zu locker/streng greifenden, unkalibrierten
MIN_HASH_MATCHES.
Wichtig, live gefunden: der Netzwerk-Call läuft in einer eigenen,
LOSGELÖSTEN Coroutine (scope.launch(Dispatchers.IO) { ... } direkt an
der Fundstelle in StreamAnalyzer.runAnalysis()), NICHT inline im
Decode-Loop - ein AudD-Request kann mehrere Sekunden dauern und darf die
laufende Audio-Dekodierung/Vosk-Verarbeitung nicht blockieren (exakt das,
was das Docker-Pendant per eigenem Hintergrund-Thread vermeidet). Der
PCM-Schnipsel wird dafür VOR dem Zurücksetzen des Puffers per
.copyOf() kopiert (songSnapshot) - sonst würde der als Referenz
übergebene, weiterhin lebende Puffer bereits vom nächsten Zyklus
überschrieben, während der Cloud-Call noch läuft. Bei Erfolg schreibt
SongFingerprintDb.setCloudMetadata() Titel/Interpret/Album/Jahr in die
von matchOrLearn() schon angelegte Zeile (kein Umweg über einen
Hash-Text wie beim Docker-Pendant nötig - die Zeilen-ID ist hier direkt
aus dem Learned-Rückgabewert bekannt) und emittiert ein synthetisches
Match-Ereignis, damit die UI sofort aktualisiert. Per isCurrent(runGeneration)
abgesichert: ein während des Cloud-Calls längst abgelöster Sender-Lauf
(Nutzer hat inzwischen weitergeschaltet) schreibt die DB zwar noch
korrekt (kein Datenverlust), aktualisiert aber NICHT mehr die Anzeige des
inzwischen falschen Senders.
currentSong/UI: PlaybackService.currentSong (StateFlow) wird bei
JEDEM refreshAnalyzer()-Aufruf (echter Senderwechsel UND
kalibrierungsbedingter Sprachwechsel auf demselben Sender) sofort auf
null gesetzt, BEVOR der Analyzer neu startet - sonst würde ein
inzwischen nicht mehr passender alter Song weiter angezeigt (dieselbe
"Stale-Anzeige"-Falle, die auch beim Docker-Hörer-Gate live gefunden
wurde, siehe dessen SESSION.md). Chip "🎵 Song" zeigt "Artist – Titel",
sonst (Nutzer-Wunsch, analog zum Docker-Web-Interface) "🔍 noch nicht
erkannt" solange service.status ungleich IDLE ist (irgendein Sender
läuft, MainActivity kombiniert dafür den status- und den
currentSong-Flow über zwei einfache Felder statt eines eigenen
combine()-Flows), sonst "–". KEIN Docker-Pendant zum dortigen dritten
Zustand "pausiert (keine Hörer)" - das Hörer-Gate-Konzept gibt es auf
Android nicht (kein Icecast-Restream-Publikum). Button "🗑 Song-DB
leeren" (mit Rückfrage, analog "🗑 Fingerprint-DB leeren") setzt die
Erkennung komplett zurück.
Nachrichten-Pause-Skip (manualNewsBreakSkip()): eigener Knopf
"⏭ Andere Pause-MP3", unabhängig vom bestehenden manualSkip()
("⚡ ZAP!", das die Pause komplett beendet und interruptNewsBreak()
aufruft). Ruft stattdessen unverändert playNextNewsBreakFile() auf -
dieselbe Funktion, die advanceNewsBreak() beim natürlichen Dateiende
sowieso schon aufruft, inklusive deren newsBreakRecentFiles-Dedup -
während _newsBreakActive weiter true bleibt. In der UI nur
klickbar, während eine Pause läuft (newsBreakActive-Flow steuert
isEnabled, gleiches Muster wie bei den Musiksammlung-Track-Buttons).
(Schritt 2, der Kalibrierungs-Wizard, ist ein eigener Abschnitt weiter unten.)
Vorbild: stt_filter.py/settings_store.py im Docker-Projekt (dessen
"Mehrsprachige STT-Erkennung"-Abschnitt in ../CLAUDE.md) - wie dort
liegt die Zuordnung an der Kategorie, nicht am einzelnen Sender
(stt/SttSettings.kts categoryLanguages: Map<String, String>,
model/Categories.kt bleibt unangetastet). Sprachliste als Freitext
(Code + Vosk-Modell-Download-URL), keine feste Auswahl - Android laedt
das Modell selbst herunter, anders als der Docker-Container, der auf
einen Host-Mount angewiesen ist, aber dieselbe Freitext-Philosophie.
vosk/VoskModelManager.kt ist seit dieser Phase ein Singleton (object)
statt einer Instanz-Klasse - mehrere Activities (Startseite, die neue
stt/SttSettingsActivity.kt) teilen sich denselben Download-Fortschritt
pro Sprache (ConcurrentHashMap<String, MutableStateFlow<ModelState>>).
Der Modell-Ordnername wird aus dem Dateinamen der Download-URL abgeleitet
statt sprachcode-basiert vergeben - fuer Deutsch mit der unveraenderten
Default-URL ergibt sich dadurch exakt derselbe Pfad wie vor dieser Phase
(vosk-model-small-de-0.15), bestehende Installationen mit bereits
heruntergeladenem Modell brauchen keinen Migrationsschritt.
vosk/VoskModelCache.kt (neu) ist ein LRU-Cache geladener
org.vosk.Model-Objekte pro Sprachcode (MAX_LOADED_VOSK_LANGUAGES=2,
identischer Default wie MAX_LOADED_VOSK_LANGUAGES im Docker-Projekt) -
direktes Pendant zu SttFilter._get_vosk_engine(). Instanzgebunden
(gehoert PlaybackService, analog FingerprintDb), cached sowohl Erfolg
als auch Fehlschlag, damit ein kaputter Modellpfad nicht bei jedem Sample
erneut das Dateisystem anfasst. StreamAnalyzer.start()/runAnalysis()
bekommen language/voskModelCache/ratioToConfirmSpeech/
ratioToConfirmMusic als Parameter statt der frueheren globalen
Konstanten RATIO_TO_CONFIRM_SPEECH/RATIO_TO_CONFIRM_MUSIC (deren
bisherige Werte 0.65/0.30 sind jetzt die Defaults in LanguageConfig,
pro Sprache ueberschreibbar). Das Modell kommt ueber
voskModelCache.get(modelPath, language) - Ownership liegt beim Cache,
StreamAnalyzer darf es deshalb nicht mehr selbst schliessen.
Kein Parameter-Threading mehr fuer Sprache/Modellpfad: bis zu dieser
Phase wurde der Vosk-Modellpfad einmal in MainActivity aufgeloest und
als Parameter durch manualPlay()/play() und vier interne
Aufrufstellen in PlaybackService.kt durchgereicht (activeModelPath-
Feld). Genau dieses Muster war im Docker-Projekt die Ursache eines
Absturz-Bugs beim Hinzufuegen der dortigen Mehrsprachigkeit (eine
Aufrufstelle bei der Signaturaenderung vergessen, siehe dessen
SESSION.md, "Fortsetzung 7"). Der Parameter wurde deshalb komplett
entfernt statt nur sorgfaeltig durchgereicht: play(station) loest
Sprache und Modellpfad jetzt bei JEDEM Aufruf intern ueber
SttSettings.resolveLanguage(station.category) auf - jeder Aufrufer
bekommt dadurch automatisch den aktuellen Stand, eine vergessene Stelle
kann strukturell nicht mehr veraltete Daten durchreichen.
LanguageConfig haelt ratioToConfirmSpeech/ratioToConfirmMusic bereits
pro Sprache - Speicherstruktur fuer den in Schritt 2 (naechster Abschnitt)
umgesetzten Kalibrierungs-Wizard.
Vorbild: der STT-Kalibrierungs-Wizard im Docker-Projekt (dessen
CLAUDE.md, Abschnitt STT-Sprachfilter, "Kalibrierungs-Wizard"). Dort
kalibriert der Wizard einen confidence_threshold gegen ein VAD+STT-Duo -
Android hat dieses Duo nicht, Vosk-Texterkennung ist hier bereits der
einzige Detektor. Der Wizard kalibriert deshalb stattdessen direkt die
vorhandene Hysterese-Bandbreite (ratioToConfirmSpeech/
ratioToConfirmMusic) um StreamAnalyzer.speechRatio - dasselbe Rohsignal,
das auch das "Bullshitometer" zeigt.
stt/Calibration.kt ist reine Domänenlogik (kennt weder PlaybackService
noch StreamAnalyzer, analog NewsBreak.kt/Fingerprint.kt):
suggestRatios(speechSamples, musicSamples) trennt die beiden
Verteilungen ueber musicHigh/speechLow und teilt die Luecke dazwischen
im Verhaeltnis MARGIN_RATIO=0.7 auf (identischer Wert wie
_THRESHOLD_MARGIN_RATIO im Docker-Projekt, Richtung Sprache-Seite
gewichtet) - ratioToConfirmMusic liegt naeher an musicHigh,
ratioToConfirmSpeech naeher an speechLow. Ueberlappen sich die
Verteilungen (musicHigh >= speechLow), liefert die Funktion
overlapping=true statt eines potenziell falschen Vorschlags.
musicHigh/speechLow sind seit dem Phase-8-Review (siehe SESSION.md,
Befund 4) das 90.- bzw. 10.-Perzentil, NICHT mehr max()/min(), und es
braucht MIN_SAMPLES_PER_LEVEL=20 Samples pro Seite (ca. 10 Sekunden).
Vorher genuegte ein einziger Uebergangswert - der Moderator holt Luft, ein
Jingle laeuft an - um jeden Vorschlag als "ueberlappend" zu verwerfen.
PlaybackService haelt die Session direkt als Felder (kein eigenes
State-Objekt) - dieselbe Entscheidung wie bei Fingerprint/News-Break,
die Session haengt am gerade laufenden analyzer. refreshAnalyzer(station)
wurde dafuer aus play() herausgezogen (reiner Refactor) und wird jetzt
auch von startCalibration()/stopCalibration()/
applyCalibrationSuggestion() genutzt, um die STT-Analyse fuer den GERADE
laufenden Sender neu aufzusetzen, ohne den ExoPlayer anzufassen. Ist eine
Session aktiv, ERZWINGT refreshAnalyzer() deren Sprache statt der ueber
SttSettings.resolveLanguage() aufgeloesten Kategorie-Sprache - Sender
koennen waehrenddessen ganz normal ueber die Startseite gewechselt werden,
jeder trifft automatisch dieselbe erzwungene Sprache. Automatisches
Umschalten (Sprache-Treffer, Fingerprint-Match) bleibt waehrend einer
aktiven Session bewusst AUS - ein durch die erzwungene Sprache
verfaelschtes Ergebnis darf keinen automatischen Wechsel ausloesen. Der
Wizard schaltet selbst NIEMALS einen Sender um.
stt/CalibrationActivity.kt bindet sich wie MainActivity an
PlaybackService, zeigt den laufenden Sender und den Live-Rohwert
(dieselbe Balken-Darstellung wie das Bullshitometer), zwei Toggle-Buttons
"🗣 Das ist Sprache"/"🎵 Das ist Musik" (erneutes Antippen pausiert das
Sammeln), Sample-Zaehler und einen live neu berechneten Vorschlag - kein
Zwischenspeichern, wird bei jeder Aenderung der Sample-Zaehler frisch aus
calibrationSuggestion() gezogen (analog zum Docker-Wizard, der den
Vorschlag bei jedem Status-Poll neu berechnet statt eine zweite
JS-Implementierung zu pflegen). "Übernehmen" speichert die Werte in
SttSettings und wendet sie sofort an, OHNE die Session zu beenden -
weiter sammeln/erneut uebernehmen bleibt moeglich.
android:screenOrientation="portrait" fuer CalibrationActivity ist
bewusst gesetzt: eine Rotation wuerde die Activity sonst zerstoeren und
neu erstellen, onDestroy() beendet aber die Session - ohne die Sperre
wuerde eine simple Bildschirmdrehung mitten in der Kalibrierung bereits
gesammelte Samples verwerfen. Zusaetzlich schuetzt
activeCalibrationLanguage != language in onServiceConnected() davor,
dass ein erneutes Binden (z.B. nach kurzem Backgrounding) startCalibration()
ein zweites Mal fuer dieselbe Sprache aufruft, was die Sample-Listen sonst
explizit leeren wuerde.
Damit eine neue APK nicht jedes Mal per USB/Datei-Transfer aufs Handy
muss, liefert UpdateManager.kt sie per HTTP(S) direkt aus dem Netz nach.
Seit 2026-08-08 (vorher: eigener Python-Server nur uebers Tailscale-Netz,
siehe SESSION.md) ist die Gegenstelle keine eigene Software mehr, sondern
ein ganz normales, statisches Verzeichnis auf dem oeffentlich erreichbaren
Webserver von blarks.de: /srv/www/blarks.de/radio/update/ (per
SSH-Host-Alias strato erreichbar; bis 2026-08-12
/srv/www/blarks.de/update_radiosabbelnich/, seitdem in einen neuen
Unterordner mit identischem Inhalt umgezogen, siehe SESSION.md). Apache
liefert Dateien von dort direkt aus - Verzeichnis-Listing ist per vhost-Konfiguration gesperrt
(Options -Indexes), der .apk-MIME-Type (application/vnd.android. package-archive) kommt automatisch aus /etc/mime.types, kein
Zusatzcode noetig.
Jede hochgeladene APK bekommt einen eigenen, zeitgestempelten Namen
(radiosabbelnich-YYYYMMDD-HHMMSS.apk) statt eine bestehende Datei zu
ueberschreiben - version.json ({"buildTime": "...", "apkFile": "radiosabbelnich-...apk"}) sagt der App, welche Datei gerade aktuell
ist. Kompletter Bauen-und-Verteilen-Schritt (siehe auch ../CLAUDE.md):
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk radiosabbelnich.apk # lokal, adb install
STAMP=$(date '+%Y%m%d-%H%M%S')
APK_NAME="radiosabbelnich-${STAMP}.apk"
cp radiosabbelnich.apk "$APK_NAME"
echo "{\"buildTime\": \"$(date '+%Y-%m-%d %H:%M')\", \"apkFile\": \"$APK_NAME\"}" > version.json
scp "$APK_NAME" version.json strato:/srv/www/blarks.de/radio/update/
rm "$APK_NAME"
scp radiosabbelnich.apk strato:/srv/www/blarks.de/radio/update/radiosabbelnich-latest.apkAeltere Staende bleiben auf blarks.de liegen (kein automatisches
Aufraeumen) - nur version.json bestimmt, welcher als "aktuell" gilt.
Der letzte scp ist ein zusaetzlicher, fest benannter Alias nur fuer den
QR-Code im Haupt-README (siehe dort) - UpdateManager selbst ignoriert
ihn komplett.
Kein Auth - bewusst, siehe ../CLAUDE.md, Abschnitt "Kein Auth": eine
App-Binary ohne Nutzerdaten oeffentlich zum Download bereitzustellen ist
ein anderes Risiko als der Rest des Projekts (Live-Radiodienst,
urheberrechtlich sensibler Restream), deshalb hier bewusst NICHT mehr
hinter einem VPN.
Die Server-Adresse ist weiterhin NICHT hartcodiert - UpdateManager.kt
haelt sie in SharedPreferences (getBaseUrl()/setBaseUrl()), mit
einem Textfeld direkt auf der Startseite ("Update-Server:") jederzeit
ohne Rebuild aenderbar. DEFAULT_UPDATE_BASE_URL im Code ist nur der
Startwert, falls noch nichts gespeichert ist - jetzt
https://blarks.de/radio/update. Wer die APK von woanders
bekommt oder einen eigenen Server betreiben will, traegt einfach eine
andere Adresse ins Textfeld ein.
Button "Nach Update suchen": laedt version.json von der aktuell
eingestellten Adresse, vergleicht den buildTime-String 1:1 gegen
BuildConfig.BUILD_TIME der laufenden App (reiner String-Vergleich, keine
Datums-Ordnung - ein Rollback auf einen aelteren Server-Stand wuerde also
ebenfalls als "Update verfuegbar" gemeldet) und merkt sich das apkFile
aus der Antwort fuer den naechsten Schritt. Bei Unterschied: Button laedt
GENAU diese Datei (${baseUrl}/${apkFile}) in den Cache und stoesst
danach ueber FileProvider + Intent.ACTION_VIEW
(android.permission.REQUEST_INSTALL_PACKAGES) den normalen
System-Installer an - keine stille Auto-Installation (dafuer waere die
App Device-Owner/MDM, weit ausserhalb des Scopes). Vor dem allerersten
Update fragt Android einmalig, ob diese App unbekannte Apps installieren
darf (Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) - das faengt
installUpdate() in MainActivity.kt ab und leitet dorthin weiter, statt
dass der Install-Intent kommentarlos ins Leere laeuft.
downloadUpdate() prueft zusaetzlich den Content-Type der Antwort
(muss mit application/vnd.android.package-archive beginnen, sonst
Fehler statt stillem Weiterlaufen) - der reine HTTP-Statuscode reicht
NICHT: ein Webserver mit Catch-All-Route fuer unbekannte Pfade (wie
blarks.des SPA-Startseite) antwortet auf einen falschen/veralteten
Dateinamen ebenfalls mit 200, nur eben mit HTML statt der APK - live so
aufgetreten, als eine bereits installierte AELTERE App-Version (vor der
apkFile-Umstellung, siehe oben) noch den alten fest verdrahteten
Dateinamen abfragte. Ohne diese Pruefung landet die HTML-Seite
unbemerkt im APK-Cache und scheitert erst kommentarlos im
System-Installer ("Es liegt ein Problem mit der App-Datei vor").
Live im Emulator verifiziert (siehe SESSION.md, 2026-08-08): kompletter
Ablauf Check ("Kein Update verfuegbar" gegen den echten aktuellen Stand)
→ version.json auf blarks.de kurzzeitig auf einen fiktiven Stand
gesetzt → "Update verfuegbar: 2099-01-01 00:00" → Download der exakten
zeitgestempelten Datei (~46 MB ueber echtes Internet statt Emulator-NAT)
→ System-Installer-Dialog "Do you want to update this app?" (korrekt als
Update erkannt, Signatur passt) → abgebrochen, version.json auf den
echten Stand zurueckgesetzt.
Diese App selbst ist Teil von RadioSabbelNich (GPLv3, siehe
Haupt-README). Die folgenden Fremdkomponenten
werden unverändert als Gradle-Abhängigkeit (app/build.gradle.kts)
eingebunden und jeweils gemäß ihrer eigenen Lizenz genutzt:
| Komponente | Zweck in dieser App | Lizenz |
|---|---|---|
| Kotlin (JetBrains) | Programmiersprache, in der die gesamte App geschrieben ist | Apache-2.0 |
| AndroidX Core-KTX / AppCompat / ConstraintLayout / Lifecycle / DocumentFile (Google) | Grundlegende Jetpack-Bausteine — Kotlin-Erweiterungen, Basis-Activities, UI-Layout, PlaybackService als Lifecycle-Service, Ordnerzugriff für den News-Break-Import |
Apache-2.0 |
| Material Components for Android (Google) | UI-Komponenten (Material Design) | Apache-2.0 |
| kotlinx.coroutines (JetBrains) | Asynchrone Verarbeitung (Streams, StateFlow) | Apache-2.0 |
| AndroidX Media3 / ExoPlayer (Google) | Audio-Wiedergabe der Sender-Streams (PlaybackService.kt) |
Apache-2.0 |
| Vosk Android-Bindings (alphacep) | Offline-Spracherkennung für den Sprache/Musik-Filter (analysis/StreamAnalyzer.kt, stt/) |
Apache-2.0 |
- Doppelter (seit der Vorwärmung eher dreifacher) Netzwerkverbrauch durch die zwei unabhaengigen Dekodierungen (siehe oben) PLUS den vorgewaermten Kandidaten (siehe "Vorwärmung" oben) - fuer echten Mobilfunk-Betrieb ungeeignet, nur fuer WLAN/Prototyp gedacht. Durch automatisches Umschalten jetzt noch relevanter (haeufigere neue Verbindungen).
- Vorwärmung deckt nur EINEN Kandidaten ab, kein Pool - bei einer
Sperren-Kette (mehrere aufeinanderfolgende Sender gerade gesperrt) oder
bei
manualPlay()auf einen nicht vorhergesagten Sender bleibt es beim bisherigen Kaltstart (siehe "Vorwärmung" oben, bewusste Aufwand/Nutzen-Entscheidung gegen einen Pool wie im Docker-Projekt). - Nachrichten-Pause ohne Ruhezeiten-Option (
enabled_hoursdes Docker-Vorbilds, siehe eigener Abschnitt oben) - Nutzerentscheidung, spaeter ergaenzbar. Der Fenster-Check laeuft alle 15s statt bei jedem Hauptloop-Durchlauf wie im Docker-Projekt - fuer die minutengranulare Fensterlaenge unerheblich, aber ein Fenster-Ein-/Austritt kann dadurch bis zu 15s spaeter bemerkt werden als der exakte Zeitpunkt. Kein Ban-System-artiges Gedaechtnis fuer den zuletzt bedienten Slot ueber einen App-Neustart hinweg (newsBreakServedSlotist reines In-Memory-Feld) - nach einem Neustart mitten in einem bereits bedienten Fenster koennte die Pause theoretisch ein zweites Mal fuer denselben Slot anspringen. - Sprache-Gate/Werbeblock-Vorbuffering: Ticker-Granularitaet statt
Sekundentakt (siehe eigene Abschnitte oben) - der Sprache-Gate-Streak
wird nur alle 15s (
checkNewsBreak()) ausgewertet, der Vorbuffering-Trigger alle 3s (checkAdSkipPrebuffer()), gegenueber Dockers ~1s-Hauptloop-Tick. Fuer die betroffenen Zeitraeume (Minuten bzw. typischerweise 10-30s Lead-Zeit) unerheblich, aber beide Features reagieren etwas traeger als ihr Docker-Vorbild. Das Werbeblock- Vorbuffering nutzt außerdem STT (Vosk/StreamAnalyzer) statt VAD wie im Docker-Vorbild, weil Android kein VAD hat - kostet fuer die kurze Lead-Zeit eine dritte parallele Dekodierung samt Vosk-Modell fuer die Sprache des Resume-Senders (muss dafuer bereits heruntergeladen sein, sonst bleibt das Feature fuer diese Pause inaktiv, sieheAdSkipPrebuffer.start()). MIN_HASH_MATCHES=25fuers Fingerprinting ist ein Startwert, keine Messung (siehe "Audio-Fingerprinting" oben) - identisch zum Python-Wert uebernommen, aber NICHT automatisch gueltig fuer die andere Samplerate/Frame-Groesse hier. Live gegen echte Sender getestet wurde bisher nur die Kein-Falsch-Treffer-Seite (drei unterschiedliche Sprache-Clips, alle mit Match-Staerke 1 klar unter der Schwelle - sieheSESSION.md) - ein echter Wiederholungs-Fall (Match-Staerke deutlich UEBER der Schwelle) wurde mangels eines garantiert wiederkehrenden Test-Clips noch nicht live beobachtet, nur der gleiche, im Docker-Projekt bereits bewaehrte Algorithmus neu in Kotlin geschrieben.MIN_HASH_MATCHES=140fuer die Song-Erkennung ist ebenfalls ein unkalibrierter Startwert (siehe "Song-Erkennung" oben) - reine Verhaeltnisrechnung aus dem Sprache-Wert, kein eigener Cross-Match-Test. Live gegen den echten SWR3-Stream beobachtet: neue Songs mit ~1000-1400 Hashes, Zufalls-Uebereinstimmungen zwischen verschiedenen Songs im niedrigen einstelligen Bereich - deutlicher Abstand zur Schwelle, aber mangels eines garantiert wiederkehrenden Song-Testfalls noch nicht auf der ECHTEN Treffer-Seite verifiziert (gleiche Einschraenkung wie beim Sprache-Fingerprinting oben). Der rohe Uebereinstimmungs-Zaehler (statt eines auf die Ueberlappungslaenge normierten Verhaeltnisses wie bei Dockers Chromaprint-similarity()) ist eine bewusste Vereinfachung, die bei nur teilweise ueberlappenden Song-Schnipseln (unterschiedlicher Einstiegspunkt im Song) zu niedrigeren Treffer-Zahlen fuehren kann, als eine normierte Metrik liefern wuerde.- "⏭ Andere Pause-MP3"-Knopf noch nicht gegen eine ECHTE laufende
Nachrichten-Pause live getestet - der deaktivierte Zustand außerhalb
einer Pause wurde live bestätigt, ein tatsächlicher Klick während
einer aktiven Pause mangels eines kurzfristig erreichbaren
Zeitfensters (naechste volle/halbe Stunde) noch nicht. Ruft
unveraendert dieselbe
playNextNewsBreakFile()-Funktion auf, dieadvanceNewsBreak()beim natuerlichen Dateiende bereits nachweislich fehlerfrei nutzt - Risiko dadurch gering, aber nicht End-zu-Ende bestaetigt. - Ein Vosk-Modellwechsel kostet weiterhin Ladezeit (ca. 1-2s), wenn
die Zielsprache nicht mehr im
VoskModelCacheliegt - innerhalb derMAX_LOADED_VOSK_LANGUAGES=2zuletzt genutzten Sprachen wird das geladene Modell dagegen ueber Sender-Wechsel hinweg wiederverwendet (seit Phase 7; die frueher hier stehende Aussage "wird bei JEDEM Play-Klick neu geladen" galt nur bis dahin). - Resampler ist reine lineare Interpolation ohne Anti-Aliasing-Filter
(
MonoResampler.kt) - fuer die grobe Sprache/Musik-Unterscheidung ausreichend (siehe Live-Test), aber keine hochwertige Audiobearbeitung. - Glaettungs-/Hysterese-Schwellen sind standardmaessig nicht an echten
Sendern kalibriert -
ratioToConfirmSpeech/ratioToConfirmMusic(pro Sprache inLanguageConfig) starten als plausible Startwerte, keine Messwerte. Seit Schritt 2 gibt es dafuer den Kalibrierungs-Wizard (siehe "Mehrsprachiges STT, Schritt 2" oben) - bis er tatsaechlich pro Sprache durchlaufen wurde, bleibt der ungemessene Startwert aktiv, bei Gesang koennte das haeufiger falsch-positiv auf "Sprache" kippen. Der Wizard selbst wurde bisher nur mit demselben (gemischten) Test-Sender fuer beide Level gegengetestet (bestaetigt den Ueberlappungs-Warnpfad, sieheSESSION.md) - der Erfolgspfad mit zwei echt unterschiedlich klassifizierten Quellen und tatsaechlich uebernommenem Vorschlag steht noch aus. CalibrationActivitys Portrait-Sperre (schuetzt eine laufende Kalibrierungs-Session vor Datenverlust durch Rotation, siehe "Mehrsprachiges STT, Schritt 2" oben) wurde nur im durchgehend im Hochformat laufenden Emulator verifiziert, nicht auf einem echten Geraet mit tatsaechlicher Drehung gegengeprueft.- Beide Sperr-Mechanismen (Sprache-Cooldown UND Watchdog) sind reines In-Memory-Timing - sie verfallen einfach nach ihrer jeweiligen Konstante, es gibt keine Eskalation (z.B. laenger werdende Sperren bei wiederholten Treffern) und nichts ueberlebt einen Neustart der App (ein Sender, der beim Beenden gerade gesperrt war, ist nach dem naechsten Start wieder unbefangen im Rennen).
- Keine Mindest-Verweildauer vor dem naechsten Auto-Switch-Versuch - der Cooldown wirkt nur auf den VERLASSENEN Sender, nicht auf den NEUEN (der koennte theoretisch sofort wieder als Sprache gelten).
- Watchdog hat keinen Reconnect-Zaehler vor dem Sperren (anders als
STREAM_FAILURE_LIMITim Docker-Projekt) -onPlayerError()sperrt sofort, in der Annahme, dass ExoPlayers eigene interne Retries fuer transiente Fehler bereits vorher gegriffen haben. Bei bestimmten Netzwerk-Situationen (z.B. sehr kurze, wiederholte Aussetzer, die ExoPlayer selbst gerade NICHT als Retry-wuerdigen Fehler einstuft) koennte das aggressiver sperren als noetig - noch nicht gegen echte Problemsender verifiziert, nur gegen eine garantiert tote IP. - Schmale Race beim Watchdog: ein spaet eintreffender
onPlayerError()-Callback der ALTEN Quelle nach einem bereits erfolgtenplay()-Wechsel auf eine neue koennte theoretisch die NEUE (gerade erst gestartete) Quelle faelschlich sperren - bewusst nicht durch einen Generation-Counter o.ae. abgesichert, Zeitfenster sehr schmal. - Kein HLS/DASH: eingebunden sind nur
media3-exoplayer/media3-common, nichtmedia3-exoplayer-hls/-dash. Sender mit.m3u8-URL (in der Kodinerds-Importliste durchaus vorhanden) koennen deshalb prinzipiell nicht laufen - sie erscheinen als "⚠ Antwortet nicht" bzw. "nicht erreichbar", ohne dass die Ursache erkennbar waere. Auch der Analysepfad kann sie nicht lesen (MediaExtractorparst keine m3u8). Beim Phase-8-Review bewusst nur dokumentiert, nicht behoben - das Nachziehen der Dependency ist eine Funktionserweiterung, keine Review-Korrektur. - Verwaltungs-Activity baut bei jeder Aenderung alle Zeilen neu auf -
bei den real importierten 361 Sendern hunderte
inflate()im Main-Thread pro Checkbox-Klick (bekannt seit dem Phase-8-Review, nicht behoben: ein RecyclerView-Umbau ist Feature-Arbeit, kein Bugfix). - Blockierende Decoder-Aufrufe lassen sich nicht abbrechen
(
MediaExtractor.setDataSource()haengt bis zum OS-Timeout): ein abgeloester Analyse-Lauf und ein abgelaufener Erreichbarkeits-Check laufen im Hintergrund noch aus. Die daraus folgende Verwechslungsgefahr ist entschaerft (Nachzuegler koennen nichts mehr veroeffentlichen, siehe "Analyse-Fehler getrennt von Sender-Fehlern"), der doppelte Ressourcenverbrauch fuer die Restlaufzeit bleibt. - Kein Sperr-Anzeige in der Verwaltungs-Activity (nur in der Play-Liste auf dem Hauptschirm).
- Kategorienliste selbst ist weiterhin nur im Code aenderbar
(
model/Categories.kt) - anders als die Sender ist sie keine Nutzerdateneinstellung. Keine Drag-Sortierung innerhalb einer Kategorie, keine Sender-Suche - bei der auf dem Handy erwarteten Senderzahl (Dutzende, nicht Hunderte je Kategorie) bisher nicht als notwendig eingeschaetzt, auch wenn ein M3U-Import mittlerweile hunderte Sender auf einen Schlag in "Unsortiert" ablegen kann (siehe "M3U-/Kodi-Sender- Import" oben) - genau dafuer gibt es dort den separaten "🔍 Unsortierte Sender pruefen"-Knopf statt eines automatischen Checks beim Import. - Ein manuell einzeln angelegter Sender (Add-Dialog) hat weiterhin
keinen Reachability-Check (anders als
station_import.pyscheck_reachable()im Docker-Projekt, das dort JEDEN neuen Sender prueft)- eine kaputte URL faellt erst beim tatsaechlichen Abspielversuch auf. Fuer Sender aus dem M3U-Import gibt es den Check jetzt (siehe oben), fuer einzeln hinzugefuegte bewusst nicht dupliziert - der bestehende Watchdog (siehe oben) faengt das beim ersten Abspielversuch ohnehin zuverlaessig ab.
- Update-Mechanismus braucht denselben Debug-Signierschluessel ueber
Installationen hinweg - Android verweigert ein Update, wenn der neue
APK-Signer vom installierten abweicht. Solange
~/.android/ debug.keystoreauf diesem Host nicht geloescht/neu erzeugt wird, ist das kein Problem (Gradle nutzt automatisch immer dieselbe Datei); falls doch, muss die App auf dem Handy einmal deinstalliert werden, bevor die naechste Update-Installation wieder klappt. - Kein automatischer Update-Check, keine Benachrichtigung - der Nutzer muss selbst auf "Nach Update suchen" tippen. Kein Hintergrund- Polling (bewusst - haette einen weiteren Dauer-Netzwerkzugriff bedeutet).
- "🔍 Unsortierte Sender pruefen" ist bei einer grossen Importliste
langsam (live gemessen: ~8 Sender/45s bei
CHECK_CONCURRENCY=3, bei 361 importierten Sendern also grob 15-25 Minuten) und verbraucht dabei spuerbar Akku/Datenvolumen - bewusst in Kauf genommen statt eines automatischen Checks beim Import selbst (siehe "M3U-/Kodi-Sender-Import" oben fuer die Begruendung). Ergebnisse (unreachableIds) ueberleben ausserdem keinen App-Neustart - rein informativ fuer die aktuelle Sitzung, kein Persistenz-Feld instations.json. VoskModelCaches LRU-Verdraengung noch nicht mit einer dritten gleichzeitig genutzten Sprache live beobachtet (nur mit zwei, sieheSESSION.md) - die Verdraengungslogik selbst ist Standard-LinkedHashMap-Verhalten, aber noch nicht am eigenen Cache unter echter Last gegengeprueft.