Neuer State 'listening' im WakeWordService. Nach endConversation faellt
ARIA nicht direkt zu armed zurueck, sondern ins passive Lauschen fuer
PASSIVE_LISTEN_DEFAULT_MS (Default 30s, in AsyncStorage konfigurierbar).
In dem Fenster braucht Stefan kein Wake-Word mehr — er kann einfach
weitersprechen, Speaker-ID-Gating in der Whisper-Bridge filtert fremde
Stimmen (TV, Frau, Hintergrundgespraeche).
Flow:
armed → wake → conversing → TTS → resume → (Nichts gesagt) →
endConversation → enterPassiveListening('listening' + Timer) →
startPassiveStreamingRecording (kein User-Bubble, kein wake-ready-Sound)
→ Speaker-ID-Gating in Bridge → Speech detected:
exitPassiveListening('speech') → 'conversing' → normaler Flow
→ Nichts in N Sek:
Timer feuert → exitPassiveListening('timeout') → 'armed' (Wake an)
Implementation:
- wakeword.ts: WakeWordState += 'listening'. enterPassiveListening +
exitPassiveListening + onPassiveListen-Callback + Cancel-Timer-Hooks
in stop(). PASSIVE_LISTEN_DEFAULT_MS/STORAGE_KEY + load/save Helpers.
- ChatScreen.tsx: state-Type um 'listening' erweitert. State-Listener
schliesst Conversation-Focus auch in 'listening' (Spotify bleibt
pausiert). onPassiveListen → startPassiveStreamingRecording mit
noSpeechTimeoutMs=passiveMs. STT-Endpoint-Handler: bei text != ''
und state=='listening' → exitPassiveListening('speech'); bei
text == '' und state=='listening' → naechste passive Aufnahme.
Beim Wechsel listening→armed/off: laufende streaming-Aufnahme
cancellen damit OpenWakeWord beim Re-Arm das Mic kriegt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sobald eine Streaming-Session ~1.5s Audio im Buffer hat, wird einmal pro
Session der Speaker-ID-Check ausgefuehrt (im Executor, ~50-100ms auf GPU).
Bei Match → Session laeuft normal weiter. Bei Mismatch → synthetisches
stt_endpoint mit text='' reason='speaker_mismatch' + stt_stream_done →
App ruft endConversation. Kein Whisper-Transcribe fuer fremde Stimmen →
Token + Latenz gespart.
- StreamSession: 3 neue Felder (speaker_checked, speaker_match,
speaker_similarity).
- SessionManager._check_speaker / _finalize_speaker_mismatch:
Check + sauberes Beenden bei Mismatch.
- _tick_session: Check-Gate vor STREAM_MIN_AUDIO_MS-Check eingehaengt.
- speaker_id.verify: threshold=None statt =DEFAULT_THRESHOLD damit
config-Broadcast-Updates zur Laufzeit greifen (Default-Arg wird sonst
zur Def-Zeit gebunden).
Fail-open: ohne Fingerprint returnt verify() (True, 0.0) — keine
Auswirkung. Stefan kann ohne Enrollment weiter wie bisher arbeiten.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
App-Seite:
- VoiceIdEnrollment.tsx (neue Komponente, ~370 Zeilen): Status-Karte
(loading/unenrolled/enrolled/error), Sample-Recorder mit Countdown
(4s fest pro Sample), Liste mit einzelnem Loeschen, Save-Button
(disabled bis 5 Samples), Fingerprint-Delete mit Confirm.
- SettingsScreen.tsx: neue Section 🎤 'Stimme einrichten' zwischen
Wake-Word und Sprachausgabe.
- Sample-Format: WAV via audioService.startRecording — wird
whisper-bridge-seitig per wave-Modul gestrippt.
Diagnostic-Seite:
- Neue settings-section 'Voice-ID (Sprecher-Erkennung)': Status-Anzeige
(live ueber voice_id_status_response), Threshold-Slider 0.30-0.70
(persistiert in voice_config.json, broadcast als config-Message),
Refresh + Delete-Button.
- server.js: 2 neue actions (voice_id_status, voice_id_delete),
send_voice_config nimmt voiceIdThreshold mit auf.
Backend:
- speaker_id.py: _normalize_audio_bytes erkennt jetzt WAV-Header
(RIFF/WAVE) und strippt auf rohes PCM — sonst werfen die ECAPA-
Embeddings auf den 44-Byte-Header rein.
- bridge.py: config-Broadcast-Handler setzt voiceIdThreshold auf
speaker_id.DEFAULT_THRESHOLD (wird erst in Phase 3 beim Gating
genutzt, persistiert aber schon).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: ARIAs VOICE_COMMUNICATION-Lock liess WhatsApp-Sprachnachrichten,
Sprach-Suchen u.ae. nur Stille aufnehmen — Android-Audio-Policy gibt
der zweiten App formal Audio, liefert aber Null-Samples solange unsere
Pipeline aktiv ist.
Fix: AudioRecordingCallback (API 24+) registriert sich beim start() und
beobachtet andere Mic-Sessions. Fremder Mic-User detected → unsere
AudioRecord + Effects sofort freigeben (externallyPaused=true), WakeLock
+ Callback bleiben aktiv. Fremder weg → 300ms warten (Audio-Stack-
Settling), nochmal pruefen, dann reaktivieren.
Refactor mit drin: start()/stop() benutzen jetzt zwei Helper
(acquireAndStartRecording, stopAndReleaseRecording) damit die Mic-Setup-
Logik nicht zwischen start() und resumeAfterExternal() dupliziert wird.
Trade-offs:
- Wake-Word taub solange andere App das Mic nutzt — akzeptabel.
- API < 24: kein Callback verfuegbar, alter Stand (kein Mic-Sharing).
- Phone-Call kollidiert nicht mit phoneCallService.pauseForCall —
beide pausieren/reaktivieren idempotent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Frueher: Spotify-spezifische Patterns hardcoded in agent.py — jeder neue
Steuer-Skill haette wieder Brain-Code-Aenderungen gebraucht.
Jetzt: jeder Skill deklariert seine eigenen Patterns im Manifest unter
fast_patterns: [{match, args, reply}]. Brain iteriert generisch, kein
Skill bekommt Sonderbehandlung.
- agent.py: _try_skill_fast_path liest aus skills.list_skills(), keine
Spotify-Konstanten mehr. skill_create/skill_update Tool-Schema kennt
fast_patterns (mit Beispiel + Wann-nutzen-Hinweis).
- skills.py: _normalize_fast_patterns validiert Regex + filtert kaputte
Eintraege; create_skill/update_skill akzeptieren das Feld.
- main.py: einmalige Lifespan-Migration — wenn spotify-Skill existiert
und kein fast_patterns hat, werden die alten Hardcoded-Patterns
rueberkopiert. Idempotent, laeuft bei jedem Restart sicher mehrfach.
- seed_rules.py: neue Regel `seed/skill-rule/fast-patterns-for-control`
erklaert ARIA wann sie das Feature nutzen soll (reines Steuern: ja,
kreativer Output / Parametrisierung: nein) — mit Beispiel.
Trade-off: Volume-Patterns (lauter/leiser) fallen aus dem Fast-Path raus,
weil die Multi-Step-Logik (GET state → compute → PUT) sich nicht
deklarativ ausdruecken laesst. Wer das zurueck will: Spotify-Skill um
einen action=volume_relative-Arg erweitern der die Mathe intern macht.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- WakeWord Doppel-Trigger: detectionInProgress-Guard gegen Native-Event-
Race + setBackground/setForeground statt setResumeCooldown im AppState.
- Media-Pause beim App-Oeffnen: 1.5s Startup-Suppression im Kotlin
emitDetected() — Mikro-Spin-up-Spike triggert kein false-positive mehr.
- Spotify Fast-Path im Brain: einfache Media-Commands (naechster Track,
pause, play, lauter, ...) matchen via Regex und gehen direkt aufs
spotify-Skill statt durch Claude. ~1.5s statt 5-10s pro Befehl.
- File-Limit auf 1 GB hochgezogen (war 70 MB). RVS maxPayload +
Bridge max_size auf 1500 MB; Node-Heap im RVS-Container auf 4 GB.
- TriggerBrowser / Datei-Manager Connection-Refused: brainApi._send
fast-failt bei disconnected RVS statt 30s zu timeouten, und beide
UIs reloaden automatisch beim Reconnect-Event.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stefan's UX-Wunsch: Datei direkt oeffnen ohne Umweg ueber Download.
Plus: in der App fehlte komplett der Per-Row-Download-Button (nur via
Checkbox + Bulk-Download). Beides jetzt gefixt.
App (SettingsScreen.tsx):
- Neue per-Row-Buttons: 👁 Open + ⬇ Download + 🕒 Versionen + 🗑 Loeschen
- Open-Pfad nutzt requestId-Praefix 'open-' im file_response-Handler
→ Datei wird nach CachesDirectory geschrieben (kein Storage-Bloat)
→ FileOpener-Native-Module (Intent.ACTION_VIEW mit MIME) oeffnet
mit dem System-Picker → User waehlt PDF-Viewer / Galerie / Player
- guessMimeFromName-Helper fuer den Intent damit Android die passende
App findet
- Download-Pfad unveraendert ('single-' Praefix), schreibt nach
DownloadDirectory mit Suffix-Inkrement bei Namens-Konflikt
Diagnostic (server.js + index.html):
- Neue Route /api/files-view (gleicher Code-Pfad wie files-download,
aber Content-Disposition:inline + echter MIME-Type statt octet-stream)
- Browser zeigt PDF / Bilder / Text im neuen Tab statt forcierten Download
- 👁-Button in jeder File-Row neben ⬇/🕒/🗑
- Fallback fuer unbekannte MIMEs: octet-stream → Browser bietet Download
Bei beiden Pfaden bleibt der Cache nutzbar: nach App-Open kann der User
die Datei im jeweiligen Viewer behalten; im Browser bleibt sie im Tab.
Stefan-Reproduktion: nach Wake-Word + ARIA-Antwort oeffnet das
Conversation-Window automatisch das Mikro fuer Follow-Up. Wenn Stefan
nichts sagt, ist das 4-8s Stille. Whisper halluziniert dann YouTube-
Untertitel-Patterns aus seinem Trainings-Corpus — gemessen 'Untertitelung
des ZDF, 2020' — und ARIA antwortet brav darauf. Endlos-Loop bis Stefan
manuell stoppt.
Fix in faster-whisper-transcribe:
1. Per-Segment no_speech_prob auswerten. Bei >= 0.6 (relativ konservativ:
echte leise Sprache geht noch durch) → Segment verwerfen. Das eliminiert
die offensichtlichen Halluzinationen schon zu 90%.
2. Bekannte Hallucination-Phrasen-Blacklist:
- Untertitelung/Untertitel des ZDF (mit/ohne Jahr)
- Amara.org community
- Vielen Dank fuer's Zuschauen (mit allen Umlaut/Apostroph-Varianten)
- Thanks for watching / Subs by ...
Substring-Match (case-insensitive) auf normalisiertem Text (lowercase,
Trailing-Punctuation und Jahres-Suffix '2020' weg).
3. Wenn ALLE Segmente einer Aufnahme rausgefiltert werden, ist text=''
→ App behandelt das via existierende no-speech-Pfad: Conversation-
Window endet sauber, kein TTS-Echo-Loop.
Tradeoff: echte Phrasen wie 'Vielen Dank' allein gehen durch (Pattern
ist 'vielen dank fuer's zuschauen' — voller Match). Nur die bekannten
Halluzinations-Phrasen werden weggefiltert.
Falls in Zukunft neue Patterns auftauchen (Whispers Modell ändert sich):
einfach _HALLUCINATION_PHRASES erweitern, kein Brain-Restart noetig (lebt
in der Whisper-Bridge, die hot-reloaded werden kann).
Stefan-Beobachtung: Wenn man V1 restored, taucht der neue Restore-Commit
als V4 in der Liste auf, mit identischem Inhalt wie V1. Bei mehrfachem
Hin- und Herrestoren wird die Liste schnell unuebersichtlich.
Fix: listVersionsForFile dedupliziert auf Blob-Hash-Ebene. Pro
inhaltlich identischer Datei-Variante wird nur der AELTESTE (= zuerst
in der History entstandene) Commit gezeigt. Restore-Commits werden
damit gefiltert da ihr Blob = der Blob eines aelteren Commits ist.
AKTIV-Marker wandert mit: vergleicht Blob der Working-Copy mit jedem
sichtbaren Eintrag — der Match-Eintrag bekommt isCurrent=true. So
zeigt das UI nach Restore "V1 ist AKTIV" obwohl im git ein neuer
V4-Commit existiert.
Implementation:
- log --format=%H + ls-tree pro Commit → blob-hash sammeln
- rueckwaerts durchgehen (chronologisch aelteste zuerst), seen-Set
dedupliziert
- Reverse fuer UI (neueste-zuerst)
- git hash-object <working-copy> → currentBlob, mit jedem Eintrag
vergleichen fuer den AKTIV-Marker
- blob-Feld aus Response strippen (sieht aus wie zweite Commit-ID)
Audit-Trail bleibt im git intakt — Restore-Commits sind weiterhin
da, nur nicht im UI sichtbar. Falls jemals forensische Untersuchung
noetig: `git log` im /shared/uploads zeigt alle, inkl. Restore-Commits.
Step 3 vom File-Versioning-Feature (Step 1+2 lief schon in Diagnostic).
App kann jetzt:
- pro Datei via 🕒-Button die Versions-Liste anzeigen
- alte Versionen als '<name>@<short-hash>.<ext>' nach Downloads schreiben
- per ⟲ eine alte Version als neue aktive setzen (non-destructive,
macht im Backend einen 'restore:'-Commit, die bisherige Version
bleibt in der Historie)
Drei neue RVS-Message-Type-Paare:
file_version_list_request / _response
file_version_download_request / _response (base64)
file_version_restore_request / _response
rvs/server.js: alle sechs Typen in die ALLOWED_TYPES-Whitelist.
bridge/aria_bridge.py: handler proxen die Anfragen an diagnostic
(http://localhost:3001/api/files-versions / -version-content / -restore).
Diagnostic ist eh schon der Owner der git-Repository-Logik. Bridge
wrappt die Binary-Antwort als base64 fuer den RVS-Transport.
android/src/screens/SettingsScreen.tsx:
- State versionsOpen/versionsList/-Loading/-Error
- Drei rvs.onMessage-Branches fuer die neuen *_response Types
- 🕒-Button in jeder Datei-Zeile (zwischen Auswahl-Checkbox und Mülltonne)
- Neues Modal mit Versions-Liste (AKTIV-Badge, short-hash, subject,
formatiertes Datum, ⬇ Download + ⟲ Restore-Button pro Eintrag)
- Restore-Button hat Confirm-Alert
- Bei file_version_restore_response: list refresh + file-list refresh
Stefan-Wunsch: ARIA-Aenderungen an Dateien sollen vom System (nicht
von ARIA selbst) automatisch versioniert werden. Im Datei-Manager:
Versionen auflisten, einzelne downloaden, oder als neue aktive Version
setzen (Restore = non-destructive neuer Commit).
Implementierung (alles im diagnostic-Container, da der eh schon
File-Handling kann):
1. Dockerfile: apk add git
2. server.js — Auto-Commit-Loop:
- Beim Start: /shared/uploads als git-Repo initialisieren (idempotent;
bestehendes .git wird uebernommen)
- setInterval(30s): git status --porcelain → wenn dirty, add+commit
mit "auto: <ISO-Timestamp>"-Message
- Re-Entrancy-Guard fuer langsame git-Ops
3. server.js — drei neue HTTP-Routen:
GET /api/files-versions?path=X
→ [{hash, ts, subject, isCurrent}] aus git log --follow
GET /api/files-version-content?path=X&hash=Y
→ Binary-Stream der Datei aus diesem Commit (Content-Disposition
attachment mit "name@<short-hash>.ext" als Default-Dateiname)
POST /api/files-version-restore body={path, hash}
→ non-destructive: schreibt alten Inhalt als NEUE Version, neuer
Commit "restore: <path> <- <short>". Aktive Version damit
weiterhin rollback-bar.
4. index.html — Datei-Manager:
- Pro Datei zusaetzlich 🕒-Button neben ⬇/🗑
- Klick zeigt Modal mit Version-Liste (timestamp, short-hash,
'AKTIV'-Marker fuer den jeweils letzten)
- Pro Version: ⬇ Download + ⟲ Restore (mit Confirm)
- Restore broadcasted file_version_restored damit Browser refreshen
Path-Safety: alle Pfade muessen relative-to-uploads sein, kein '..',
kein '/', kein '.git/'. Hash muss [0-9a-f]{7,40}.
.gitignore zunaechst keine — uploads/ ist eh nur User-/ARIA-Dateien,
kein Log-Noise erwartet. Falls Disk explodiert: spaeter ergaenzen.
Step-2 (App-Side via RVS-Messages) folgt im naechsten Commit, sobald
das hier in Diagnostic funktioniert.
Stefan testet Spotify-Resume nach TTS, klappt noch nicht. Aktuell sehe ich
in den App-Logs nur 'PcmPlaybackFinished native event' aber NICHT ob
requestDuck / release / nudgeMediaResume jemals laufen.
Logge jetzt:
audio.focus: 'TTS-start: requestDuck() called + canceled pending release'
audio.focus: '_releaseFocusDeferred SKIPPED (conversation active)' (skip case)
audio.focus: '_releaseFocusDeferred scheduled in 800ms'
audio.focus: 'release timer fired but conversation now active → SKIP' (race)
audio.focus: 'AudioFocus.release() now'
audio.focus: 'nudgeMediaResume() now (50ms after release)'
Damit beim naechsten Stefan-Test eindeutig zuordenbar wo der Resume-
Pfad genau klemmt — feuern beide native Calls? Werden sie geskipped?
Greift der Cancel zu frueh? etc.
Wurzel: claude-max-api-proxy's subprocess/manager.js passt den Prompt
als letztes CLI-Argument an spawn('claude', [...args, prompt]). Bei
ARIA's groesseren Prompts (52 Messages + 24 Tools = ~80-100 KB) ueber-
schreitet das den Linux-Kernel-Limit fuer Argument-Listen → spawn
wirft E2BIG → Proxy gibt 500 zurueck → Brain wirft 502 → aria-bridge
wrappt als '[Brain-Fehler] HTTP Error 502: Bad Gateway' und sendet
das als Chat-Bubble + TTS. Stefan sieht 'Bad Gateway' und die App
spricht das auch noch aus.
Fix per zwei zusaetzlichen sed-Patches in docker-compose.yml die beim
Proxy-Start neben den bestehenden ausgefuehrt werden:
1. Loescht die 'prompt, // Pass prompt as argument'-Zeile aus
buildArgs() — claude-CLI bekommt den Prompt nicht mehr per argv
2. Aendert this.process.stdin?.end() in start() zu
this.process.stdin?.end(prompt) — Prompt wird nach Spawn via
stdin geschrieben und stdin sofort danach geschlossen
Test: 'echo "test" | claude --print' funktioniert sauber. Stdin hat
kein Limit wie argv (E2BIG). Original-Kommentar 'more reliable than
stdin' war wohl von einer alten CLI-Version — aktuelles claude-CLI
reads stdin in --print mode korrekt.
Idempotent: beim Container-Restart sind die seds no-op (gemusterter
Code schon nicht mehr da).
Bonus-Wert: claude-max-api-proxy npm package muss man nicht patchen,
unsere Aenderungen ueberleben package-Updates (sed im compose-command).
Stefan-Symptom: Spotify pausiert wenn ARIA zuhoert/spricht ✓, aber
resumed nach TTS-Ende NICHT (oder nur unzuverlaessig).
Diagnose aus dem Log-Trace:
Manual-Button-Flow:
recording start → AudioFocus.requestExclusive (Spotify pausiert)
STT-Endpoint → _cleanupStreamLocal → _releaseFocusDeferred → 800ms
Timer → release+nudge (Spotify wuerde resumen)
Brain processing 50s lang...
TTS-Start → KEIN expliziter Focus-Request! AudioTrack-USAGE_
ASSISTANT pausiert Spotify nur IMPLIZIT (versions-
abhaengig). Wir wissen nicht ob Spotify gerade
gepaust ist.
TTS-End → release+nudge — aber wenn Spotify implizit-paused
ist, hat es keinen sauberen Focus-Owner gesehen
und nudge alleine reicht nicht zum Resume.
Wake-Word-Flow:
Aehnliches Problem wenn state schon armed ist beim TTS-Ende
(vom 'no-speech in conv-window'-Pfad), dann ist mein
endConversation ein noop → releaseConversationFocus laeuft nie,
nur die PcmPlaybackFinished-Direkt-Release greift, hat aber
dasselbe nudge-zu-schwach-Problem.
Fix in _firePlaybackStarted (audio.ts): EXPLIZITES AudioFocus.requestDuck
beim ersten TTS-PCM-Chunk. Damit IST Spotify ueber unseren Focus
gepaused, statt nur implizit. Der spaetere release beim Pcm-Playback-
Finished ist dann das normale 'Owner ist fertig'-Pattern das Spotify
zuverlaessig zum Resume triggert.
Idempotenz: requestDuck released vorher den vorigen Focus (in Kotlin),
also harmlos wenn wake-word-acquireConversationFocus eh schon requestDuck
gerufen hat. Plus _cancelDeferredFocusRelease vorne, damit kein noch
pendender 800ms-Timer mitten in der TTS Spotify falsch resumed.
Stefan testet im Auto und auf dem Tisch — beide Spotify-Versionen
sollten mit explizitem Focus-Owner-Wechsel sauber pausieren+resumen.
Stefan-Beobachtung: Wetterbericht klang scheisse, weil 'kt'/'°C'/Komma-
Zahlen literal vorgelesen wurden. Der clean_text_for_tts-Regex kennt nur
Markdown + ein paar Uhrzeit-Patterns — Einheiten ausschreiben war never
on the table mit Regex (waechst sonst zur Mammut-Tabelle).
Loesung: hybrid. ARIA selbst macht die semantisch korrekte TTS-Variante,
Regex bleibt als Safety-Net.
bridge/aria_bridge.py:
- neue Helper strip_voice_tag_for_display(text)
- chat-broadcast (1188) und chat_backup-Persist (1157) strippen den Tag
BEVOR Output. ARIA's `<voice>...</voice>` lebt nur transient in `text`
bis clean_text_for_tts ihn fuer TTS extrahiert.
- clean_text_for_tts unveraendert (kennt den Tag schon seit Phase 1).
aria-brain/seed_rules.py:
- neue seed-rule voice/tts-voice-tag mit klarer Trigger-Liste
(Einheiten, Komma-Zahlen, Uhrzeiten, Statusberichte, lange IDs/Codes)
- klare Anti-Trigger (kurze 'OK'/'mache ich'-Antworten — kein doppelter
Text fuer Trivia)
- drei konkrete Beispiele (Wetter, Uhrzeit, Server-Status, Music-Now-Playing)
Output-Format: ARIA schreibt erst Chat-Display-Variante (mit Markdown OK),
haengt dann an EINER neuen Zeile den <voice>-Block an. Tag wird automatisch
gestrippt fuer App-Anzeige + Chat-Backup. f5tts kriegt nur den voice-Inhalt.
Beide Welten happy: Stefan sieht hervorgehobenes Markdown in der Bubble,
hoert sprechbar formulierten Text aus dem Lautsprecher. Keine wachsende
Regex-Tabelle mehr.
Deploy: brain rebuild + restart, bridge restart.
Im Stefan-Test (31.05.2026) hat ARIA das spotify-Skill korrekt mit
_all=true aufgerufen, der Skill paginierte sauber alle 90 Playlists
in 34 KB compact JSON. Aber: ARIA's Thinking sagte 'tool result was
cut off'. Disk-Log + Skill-Return waren beide OK. agent.py:1160 cap
ist 50 KB (passt). Aber:
agent.py:947 cap'd tool_result[:8000] beim Append in die Proxy-
Conversation. DRITTER Truncation-Punkt fuer denselben Output, fuer
Claude effectiv abgeschnitten — Skill liefert 34 KB, ARIA sieht 8 KB.
Auf 50 KB hochgesetzt (konsistent mit dem anderen Cap in derselben
Datei). Tests + nochmal restart noetig.
Lerneffekt: bei Stdout-Caps suche IMMER alle Truncation-Punkte. War
jetzt der dritte den ich heute gefunden hab. ;-)
Stefan-Reproduktion vom 31.05.2026: bei 'Such Playlist Prodigy raus'
hat ARIA die Spotify-Pagination drei Mal hintereinander laufen lassen,
jedes Mal eine andere Playlist-ID gefunden, am Ende falsche abgespielt.
Spotify sortiert /v1/me/playlists nach recently-played — die Reihen-
folge aendert sich zwischen Calls wenn parallel was laeuft, also
liefern aufeinanderfolgende paginierte Runs inkonsistente Snapshots.
Loesungen:
1. **spotify-Skill _all=true** (via skill_update angewendet, lebt nur
in /data/skills/spotify/ im Container, nicht in git): Skill prueft
_all=true im URL-Query, paginiert dann intern ueber Spotifys
next-Field bis MAX_PAGES (20) oder fertig. Liefert konsolidiertes
JSON {items, total, fetched_count, fetched_pages}. EIN Tool-Call,
konsistenter Snapshot.
2. **skills.py: Stdout-Truncation entkoppeln**. Vorher: 8000-char-Cap
sowohl fuer Disk-Log als auch fuer Return-Value an Agent.
Konsequenz: _all=true Output (50 KB JSON) wurde fuer ARIA auf 8 KB
gekuerzt, sie sah nur die ersten ~20 Playlists. Jetzt:
- Disk-Log: weiterhin 8 KB pro stdout (Disk-Schoner)
- Return-Value: ungekuerzt, agent.py macht 50 KB downstream-Cap
Skills.py:687 — record-Dict aufgesplittet in log_record + record.
3. **seed_rule list-api-pagination-snapshot**: dokumentiert das
Pattern fuer ARIA — bei Pagination-Resultaten IMMER vollstaendig
laden bevor Entscheidung; _all=true bevorzugen wo verfuegbar;
bei inkonsistenten Match-Resultaten ehrlich nachfragen statt
raten. Mit konkreter Antipattern-Sammlung aus Stefans Test.
Deployment: brain restart noetig damit (2) und (3) greifen. Skill-
Code (1) ist schon via PATCH /skills/spotify aktiv.
Wake-Word-Listener nach jeder Konversation
Aus dem Log diagnostiziert: zwei onPlaybackFinished-Listener feuern
direkt hintereinander wenn TTS endet:
1. mein neuer Listener (Background): endConversation()
→ state=armed, OpenWakeWord.start() (idempotent)
2. existierender Listener: stopBargeListening()
→ bargeListening=true → OpenWakeWord.stop() ← killt re-armed Listener
State zeigte 'armed' (UI: Ohr-Icon ausgefuellt, sieht aktiv aus), aber
das Native-Modul war gestoppt → Stefan's "Computer" verpufft.
Fix: endConversation setzt bargeListening=false BEVOR Native gerufen
wird. stopBargeListening checkt das Flag oben:
async stopBargeListening() { if (!this.bargeListening) return; ... }
→ wird zum No-Op wenn endConversation schon gelaufen ist.
Bonus: OpenWakeWord.start() darf jetzt auch gerufen werden wenn der
Listener via barge-listening schon lief — Kotlin checkt running.get()
und resolved idempotent. Sicherer als state-vorher-Check.
Bug: '⬇ Download' im Dateimanager schickte file_request raus, aber kein
SettingsScreen-Handler nahm das file_response auf. ChatScreen fing es
zwar global ab, versuchte aber nur Chat-Bubble-Attachments zu
patchen — kein Match, also passierte sichtbar nichts.
Fix: Handler in SettingsScreen fuer file_response mit requestId-Praefix
'single-' (aus bulkDownload-1-Datei-Pfad). Schreibt nach
RNFS.DownloadDirectoryPath, mit Suffix-Inkrement bei Namens-Konflikt
damit nichts ueberschrieben wird.
Multi-Datei-Download (ZIP) lief schon ueber file_zip_response,
unangetastet.
Race-Condition entdeckt im Log: nach jeder ARIA-Antwort lief
endConversation 5s nach TTS-Start (= "letzter Chunk eingetroffen"),
nicht wenn der AudioTrack-Hardware-Buffer wirklich am Ende war. ARIA
sprach also noch hoerbar, waehrend OpenWakeWord schon re-armte.
Folge: ARIAs eigene Stimme ging direkt nach AudioRecord.startRecording
ins Mikro. Die OpenWakeWord-Sessions von AudioRecord und AudioTrack
sind verschieden → AcousticEchoCanceler kann den Output nicht
subtrahieren (kein gemeinsamer Reference-Stream). Threshold +
Patience-State der Wake-Word-Inferenz wird durch ARIAs konstante
Audio-Eingabe verwirrt, der naechste echte "Computer"-Trigger geht
unter.
Fix: Listener-Fire aus handlePcmChunk(isFinal=true) raus, dafuer in
den schon existierenden PcmPlaybackFinished-Native-Event-Handler
rein. Die Kotlin-Seite emittiert das Event aus dem Writer-Thread-
finally-Block — also genau dann wenn AudioTrack alle Samples
durchgeschrieben hat.
Side-Effect: UI-Konsumenten von onPlaybackFinished sehen den
"finished"-State jetzt 1-2s spaeter (= ehrlicher zur Realitaet,
ist eigentlich eine UX-Verbesserung).
Stefan beobachtet dass Wake-Word nach Conversation manchmal nicht
re-armt. endConversation hatte bisher kein RVS-Logging — wir waren
beim Diagnose blind.
Loggt jetzt:
- 'endConversation called but state=X → noop' (state-Mismatch)
- 'endConversation called, calling OpenWakeWord.start()' (Eintritt)
- 'OpenWakeWord.start() OK → state=armed' (Erfolg)
- 'OpenWakeWord.start() FAIL: ... → state=off' (Native-Fehler)
- 'fallback: nativeReady=false → state=off' (kein Native-Modul)
Damit sehen wir im naechsten Test welcher Pfad gegriffen hat und ob
das Native-Modul ueberhaupt aufgerufen wurde.
Symptom: Wake-Word laeuscht nach erfolgreicher Konversation im
Hintergrund nicht wieder — erst beim App-Vorholen wird's wieder
armed. Grund: nach TTS-Ende laeuft wakeWordService.resume() in
einen setTimeout(800ms) der im Doze stark verzoegert wird. Der
verspaetete Timer findet dann delay > 2800 und ruft endConversation
(re-arm) — aber eben erst beim App-Resume.
Fix: in onPlaybackFinished AppState pruefen:
active → resume() wie bisher (Multi-Turn-Conversation-Window)
background → endConversation() direkt — kein setTimeout, native
OpenWakeWord.start() greift sofort.
Begruendung fuer das Verhalten:
- Foreground: User ist aktiv, Multi-Turn-Dialog ohne erneutes
"Computer"-Sagen ist nuetzlich.
- Background: User nutzt das Handy anderweitig, automatisches Mikro-
Oeffnen ist nicht erwartet und droht durch Doze-Verzoegerung in
ein Phantom-Trigger-Mismatch zu kippen. Direkt re-armen ist
robust + erwartungskonform.
Eng verwandt mit dem 0.1.7.0-Fix (kein setTimeout zwischen
wake.detect und Callback) — selbes Doze-Throttling-Pattern, andere
Stelle in der Pipeline.
VoiceButton rewrite — dB/VAD-Pfad endgueltig raus. Knopf ist jetzt nur
noch UI-Trigger:
- onTapStart (ChatScreen baut Bubble + startStreamingRecording)
- onTapStop (ChatScreen ruft stopStreamingRecording)
- audioService.onStateChange treibt die Animation (statt internem
isRecording-Flag)
- onSilenceDetected-Subscription weg
ChatScreen:
- handleVoiceRecording (Legacy) → handleVoiceButtonStart +
handleVoiceButtonStop
- Bubble wird beim Tap SOFORT gebaut (vorher: erst nach Stop), Text
landet via audioRequestId-Match im chat-Handler-Update-Pfad
- noSpeechTimeoutMs=0 (manueller Modus, User kontrolliert via Tap),
hardCapMs=300_000 (5 Minuten Notbremse)
- Wake-Word-conversing + manueller Stop = endConversation (User
will nicht in Multi-Turn-Modus)
- RecordingResult-Import entfaellt (nicht mehr genutzt)
Damit ist die komplette App-seitige Aufnahme auf Streaming + ML-
Endpointer. Der ganze dB/VAD-Apparat (vadEnabled, vadBaselineSamples,
loadVadSilenceDbOverride, vadTimer, noSpeechTimer, etc.) ist jetzt
nur noch Dead-Code — wird in einem Folge-Commit gemeinsam mit dem
zugehoerigen Settings-Slider abgeraeumt.
Symptom: User sagt "Naechstes Lied bitte", ARIA spielt Track, Display
geht aus, User holt 10s spaeter die App vor und sieht "Aufnahme laeuft"
— als haette er Wake-Word gesagt. Klassisches Doze-Throttling: nach
TTS-Ende schedulet resume() einen setTimeout(800ms) der den Conversation-
Window-Callback feuert. Im Hintergrund parkt der JS-Thread, der Timer
feuert erst beim App-Resume — gefuehlt ein Phantom-Trigger.
Fix: scheduledAt-Timestamp messen, Delay nach dem setTimeout pruefen.
Wenn der Timer >2.8s ueberfaellig ist (Schwelle = 800ms + 2000ms
Toleranz), JS war im Background → endConversation statt Mikro-oeffnen.
Wenn der User wirklich nachfragen will sagt er einfach nochmal "Computer".
Die ALLOWED_TYPES-Whitelist im RVS-Hub droppte stt_stream_start /
stt_audio_chunk / stt_stream_end / stt_partial / stt_endpoint /
stt_stream_done silent — App schickt, niemand kriegt. Das hat
Phase 1+2 komplett tot gemacht obwohl App + Whisper-Bridge
korrekt deployed waren.
Sechs neue Types eingetragen, dann fluppt's.
streamEndpointFired-Latch + neue _fireEndpoint(ev)-Methode konsolidieren
die drei Pfade die den Endpoint-Listener feuern (RVS-stt_endpoint, cancel,
neuer Fallback). Listener feuert pro Session-Cycle maximal einmal.
stopStreamingRecording bekommt einen 3-Sekunden-Watchdog: kommt in dem
Fenster keine echte stt_endpoint-Antwort der Bridge, feuert der
Listener mit text='' (reason=stop:...:no-response) damit ChatScreen
die "wird verarbeitet"-Bubble unstickt + endConversation aufruft.
Greift praktisch in zwei Faellen:
- Whisper-Bridge laeuft alte/keine Streaming-Version (Stefan Gamebox-
Restart vergessen) → wir bleiben sonst bis zur 60s-Hardcap haengen
- User-initiated Stop + Whisper langsam/crashed
Stefan's Gamebox ist Windows (kein SSH-Zugriff), und in Zukunft
koennten whisper/f5tts auf separaten Hosts laufen. Wir brauchen
deshalb einen Logging-Pfad ueber RVS — gleicher Mechanismus wie
fuer die App (reportAppDebug).
Beide Bridges senden jetzt app_log-Messages mit platform="whisper"
bzw. "f5tts". aria-bridge schreibt sie in /shared/logs/app.log
(unverändert), Live-Logs-Tab + Diagnostic /api/app-log lesen mit.
Toggle via aria-bridge config:
whisperDebugLog: bool — default OFF (aktuell aber ON in
whisper-bridge weil wir Phase-1/2-
Pipeline einfahren)
f5ttsDebugLog: bool — default OFF
Beide werden in voice_config.json persistiert + nach RVS-Connect
rebroadcastet, damit Toggle Container-Restart ueberlebt.
Whisper-Bridge logt aktuell:
boot → Streaming-Mode-Marker (sehen wir damit ob
neue Version aktiv ist)
stream.start → stt_stream_start angekommen
stream.chunk → alle 25 Chunks (=5s Audio) einer
stream.chunk.reject → Chunk fuer unbekannte Session
stream.partial → Whisper hat neuen Text erkannt
stream.final → Endpoint detected, finaler Text raus
stream.end → stt_stream_end angekommen
config → Toggle umgeschaltet
F5TTS-Helper ist da (gleicher Pattern), Logging-Punkte kommen
spaeter wenn wir ein konkretes TTS-Problem zu debuggen haben.
audio.ts:
- neue Methoden startStreamingRecording / stopStreamingRecording /
cancelStreamingRecording mit PcmStreamRecorder als AudioRecord-Source
- permanenter RVS-Listener fuer stt_partial / stt_endpoint / stt_stream_done,
Filterung ueber streamRequestId-Match
- Callbacks onSttEndpoint(SttEndpointEvent) + onSttPartial(text)
- No-Speech-Watchdog + App-seitiger Hard-Cap (+2s Toleranz gegen Bridge)
- cancelStreamingRecording feuert onSttEndpoint mit text='' damit
ChatScreen den No-Speech-Fall behandeln kann (wie frueher
onSilenceDetected -> stopRecording() -> null)
- Legacy startRecording / stopRecording / onSilenceDetected unangetastet
-- VoiceButton (manuelle Aufnahme) nutzt das weiterhin
ChatScreen.tsx:
- Wake-Callback: startRecording -> startStreamingRecording
- Bubble wird sofort gebaut, audioRequestId landet via
stt_endpoint -> chat(sender=stt) im chat-Handler-Update-Pfad wie bisher
- onSilenceDetected entfernt, ersetzt durch onSttEndpoint:
text != '' -> log, aria-bridge triggert Brain selbst (Phase-2-Shortcut)
text == '' -> endConversation (No-Speech-Fall)
- Barge-In via Wake-Word: ebenfalls auf Streaming umgestellt
- AppState-resume + toggleWakeWord-off pruefen jetzt isStreamingRecording()
und nutzen passenden Cancel
Damit: kein dB/VAD mehr im Hot-Path. Whisper hoert auf semantische
Stille (kein neuer Text), Brain bekommt den Text direkt von aria-bridge,
Audio-Roundtrip App->aria->whisper->aria->App entfaellt komplett.
Neues Native-Modul fuer die Streaming-STT-Pipeline:
PcmStreamRecorder.start() — oeffnet AudioRecord 16 kHz mono PCM,
VOICE_COMMUNICATION-Source mit AEC/NS,
PARTIAL_WAKE_LOCK gegen Doze
PcmStreamRecorder.stop() — sauber schliessen
Event "PcmStreamChunk" — {pcm: base64-s16le, seq, ts} alle 200ms
Event "PcmStreamError" — bei Capture-Crash
200ms-Chunks: gross genug fuer geringen RVS-Overhead, klein genug fuer
granulares Endpointing in der Whisper-Bridge.
Mic-Ownership: darf NICHT parallel zu OpenWakeWord laufen — beide
wollen AudioRecord. Coordination liegt bei audio.ts (stop OWW vor
start, start OWW nach stop), genau wie's bisher mit react-native-
audio-recorder-player gemacht wurde.
Empfaengt das stt_endpoint-Event der Streaming-Whisper-Bridge und
uebernimmt den Pfad den sonst _process_app_audio NACH dem STT-Schritt
hat: broadcastet chat(sender=stt) fuer die App-UI-Bubble, baut den
Core-Text und ruft send_to_core(). Damit faellt der Audio-Roundtrip
App→aria→whisper→aria komplett weg — die App schickt nur noch
PCM-Chunks direkt an whisper-bridge, whisper meldet Endpoint, aria
forwarded sofort an Brain.
Echos voice/speed/interrupted/location aus dem App-Payload werden
respektiert wie beim Legacy 'audio'-Event. clean_text_for_tts +
ttsText-Embedding bleiben unveraendert da der TTS-Pfad ueber das
bestehende send_to_core laeuft.
Idempotenz via audioRequestId als client_msg_id — falls die App den
Stream durch einen Reconnect-Race nochmal triggern sollte.
source-Tag fuer den Brain-Log: "app-voice-stream" statt "app-voice"
damit man im Brain-Log sehen kann ob via Legacy- oder Stream-Pfad.
Neue RVS-Messages auf der Whisper-Bridge:
stt_stream_start {requestId, audioRequestId, language?, model?,
endpointMs?=1500, hardCapMs?=60000, voice, speed,
interrupted, location, sampleRate?=16000}
stt_audio_chunk {requestId, pcm: base64-s16le, seq}
stt_stream_end {requestId, reason}
stt_partial (Bridge→App, alle ~700ms, fuer Live-UI-Feedback)
stt_endpoint (Bridge→App+aria-bridge, finaler Text + alle Echos)
stt_stream_done (Bridge→App, signalisiert Session-Ende)
Endpointer-Logik:
- alle 700ms transkribiert die Bridge den Ringbuffer (beam_size=1, schnell)
- waechst der Transkript-String → Stagnation-Timer reset
- waechst er nicht → bei endpointMs ohne Wachstum: finalisiert
- bei hardCapMs (60s) sowieso finalisiert egal ob stagnierend
- Final-Transcribe nochmal mit beam_size=5 fuer Qualitaet
- stt_endpoint enthaelt voice/speed/interrupted/location echos,
damit aria-bridge in Phase 2 direkt an Brain weiterleiten kann
Legacy stt_request (One-Shot mit base64-mp4/wav) bleibt unveraendert
als Fallback.
Default-Parameter (alle vom App-Payload uebersteuerbar):
STREAM_TRANSCRIBE_INTERVAL_MS = 700 (Throttle)
STREAM_DEFAULT_ENDPOINT_MS = 1500 (Stille = kein neuer Text)
STREAM_DEFAULT_HARD_CAP_MS = 60000 (Schmerzgrenze)
STREAM_MIN_AUDIO_MS = 600 (erst transkribieren ab N Audio)
STREAM_SESSION_TTL_S = 120 (tote Sessions aufraeumen)
Ersetzt den dB/VAD-Stille-Trigger auf der App-Seite — Endpointer
hoert auf SEMANTISCHE Stille (kein neuer Text), nicht akustische.
Funktioniert im Auto / mit Musik im Hintergrund / in lauten
Umgebungen wo VAD versagt.