Projekt-Nachrichten aus der Zeit vor dem chat_backup-project_id-Feld (getaggt in
conversation.jsonl seit fc0f91d, aber chat_backup fuehrte project_id erst ab
f51ad15) lagen in der UI im Hauptchat statt im Projekt. Die App/Diagnostic
zeigen aus chat_backup.jsonl — dort fehlte der Tag.
Neue Einmal-Migration (Brain-Lifespan) schreibt project_id aus conversation.jsonl
per (role, text)-Match reihenfolge-erhaltend nach chat_backup.jsonl zurueck:
- idempotent via Marker /shared/config/.chat_backup_projectid_backfill_v1
- nicht-destruktiv: legt .pre-backfill-v1.bak an, setzt nur LEERE project_ids,
entfernt/aendert nie einen bestehenden Tag
- atomar (tmp + os.replace)
- Duplikate: Deque je (role, normtext) inkl. "" fuer Hauptthread → korrekte
Zuordnung auch bei wiederholten Texten, kein faelschliches Taggen von
Hauptchat-Interleaving
Mit Logik-Tests (Zuordnung, Duplikat-Reihenfolge, Idempotenz) verifiziert.
Nachrichten aus der Zeit bevor es Projekte gab bleiben untagged im Hauptchat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Das Dashboard hatte dieselbe Ursache wie die App (untagged Nachrichten in
chat_backup) PLUS zwei eigene untagged-Pfade ueber den Gateway-Watch:
- Frontend: chat_final rendert keine Chat-Bubble mehr. ARIA-Antworten kommen
ausschliesslich via rvs_chat (traegt projectId). chat_final stammt vom
Gateway-Watch ohne projectId → erzeugte eine Duplikat-Bubble im Hauptchat.
- server.js: kein chat_backup-Write im Gateway-final-Handler mehr. Die Bridge
(_process_core_response) ist massgeblicher Writer und schreibt MIT project_id;
der Write hier erzeugte untagged Duplikate, die beim Reload im Hauptchat
statt im Projekt landeten.
Der App-Focus-Fix (63dde65) sorgt dafuer dass neue Nachrichten ueberhaupt
getaggt in chat_backup landen — davon profitiert das Dashboard direkt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kernbug: ProjectsBrowser.load() pushte bei JEDEM Drawer-Oeffnen status.active
in den App-Focus. Im Multi-Threading hat das Brain keinen globalen
active_project-State mehr → status.active ist null → Focus wurde auf Hauptchat
zurueckgesetzt. Folge: nach jedem Drawer-Oeffnen landeten alle Nachrichten im
Hauptchat, Projekte blieben leer.
- ProjectsBrowser: neues Prop currentFocusId (App-Focus = Source-of-Truth).
load() uebernimmt nur noch die Projektliste, kein onActiveChanged(status.active)
mehr. Highlight (✓ FOCUS) folgt currentFocusId.
- ChatScreen: currentFocusId={focusedProjectId} durchgereicht.
- ChatScreen: direkter „← Hauptchat"-Button im Focus-Header (nur im Projekt
sichtbar) — ein Tap statt Drawer→Hauptchat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Zwei Bugs aus dem ersten Live-Test des Multi-Threading-Designs.
Bug 1 — Voice ignorierte App-Focus:
Stefan hat in Projekt X reingeguckt und was reingesagt — Message landete
im Hauptchat statt in X. Der Voice-Router auf der Bridge kannte den
sichtbaren Kontext der App nicht.
Fix:
- audio.ts.startStreamingRecording nimmt neuen opts.projectId und
schickt es im stt_stream_start-Payload mit.
- ChatScreen.tsx: alle 4 startStreamingRecording-Callsites (wake,
barge-in, passive, manuell) uebergeben focusedProjectIdRef.current.
Neuer useRef-Spiegel damit die Focus-ID auch in useCallbacks/
useEffects mit alten Closures aktuell bleibt.
- aria_bridge.py: neuer Handler fuer stt_stream_start speichert die
projectId in self._stt_stream_projects[requestId], stt_stream_end
loescht wieder. Beim stt_endpoint wird sie an _process_endpoint_text
weitergereicht und dort als default_project_id in den Voice-Router.
- _apply_voice_router bekommt neuen Prio-Rank 4: „App-Focus als
Default" — greift wenn kein Meta, kein Prefix und kein aktiver Sticky.
So folgt Voice ohne extra Marker dem sichtbaren Kontext.
Bug 2 — Back-to-Main-Regex zu eng:
„zurück ins hauptmenü" wurde nicht als Meta erkannt (Regex matchte nur
„zurück zum hauptchat") und landete deshalb im aktiven Sticky-Projekt.
Fix: Regex akzeptiert jetzt auch hauptmenü, menü, haupt, main mit
Praepositionen „zum/zur/ins/in den".
Bonus — Burger-Button heller:
Stefan konnte den ☰-Toggle im Header kaum sehen. Farbe von Default
(dunkelgrau) auf #E0E0F0 (hell) mit fontWeight 700 gesetzt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4 vom Multi-Threading-Redesign — der Voice-Layer routet STT-Text
per-Projekt und lässt Meta-Kommandos gar nicht erst ans Brain.
Voice-Router in _process_endpoint_text():
- „zurueck zum hauptchat" / „hauptchat bitte" / „aria hauptchat"
→ Sticky reset, project_changed(exited) broadcasten, KEIN Brain-Call.
- „fuer <name>: <text>" (Fuzzy-Match auf Projekt-Namen ≥ 0.6 Score)
→ Sticky auf gefundene project_id + Rest des Texts geht ans Brain
im Projekt-Kontext. project_changed(entered) broadcasten damit
App/Diagnostic den Focus mit umschalten.
- Sticky-Timeout 30s: eine Voice-Message ohne Prefix innerhalb des
Fensters bleibt im Sticky-Projekt, refresht das Timeout. Nach Ablauf
→ Default Hauptchat.
- Meta-Kommandos aendern KEINEN Brain-State — ARIAs Arbeit in laufenden
Projekten wird nicht abgebrochen.
send_to_core wird jetzt mit dem gerouteten project_id gerufen; das Brain
bekommt den Text im richtigen Queue-Kontext.
Broadcast-Chain: Voice-Router setzt Sticky → project_changed geht via RVS
an App+Diagnostic → Focus-Header/Kontext-Strip wechseln automatisch.
Damit ist der komplette Multi-Threading-Redesign abgeschlossen:
- Brain: per-Request project_id + per-Projekt Queue + Queue-Aware Prompt
- Bridge: Chat-Routing + Voice-Router
- App: Focus-One + Drawer + Status-Dots
- Diagnostic: Kontext-Strip + Focus-Filter
- Voice: Sticky + Meta-Interception
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 vom Multi-Threading-Redesign. Diagnostic zeigt einen scrollbaren
Streifen von Kontext-Karten ueber dem Chat (Hauptchat + Projekte), jede
mit Live-Status-Dot. Tap wechselt den Focus, Chat filtert auf diesen
Kontext, Sende-Input laeuft mit der Focus-ID durch Bridge → Brain-Queue.
index.html:
- Neuer <div id="chat-context-strip"> ueber der Chat-Box, horizontal
scrollbar.
- JS: focusedContextId (in localStorage gespiegelt), diagQueueStatus,
diagProjectsCache. renderContextStrip() zeichnet Karten mit Dot
+ Status-Label. switchDiagFocus(id) wechselt Focus + versteckt
Bubbles anderer Kontexte via data-project-id + style.display.
- Polling: /api/brain/projects/queue-status alle 2s, /projects/list
alle 15s.
- addChat: nimmt options.projectId → schreibt data-project-id an die
DOM-Node, versteckt sofort wenn Focus abweicht.
- Chat-Reception-Handler propagiert p.projectId aus dem RVS-Payload.
- testRVS() sendet msg.projectId=focusedContextId mit.
server.js:
- sendToRVS(text, isTrace, projectId): neuer Param, wird in
payload.projectId gesetzt → Bridge routet an /chat body.project_id.
- test_rvs-Handler reicht msg.projectId durch.
Bewusst nicht drin (Follow-up wenn Stefan mag):
- Voller Dashboard-Stack mit stacked Karten die eigene Message-Listen +
Input-Felder haben. Aktuelle Variante ist „Kontext-Strip fuer schnellen
Wechsel + Focus-One-Rendering" — ~90% des UX-Werts mit ~10% des Aufwands.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 vom Multi-Threading-Redesign. Chat zeigt jetzt genau EINEN Kontext
(Hauptchat oder Projekt X) — die anderen laufen im Brain weiter, sichtbar
nur ueber Status-Dots im Drawer.
ChatScreen:
- Reorder-Trick + collapsible Project-Bloecke raus. messagesForRender filtert
jetzt direkt auf focusedProjectId.
- Neuer Focus-Header oben: ☰ Drawer-Toggle + Kontext-Name + Status-Dot
(gruen idle / gelb queue / rot arbeitet). Drawer-Icon kriegt ein Badge
mit der Anzahl OTHERE aktiver Kontexte.
- Focus in AsyncStorage gespiegelt — Neustart restauriert den letzten Blick.
- brainApi.getProjectQueueStatus() alle 2s gepollt fuer Status-Dots.
- project_changed-Event steuert Focus-Wechsel (App-lokal, kein Brain-Roundtrip).
brainApi:
- Neuer Typ QueueContextStatus + ProjectQueueStatus.
- Methode getProjectQueueStatus() → /projects/queue-status.
ProjectsBrowser:
- Nimmt queueStatus als Prop, rendert Status-Dot pro Zeile (Hauptchat +
Projekte).
- switchTo ruft NICHT mehr brainApi.switchProject (kein globaler active
mehr) — direkt onActiveChanged mit dem Projekt-Objekt aus der Liste,
schliesst danach die Modal.
- Label ✓ FOCUS statt ✓ AKTIV — praeziser fuer's neue Modell.
SettingsScreen:
- File-Manager-Filter-Default nutzt AsyncStorage statt Brain-Query.
Bewusst nicht drin (Follow-up):
- OS-Push wenn Projekt fertig ist — braucht Firebase-Setup, kommt separat
wenn die visuellen Dots nicht reichen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Erster Schritt zum echten Multi-Threading fuer ARIA-Projekte. Kein globaler
active_project-State mehr — jeder /chat-Request sagt selbst welche Buehne
(project_id im Body). Verschiedene Projekte laufen parallel, gleiches
Projekt queued via asyncio.Lock.
Backend:
- ChatIn.project_id: Client bestimmt pro Request wohin. Bridge routet.
- /chat: async, holt per-Projekt asyncio.Lock. Requests fuers gleiche
Projekt reihen sich in _project_pending ein, warten am Lock. Requests
fuer verschiedene Projekte laufen echt parallel.
- Neuer /projects/queue-status endpoint: pro Kontext (inkl. Hauptchat
unter __main__): busy True/False + queue_size. Fuers UI-Status-Dots.
- Agent.chat() nimmt project_id + pending_queue Params. Kein
projects_mod.get_active() mehr im Hot-Path.
Queue-Aware Prompting:
- Wenn nach dem aktuellen Turn weitere Nachrichten in der Queue liegen,
wird der System-Prompt um ein QUEUE-Segment erweitert mit Instruktion:
„Bevor Du den aktuellen Task loesst, pruef die Queue — widerspricht/
annuliert eine spaetere Nachricht? Dann Skip-Antwort statt Doppelarbeit."
- Beispiel: Task 'titelleiste rot' + Queue-Tail 'doch nicht, blau'
→ ARIA skipt rot, blau kommt als naechste Anfrage sauber durch.
- Kein extra LLM-Call — reine Prompt-Injection.
Project-Tools:
- project_enter/exit sind jetzt UI-Signale (App wechselt Ansicht via
project_changed event), aendern KEINEN Brain-State mehr. Der aktuelle
Turn bleibt in seinem Chat-Kontext.
- project_list zeigt keinen "AKTIV"-Marker mehr (nicht mehr sinnvoll).
- projects_mod.set_active/get_active bleiben als Legacy-Helpers (kein
Aufruf mehr aus dem Hot-Path).
Bridge:
- send_to_core packt project_id in den /chat-Body.
- User-Backup-Eintrag tag't project_id sauber, keine Brain-Query mehr.
Naechste Schritte (kommende Commits):
- App: Focus-One-View mit Drawer + Status-Dots + OS-Push
- Diagnostic: Dashboard-Stack mit Karten
- Voice-Router: 30s-Sticky + Meta-Command-Interception im wakeword.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Zwei Stefan-Reports nach dem ersten Live-Test:
1. App-Reload verlor die Projekt-Bloecke
- chat_backup.jsonl hatte keine project_id-Felder, also kamen die
Bubbles als Hauptchat zurueck wenn die App ueber chat_history_response
ihre History neu lud.
- Fix: aria_bridge schreibt jetzt project_id in jeden Backup-Eintrag.
Assistant-Reply via turn_pid (aus ChatOut.project_id); User-Message
via payload.projectId (oder Brain-Status-Query als Fallback fuer
Trigger-Replies / Diagnostic-Sends).
- App: chat_history_response-Mapper liest m.project_id → ChatMessage.projectId.
2. Raus + rein in ein Projekt erzeugte einen zweiten Block am Ende
- Vorher: Gruppierung bei aufeinanderfolgenden gleich-getaggten Bubbles.
Hauptchat dazwischen hat den Block "unterbrochen", neuer Block.
- Fix: neue reorderedMessages-Stufe sortiert Messages so um, dass alle
eines Projekts contiguous werden, verankert am LATEST-Activity-
Timestamp des Projekts. Genau EIN Block pro projectId — bei
Re-Enter wandert der existierende Block ans Zeitende der Liste,
die neue Bubble haengt unten in der Gruppe.
- Hauptchat-Bubbles bleiben chronologisch zwischen den Projekt-Blöcken.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bisher haben nur App-Uploads (msg_type == "file") ein Projekt-Tag bekommen.
Dateien die ARIA waehrend des Turns selbst schreibt (via [FILE: /shared/
uploads/aria_xyz.pdf]-Marker) sind dem Hauptchat zugefallen, auch wenn
Stefan in einem Projekt war.
Fix: Beim Verarbeiten der ARIA-Antwort in _process_core_response wird die
turn_project_id aus payload.projectId (ChatOut.project_id vom Brain) ge-
nutzt um jede gefundene ARIA-Datei sofort zu taggen, bevor sie als
file_from_aria broadcast wird.
Helper-Split:
- _tag_file_to_project(path, pid): pure Write, pid schon bekannt
- _tag_file_to_active_project(path): Convenience-Wrapper, fragt Brain
nach active project (genutzt vom App-Upload-Handler, der noch nichts
vom Projekt-Kontext weiss)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drei Stefan-Bugs aus dem ersten Deploy-Test plus die fehlenden Polish-
Features fuer die Projekt-Funktion.
Fixes:
- ProjectsBrowser-Spinner-Hang: useRef-Pattern statt useCallback([onActive
Changed]) — Parent uebergibt inline-arrow-Callbacks, neue Identitaet
jedes Render → useCallback recomputes → useEffect refeuert → infinite
Spinner. Fix: Ref-Bridge fuer Callbacks, useCallback mit empty deps.
- ChatScreen Banner: zusaetzlicher × Hauptchat-Button rechts (sichtbar
nur wenn Projekt aktiv) — ein Tap und zurueck zum Hauptthread, ohne
Modal-Umweg.
Features:
- Brain ChatOut.project_id: aktive Projekt-ID NACH dem Turn (kann
durch project_enter/exit-Tools waehrend Turn gewechselt sein). Bridge
liest sie aus dem /chat-Response und haengt sie an jeden ARIA-Chat-
Broadcast als payload.projectId.
- App: ChatMessage.projectId-Feld. User-Bubbles werden mit aktiver
Projekt-ID getaggt vor dem Senden (auch im RVS-Payload). ARIA-Bubbles
kriegen die ID vom Bridge.
- App: Chat-Verlauf rendert aufeinanderfolgende Project-Messages als
einklappbaren Block mit Header (▶/▼ + Projekt-Name + Count). Auto-
Collapse beim Projekt-Wechsel (altes ein, neues aus), Default beim
ersten Render: alle inaktiven Projekte eingeklappt.
- File-Manager Project-Tagging:
- diagnostic/server.js: Manifest /shared/config/file_projects.json
+ /api/files-list returnt projectId pro Datei + neuer Endpoint
/api/files-set-project.
- bridge/aria_bridge.py: nach App-Upload Auto-Tag mit aktivem Projekt
(Brain-Status-Query, best-effort fail-silent).
- App SettingsScreen: scrollbare Projekt-Pill-Reihe als Filter, default
auf aktives Projekt wenn vorhanden, sonst "Alle Projekte".
- Diagnostic: zweites Dropdown im Files-Tab, baut Projekt-Optionen
dynamisch aus /api/brain/projects/list.
Bewusst nicht drin (Folgeschritt):
- Per-File "Projekt zuweisen"-Action (Long-Press / Right-Click)
- Filter-Sync zwischen ChatScreen-Banner und SettingsScreen-Filter
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.
Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
(/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
_summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
{id}/end,{id}/archive, PATCH /{id}}.
Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.
App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.
Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
project_changed-Event-Broadcast.
Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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).