Compare commits

...

39 Commits

Author SHA1 Message Date
duffyduck b2edee9adb release: bump version to 0.1.8.8 2026-05-30 23:32:27 +02:00
duffyduck bb13477ef9 fix(wake): Race zwischen endConversation und stopBargeListening killt
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.
2026-05-30 23:31:25 +02:00
duffyduck 710e7c88d8 release: bump version to 0.1.8.7 2026-05-30 23:23:52 +02:00
duffyduck b6ee5552f0 fix(app): Dateimanager Einzel-Download landet jetzt im Downloads-Ordner
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.
2026-05-30 23:22:44 +02:00
duffyduck 570eb031e0 release: bump version to 0.1.8.6 2026-05-30 23:20:01 +02:00
duffyduck e9615d987e fix(audio): playbackFinished-Listener feuern erst wenn AudioTrack wirklich durch ist
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).
2026-05-30 23:18:53 +02:00
duffyduck 5e95eacd11 release: bump version to 0.1.8.5 2026-05-30 23:11:16 +02:00
duffyduck ece08f0f2f debug(wake): RVS-Log in endConversation — sichtbar machen ob re-arm greift
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.
2026-05-30 23:09:11 +02:00
duffyduck 31fd0d7f7a release: bump version to 0.1.8.4 2026-05-30 23:02:41 +02:00
duffyduck 263835ad74 fix(wake): Conversation-Window nur im Foreground, Background → direkt re-armen
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.
2026-05-30 23:01:12 +02:00
duffyduck ab7e9801ee release: bump version to 0.1.8.3 2026-05-30 22:33:13 +02:00
duffyduck 3d001a1d03 feat(app): manueller Aufnahme-Knopf nutzt jetzt auch Streaming-STT
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.
2026-05-30 22:31:26 +02:00
duffyduck 91760dd2e1 release: bump version to 0.1.8.2 2026-05-30 22:24:28 +02:00
duffyduck 3c2e537420 fix(wake): kein Conversation-Window-Resume wenn JS-Thread verspaetet aufwacht
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".
2026-05-30 22:23:13 +02:00
duffyduck 97b6ea1b3e release: bump version to 0.1.8.1 2026-05-30 22:14:36 +02:00
duffyduck 94ee0455a2 fix(rvs): Streaming-STT-Message-Types whitelisten
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.
2026-05-30 22:13:31 +02:00
duffyduck 0bf6d49432 fix(app): UI-Fallback wenn Whisper-Bridge nicht antwortet
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
2026-05-30 22:09:02 +02:00
duffyduck 493cba36a2 feat(diagnostic): RVS-Debug-Logs fuer Whisper- und F5TTS-Bridge
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.
2026-05-30 22:00:55 +02:00
duffyduck a68827fb38 fix(updater): parseInt(number) -> Number() — fileSize.size ist schon number 2026-05-30 21:45:17 +02:00
duffyduck 11ca316e4e release: bump version to 0.1.8.0 2026-05-30 21:42:58 +02:00
duffyduck be1d2e950a feat(app): Streaming-STT-Pipeline — Phase 1+2 verdrahtet
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.
2026-05-30 21:42:02 +02:00
duffyduck 199297a3a1 feat(android): natives PcmStreamRecorder-Modul — 16 kHz mono s16le → JS-Events
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.
2026-05-30 21:33:18 +02:00
duffyduck e99bf0b032 feat(bridge): stt_endpoint-Handler — Phase 2 Brain-Shortcut
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.
2026-05-30 21:31:29 +02:00
duffyduck 41999c2304 feat(whisper): Streaming-Modus mit ML-Endpointer — Phase 1
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.
2026-05-30 21:29:51 +02:00
duffyduck 095c1e2d70 release: bump version to 0.1.7.0 2026-05-30 21:02:59 +02:00
duffyduck 0145179aca fix(wake): kein setTimeout zwischen wake.detect und Callback — JS-Timer im Doze unzuverlaessig
Bridge-Log-Analyse zeigte: setTimeout(200ms) in onWakeDetected feuert im
Hintergrund (Display aus) entweder gar nicht oder erst nach 8+ Sekunden,
auch mit aktivem PARTIAL_WAKE_LOCK + Foreground-Service. Hermes parkt den
JS-Thread sobald er idle ist und wartet auf Native-Wake-Events; die
Bridge-Queue fuer Timer kommt erst dran wenn irgendein Native-Event
(z.B. Audio-Sample) den Thread weckt.

Drei Wake-Events live mitgelesen:
  - Vordergrund:  Timer feuert +209ms (ok)
  - Hintergrund:  Timer feuert +8061ms (wake-callback verspaetet)
  - Hintergrund:  Timer feuert nie (>5 min, gong-Sound bleibt aus)

OpenWakeWord.stop() ist davor awaited → Mikro ist garantiert frei.
Der 200ms-Sicherheitsabstand war Belt-and-Suspenders, jetzt entbehrlich.
Callback wird direkt synchron gefeuert.
2026-05-30 21:00:45 +02:00
duffyduck c2475ffef6 release: bump version to 0.1.6.9 2026-05-30 20:46:55 +02:00
duffyduck 98982fea2f feat(app): App-Logs live im Settings → Protokoll → Live Logs Tab anzeigen
Stefan: "wir haben live log + events tab in protokoll einstellungen, da
ist aber nie was drin".

Bisher hoerten Live Logs / Events nur auf RVS-Messages type='log'/'event'
von der Bridge — die Bridge schickt aktuell aber keine solchen Messages
zurueck zur App. Plus: reportAppDebug/Error ging nur an die Bridge in
/shared/logs/app.log, lokal in der App war nichts sichtbar.

Loesung: lokaler DeviceEventEmitter-Bus.

logger.ts:
- APP_LOG_EVENT Konstante exportiert
- reportAppError + reportAppDebug emittieren ZUSAETZLICH zum
  RVS-Send ein lokales DeviceEventEmitter-Event (errors immer,
  debug nur wenn Toggle AN)

SettingsScreen.tsx:
- DeviceEventEmitter.addListener auf APP_LOG_EVENT
- Mappt Log-Entries 1:1 in den 'logs'-State (max 200)
- Cleanup in useEffect-return

Damit sieht Stefan beim Debuggen (Debug-Toggle AN, Live-Logs-Tab
offen) live in der App was passiert — ohne curl gegen Bridge.

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:44:42 +02:00
duffyduck 356f8b3171 feat(app): Debug-Logs-an-Bridge Toggle (Settings → Protokoll, default aus)
Stefan: "haben wir einen Menupunkt logging? sonst muellen wir uns dicht
wenns funktioniert und wir das logging im moment nicht brauchen"

Stimmt. reportAppDebug() schickt aktuell IMMER an Bridge, auch wenn
gar nicht debuggt wird. Bei armed Wake-Word + Pipeline-Logs sind das
schnell ein Dutzend Eintraege pro Wake-Trigger.

Loesung: separater Settings-Toggle "Debug-Logs an Bridge" mit eigenem
AsyncStorage-Key (aria_debug_logs_to_bridge), Default AUS.

- logger.ts: _debugLogsToBridge flag + isDebugLogsToBridge() /
  setDebugLogsToBridge(). initLogger() laedt den Wert. reportAppDebug()
  prueft das Flag und schickt nur wenn AN.
- SettingsScreen: neuer Toggle direkt unter Verbose-Logging,
  orange (#FF9500) damit er als "Power-User-Option" erkennbar ist,
  mit Erklaerungs-Hinweis dass nur Info-Logs gefiltert werden,
  Crash-Reports (Errors via reportAppError) gehen weiterhin IMMER.

Workflow:
- Default-User: Toggle aus, kein Traffic, kein Disk-Schreiben
- Stefan beim Debuggen: Toggle an, testet die App, schaut Logs via
  curl /api/app-log?lines=N, schaltet wieder aus

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:41:40 +02:00
duffyduck b4115bb345 debug(wake): mehr Log-Punkte zwischen onWakeDetected-Trigger und Callback-Feuern
Stefan's Test zeigt: 'wake.detect keyword=computer state=armed' kommt
im Background durch (WakeLock greift!), aber 'wake.cb callback fired'
aus ChatScreen fehlt. Heisst: zwischen Detection und Callback-Feuern
geht's irgendwo verloren.

Mehr Logs:
- nach OpenWakeWord.stop(): 'native stop ok' oder 'native stop FAIL msg'
  → klaert ob async stop() haengt
- vor setTimeout: 'state→conversing, wakeCallbacks.length=N, scheduling'
  → klaert ob Liste leer ist (ChatScreen unmounted) und ob wir's
    schedulen
- im setTimeout: 'timeout fired, state=X, cbs=N'
  → klaert ob der Timer in 200ms tatsaechlich feuert (Doze-Throttle?)
- bei barge-path: 'barge path: cbs=N'

Damit sehen wir genau wo's klemmt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:38:14 +02:00
duffyduck 02cac99ef9 release: bump version to 0.1.6.8 2026-05-30 20:29:41 +02:00
duffyduck 2940ce0075 release: bump version to 0.1.6.7 2026-05-30 20:28:38 +02:00
duffyduck d78b668e31 feat(app): reportAppDebug — Live-Debug-Logs an Bridge ohne ADB
Stefan-Anforderung: Background-Wake-Word-Pipeline klappt noch nicht,
ADB nicht zur Hand → Debug via RVS-Log-Pipeline.

Logger:
- reportAppDebug(scope, message) analog zu reportAppError aber
  level=info, kein console.error, fuer Live-Diagnose

Strategische Log-Punkte:
- wakeword.ts: start() emits 'wake.start armed'
- wakeword.ts: onWakeDetected emits 'wake.detect state=X' beim
  Native-Trigger-Empfang
- ChatScreen.tsx wake-callback: 'wake.cb callback fired',
  'wake.cb startRecording=X', 'wake.cb gong played'
- backgroundAudio.ts: 'bg.start slot=X', 'bg.stop service stopped',
  'bg.start.fail msg' wenn Service nicht hochkommt

Abruf live via curl http://172.0.2.33:3001/api/app-log?lines=100

Damit kann Stefan nach APK-Build (mit allen Native-Fixes + Logger)
im Background-Test exakt sehen wo es klemmt:
- Kommt 'wake.detect' im Hintergrund an? (WakeLock-Frage)
- Kommt 'wake.cb callback fired'? (JS-Bridge-Frage)
- Geht 'bg.start slot=wake' durch? (Service-Start-Frage)

APK neu bauen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:27:29 +02:00
duffyduck a9115699db release: bump version to 0.1.6.6 2026-05-30 20:21:29 +02:00
duffyduck f2bfd4bbc6 feat(app): Background-GPS als opt-in Settings-Toggle
Stefan-Anforderung: GPS soll auch im Hintergrund liefern (Auto-Szenarien,
Handy-Tasche), aber NUR fuer Power-User die das bewusst aktivieren.
Mama-Tauglichkeit bleibt erhalten — Default AUS, keine Surprise-Permission.

Aenderungen:

AndroidManifest:
- ACCESS_BACKGROUND_LOCATION Permission
- FOREGROUND_SERVICE_LOCATION Permission
- AriaPlaybackService foregroundServiceType erweitert um |location
  (vorher: mediaPlayback|microphone)

backgroundAudio.ts:
- Neuer Slot 'location' zwischen 'wake' und 'background' in der
  Prioritaeten-Liste. Notification zeigt entsprechend.

gpsTracking.ts:
- isBackgroundGpsEnabled() / setBackgroundGpsEnabled() AsyncStorage-Helper
- ensureBackgroundLocationPermission() pruefte ACCESS_BACKGROUND_LOCATION
  und oeffnet Android-Settings wenn fehlend (auf Android 10+ kann das
  NICHT ueber den normalen Permission-Dialog angefordert werden)
- start(): wenn BG-GPS enabled, acquireBackgroundAudio('location') →
  Foreground-Service hochziehen mit type=location
- stop(): releaseBackgroundAudio('location')

SettingsScreen.tsx:
- Neuer Toggle "GPS auch im Hintergrund" direkt unter dem
  GPS-Tracking-Toggle, rot (#FF3B30) statt orange weil's eine stark
  privacy-relevante Einstellung ist
- Erklaerungs-Text zu Android-Settings + Akku-Verbrauch
- Beim Aktivieren: Permission-Check, ggf. Android-Settings oeffnen
- Wenn Tracking bereits laeuft: neustart damit location-Slot greift

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:19:16 +02:00
duffyduck b182ef5ed5 release: bump version to 0.1.6.5 2026-05-30 20:12:39 +02:00
duffyduck 9818dc1867 fix(app): Spotify resumed wieder nach TTS — nudgeMediaResume mit TRANSIENT
Stefan-Bug-Report: ARIA liest Nachricht vor, Spotify pausiert korrekt,
ARIA spricht durch — aber Spotify spielt danach NICHT automatisch
weiter. Sollte mit GAIN_TRANSIENT auto-resumen, tut es aber bei
manchen Spotify-Versionen/Geraeten nicht zuverlaessig.

Hintergrund: alte kickReleaseMedia() mit AUDIOFOCUS_GAIN (permanent)
war zu aggressiv (Spotify interpretierte als "user stoppte" =
Auto-Resume kaputt). Wurde entfernt. Jetzt ist das Pendel andersrum
zu weit: ohne Nudge keine Resume.

Sanfter Mittelweg: nudgeMediaResume() mit GAIN_TRANSIENT statt
GAIN-permanent. 100ms hold, abandon. Spotify bekommt Focus-Wechsel-
Hint ohne "user stopped"-Effekt.

audio.ts: nach AudioFocus.release() 50ms warten, dann nudgeMediaResume.
AudioFocusModule.kt: neue Methode + alte kickReleaseMedia bleibt mit
⚠️-Markierung fuer andere Use-Cases.

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:09:55 +02:00
duffyduck 543ad3c46d fix(app): WakeLock auch im AriaPlaybackService — Pipeline-weiter Schutz
Stefan-Ergaenzung: nach Wake-Word muss Aufnahme, Senden und ARIA-
Antwort + TTS auch im Hintergrund klappen, und danach soll das ganze
wieder von vorne als Konversations-Schleife laufen.

Vorher hielt nur OpenWakeWordModule einen WakeLock (commit 408d20a).
Sobald Wake-Word erkannt wurde, ruft die JS-Seite OpenWakeWord.stop()
fuer das Mic-Handover an audioService.startRecording() — und der
WakeLock wurde released. Mid-Aufnahme konnte die CPU dann in Doze
gehen, Audio-Chunks erreichten die JS-Bridge nicht zuverlaessig.

Fix: AriaPlaybackService haelt selbst einen PARTIAL_WAKE_LOCK,
solange der Foreground-Service aktiv ist. acquireBackgroundAudio()
in der JS-Seite haelt den Service ueber alle Pipeline-Schritte
(wake → rec → tts → wake) durchgehend — damit ist der WakeLock
ueber die ganze Konversations-Schleife durchgehend aktiv.

Doppelter Schutz (WakeLock auch im OpenWakeWordModule) bleibt drin
als defense in depth — beide haben setReferenceCounted(false), also
keine doppel-buchhaltung, einfach robuster gegen einzeln-failende
acquires.

APK neu bauen erforderlich (native Kotlin-Aenderung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:04:55 +02:00
duffyduck 408d20a087 fix(app): PARTIAL_WAKE_LOCK fuer Wake-Word — JS-Bridge bleibt im Hintergrund aktiv
Stefan-Bug-Report: Wake-Word wird im Hintergrund erkannt (Spotify
pausiert sofort), aber der Gong + Aufnahme-Start kommen erst wenn die
App in den Vordergrund geholt wird. Akku-Optimierung war bereits
deaktiviert ("Hintergrund aktiv").

Ursache: Foreground-Service haelt den App-Prozess am Leben + erlaubt
mic-Zugriff via foregroundServiceType=microphone. Aber: ohne expliziten
WakeLock kann die CPU im Doze-Mode (Display aus / Telefon idle) die
Auslieferung von DeviceEvents an die React-Native-JS-Bridge pausieren.
Folge: Native erkennt Wake-Word, ruft emit("WakeWordDetected"), aber
das Event queued sich nur — der JS-Listener (onWakeDetected → start-
Recording + playWakeReadySound) feuert erst beim naechsten JS-Tick,
und der kommt erst beim App-Resume.

Fix:
- AndroidManifest: WAKE_LOCK Permission hinzu (kein User-Prompt noetig,
  ist eine "normal" Permission).
- OpenWakeWordModule.kt: PowerManager.PARTIAL_WAKE_LOCK in start()
  acquired (8h Cap als Sicherheit), in stop() + dispose() released.
  Lock-Tag "AriaCockpit:WakeWordRecord" damit der in adb shell dumpsys
  power sichtbar ist.

Wirkung: solange Wake-Word "armed" ist, bleibt die CPU wach und die
JS-Bridge verarbeitet die Detection-Events live — Gong, Mic-Start,
ARIA-Antwort kommen ohne Foreground-Resume durch.

APK muss neu gebaut werden (native Kotlin-Aenderung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:03:08 +02:00
22 changed files with 1999 additions and 212 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10604
versionName "0.1.6.4"
versionCode 10808
versionName "0.1.8.8"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -9,14 +9,26 @@
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Background-Location ist OPT-IN (Settings → GPS auch im Hintergrund).
Muss vom User explizit in Android-Einstellungen auf "Immer erlauben"
gesetzt werden — kann nicht ueber den normalen Permission-Dialog
angefordert werden (Android 10+). Default: aus. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
Aufnahme im Gespraechsmodus). -->
Aufnahme im Gespraechsmodus). LOCATION wird nur aktiv wenn der
User Background-GPS in Settings einschaltet. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WAKE_LOCK damit Wake-Word + JS-Bridge auch bei aus-Display und Doze
arbeiten: ohne Lock pausiert Android die CPU, Native-AudioRecord
laeuft weiter aber JS-Bridge frisst die DeviceEvents nicht mehr ->
Wake-Word wird erkannt aber callbacks feuern erst beim App-Resume. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".MainApplication"
@@ -52,6 +64,6 @@
<service
android:name=".AriaPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" />
android:foregroundServiceType="mediaPlayback|microphone|location" />
</application>
</manifest>
@@ -5,9 +5,11 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
@@ -32,15 +34,26 @@ class AriaPlaybackService : Service() {
private var currentReason: String = ""
// PARTIAL_WAKE_LOCK haelt die CPU wach solange der Foreground-Service
// aktiv ist. Damit bleibt die JS-Bridge im Doze ansprechbar und die
// gesamte Sprach-Pipeline (Wake → Aufnahme → POST → ARIA → TTS → wieder
// Wake) laeuft durchgehend im Hintergrund. Ein einziger Lock fuer den
// ganzen Foreground-Cycle, nicht pro Sub-Modul.
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
acquireWakeLock()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
currentReason = reason
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
// Falls der Lock zwischendurch released wurde (z.B. nach onCreate-
// race oder OS-quirk), hier sicherheits-halber erneut anfordern.
acquireWakeLock()
try {
startForeground(NOTIFICATION_ID, buildNotification(reason))
} catch (e: Exception) {
@@ -53,10 +66,36 @@ class AriaPlaybackService : Service() {
}
override fun onDestroy() {
releaseWakeLock()
Log.i(TAG, "Foreground-Service gestoppt")
super.onDestroy()
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
try {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"AriaCockpit:Pipeline").apply {
setReferenceCounted(false)
acquire(8 * 60 * 60 * 1000L) // 8h Sicherheits-Cap
}
Log.i(TAG, "WakeLock acquired (CPU bleibt wach im Hintergrund)")
} catch (e: Exception) {
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
}
private fun releaseWakeLock() {
try {
wakeLock?.takeIf { it.isHeld }?.release()
if (wakeLock != null) Log.i(TAG, "WakeLock released")
} catch (e: Exception) {
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
}
wakeLock = null
}
override fun onBind(intent: Intent?): IBinder? = null
private fun ensureNotificationChannel() {
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true)
}
/** Sanfter Spotify-Resume-Nudge: kurz USAGE_MEDIA mit TRANSIENT
* requesten und sofort abandonen. Spotify bekommt das als
* Focus-Frei-Signal und resumed automatisch — aber weil TRANSIENT
* (nicht GAIN permanent), interpretiert Spotify das NICHT als
* "user stopped" was Auto-Resume verhindert haette.
*
* Hintergrund: ARIA spricht TTS via USAGE_ASSISTANT GAIN_TRANSIENT,
* Spotify pausiert. ARIA released. Spotify SOLLTE nach
* TRANSIENT-Loss + Abandon automatisch resumen, tut es aber bei
* manchen Versionen / Geraeten nicht zuverlaessig. Dieser Nudge
* triggert den Focus-Stack-Refresh ohne den Spotify-Auto-Stop-Bug
* der alten kickReleaseMedia mit GAIN permanent.
*/
@ReactMethod
fun nudgeMediaResume(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
Thread {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
val nudgeReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(nudgeListener)
.build()
am.requestAudioFocus(nudgeReq)
Thread.sleep(100)
am.abandonAudioFocusRequest(nudgeReq)
} else {
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
@Suppress("DEPRECATION")
am.requestAudioFocus(nudgeListener, AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
Thread.sleep(100)
@Suppress("DEPRECATION")
am.abandonAudioFocus(nudgeListener)
}
Log.i(TAG, "nudgeMediaResume: USAGE_MEDIA TRANSIENT request+abandon (Spotify-Resume-Trigger)")
} catch (e: Exception) {
Log.w(TAG, "nudgeMediaResume failed: ${e.message}")
}
}.start()
promise.resolve(true)
}
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
@@ -140,6 +192,10 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
*
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
* laesst den AudioFocusRequest haengen.
*
* ⚠️ ACHTUNG: nutzt AUDIOFOCUS_GAIN (permanent), Spotify kann das als
* "user-action stopp" interpretieren und Auto-Resume verhindern.
* Fuer Spotify-Resume nach TTS lieber nudgeMediaResume() nehmen (sanfter).
*/
@ReactMethod
fun kickReleaseMedia(promise: Promise) {
@@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
add(ApkInstallerPackage())
add(AudioFocusPackage())
add(PcmStreamPlayerPackage())
add(PcmStreamRecorderPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
@@ -4,6 +4,7 @@ import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
@@ -11,6 +12,7 @@ import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Promise
@@ -80,6 +82,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = null
// PARTIAL_WAKE_LOCK damit die CPU bei aus-Display nicht in Doze geht und
// die JS-Bridge die WakeWordDetected-Events live verarbeitet (sonst
// queuen sich die Events nur und werden erst beim App-Foreground
// delivered — Stefan-Beobachtung: "Spotify pausiert, aber Gong/Aufnahme
// kommen erst wenn ich die App nach vorne hole").
private var wakeLock: PowerManager.WakeLock? = null
// Inferenz-State
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
private var melProcessedIdx: Int = 0
@@ -198,6 +207,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
running.set(true)
record.startRecording()
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
// 8h Cap als Sicherheit gegen forgotten-release.
try {
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"AriaCockpit:WakeWordRecord").apply {
setReferenceCounted(false)
acquire(8 * 60 * 60 * 1000L)
}
Log.i(TAG, "WakeLock acquired")
} catch (e: Exception) {
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
@@ -232,6 +256,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
}
@@ -245,10 +270,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
disposeSessions()
promise.resolve(true)
}
private fun releaseWakeLock() {
try {
wakeLock?.takeIf { it.isHeld }?.release()
if (wakeLock != null) Log.i(TAG, "WakeLock released")
} catch (e: Exception) {
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
}
wakeLock = null
}
@ReactMethod
fun isAvailable(promise: Promise) {
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
@@ -0,0 +1,246 @@
package com.ariacockpit
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.os.PowerManager
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.atomic.AtomicBoolean
/**
* PCM-Streaming-Recorder fuer die Streaming-Whisper-Bridge.
*
* Oeffnet AudioRecord (16 kHz mono s16le, VOICE_COMMUNICATION-Source mit
* automatischer AEC + NS) und feuert ~200ms-Chunks als base64-Event
* "PcmStreamChunk" an die JS-Bridge.
*
* audio.ts schickt die Chunks via RVS direkt an die whisper-bridge die
* dort einen ML-Endpointer laufen laesst — kein dB-VAD-Tuning mehr.
*
* Mic-Ownership: dieser Recorder DARF nicht gleichzeitig mit
* OpenWakeWord laufen — beide wollen AudioRecord vom MIC. Caller
* muss OpenWakeWord.stop() vor start() hier aufrufen und nach stop()
* hier wieder OpenWakeWord.start() — genau wie's audio.ts ohnehin
* macht.
*
* Events:
* "PcmStreamChunk" { pcm: base64-s16le, seq: N, ts: epochMs }
* "PcmStreamError" { error: string }
*/
class PcmStreamRecorderModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PcmStreamRecorder"
companion object {
private const val TAG = "PcmStreamRecorder"
private const val SAMPLE_RATE = 16000
// 200ms-Chunks: gross genug fuer wenig RVS-Overhead, klein genug damit
// der Endpointer im Whisper-Bridge granular sieht. 200ms ist auch das
// Whisper-VAD-Frame-Hop — passt also zu downstream.
private const val CHUNK_SAMPLES = 3200 // 200ms @ 16 kHz
private const val BYTES_PER_SAMPLE = 2 // s16
private const val CHUNK_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE
}
private var audioRecord: AudioRecord? = null
private val running = AtomicBoolean(false)
private var captureThread: Thread? = null
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = null
// PARTIAL_WAKE_LOCK damit der JS-Bridge-Loop weiterlaeuft auch wenn das
// Display aus ist — sonst sammeln sich zwar Chunks in der nativen Queue
// an, aber emit() landet nicht zeitnah in JS und der Whisper-Bridge
// bekommt die Audio-Chunks erst beim App-Foreground-Resume.
private var wakeLock: PowerManager.WakeLock? = null
private var seq: Long = 0L
@ReactMethod
fun start(promise: Promise) {
if (running.get()) {
promise.resolve(true)
return
}
val perm = ContextCompat.checkSelfPermission(
reactApplicationContext, Manifest.permission.RECORD_AUDIO
)
if (perm != PackageManager.PERMISSION_GRANTED) {
promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
return
}
try {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_BYTES * 4) // 4x Chunk-Size als Sicherheit
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf,
)
if (record.state != AudioRecord.STATE_INITIALIZED) {
record.release()
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt? OpenWakeWord noch aktiv?)")
return
}
audioRecord = record
// AEC/NS/AGC explizit anschalten — manche Geraete liefern's via
// VOICE_COMMUNICATION zwar mit, aber Belt-and-Suspenders.
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
seq = 0L
running.set(true)
record.startRecording()
try {
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"AriaCockpit:PcmStreamRecord").apply {
setReferenceCounted(false)
acquire(8 * 60 * 60 * 1000L) // 8h Cap
}
} catch (e: Exception) {
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
captureThread = Thread({ captureLoop() }, "PcmStreamRecorderCapture").apply {
isDaemon = true
start()
}
Log.i(TAG, "Recording gestartet (16kHz mono s16le, ${CHUNK_SAMPLES} samples/chunk)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
running.set(false)
audioRecord?.release()
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
running.set(false)
try {
captureThread?.join(1500)
} catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
Log.i(TAG, "Recording gestoppt (seq=$seq Chunks gesendet)")
promise.resolve(true)
}
@ReactMethod
fun isRecording(promise: Promise) {
promise.resolve(running.get())
}
private fun captureLoop() {
val buffer = ByteArray(CHUNK_BYTES)
val rec = audioRecord ?: return
try {
while (running.get()) {
var offset = 0
// Solange lesen bis ein voller 200ms-Chunk zusammen ist.
// AudioRecord.read kann weniger als angefordert liefern.
while (offset < CHUNK_BYTES && running.get()) {
val n = rec.read(buffer, offset, CHUNK_BYTES - offset)
if (n <= 0) {
if (!running.get()) break
// Fehlerzustand — kurze Pause, dann weiter probieren
Thread.sleep(5)
continue
}
offset += n
}
if (offset < CHUNK_BYTES) break
val b64 = Base64.encodeToString(buffer, Base64.NO_WRAP)
val ts = System.currentTimeMillis()
val params = Arguments.createMap().apply {
putString("pcm", b64)
// putLong existiert nicht in WritableMap — putDouble fuer ts/seq.
putDouble("seq", seq.toDouble())
putDouble("ts", ts.toDouble())
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PcmStreamChunk", params)
seq++
}
} catch (e: Exception) {
Log.e(TAG, "captureLoop crashed", e)
try {
val err = Arguments.createMap().apply {
putString("error", e.message ?: "unknown")
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PcmStreamError", err)
} catch (_: Exception) {}
}
}
private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
try { agc?.release() } catch (_: Exception) {}
aec = null; ns = null; agc = null
}
private fun releaseWakeLock() {
try {
if (wakeLock?.isHeld == true) wakeLock?.release()
} catch (_: Exception) {}
wakeLock = null
}
// Damit RCTEventEmitter den Listener-Lifecycle nicht crasht
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class PcmStreamRecorderPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PcmStreamRecorderModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.6.4",
"version": "0.1.8.8",
"private": true,
"scripts": {
"android": "react-native run-android",
+51 -72
View File
@@ -1,12 +1,19 @@
/**
* VoiceButton - Push-to-Talk + Auto-Stop Aufnahmeknopf
* VoiceButton — Tap-to-Talk-Aufnahmeknopf (Streaming-Variante).
*
* Zwei Modi:
* 1. Push-to-Talk: gedrueckt halten zum Aufnehmen, loslassen zum Senden
* 2. Tap-to-Talk: einmal tippen startet Aufnahme, VAD stoppt automatisch bei Stille
* (auch genutzt fuer Wake-Word-getriggerte Aufnahme)
* Push-to-Talk gibt's nicht mehr. Tap startet Streaming-Aufnahme an die
* Whisper-Bridge. Tap nochmal sendet stt_stream_end → Whisper liefert den
* finalen Text → aria-bridge forwardet direkt an Brain. Keine dB/VAD-
* Stille-Erkennung mehr — Whisper hoert auf semantische Stille (kein
* neuer Text mehr).
*
* Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme.
* Diese Komponente ist absichtlich "dumm": sie kapselt nur den
* Tap-Lifecycle + die Animation. Recording-Optionen (voice/speed/
* location/interrupted) baut ChatScreen, die User-Bubble ebenfalls.
*
* Visuelles Feedback: pulsierende Animation + Dauer + dB-Pegel via
* audioService.onMeterUpdate (das macht audio.ts noch fuer alte Records;
* neu kommt der Pegel via NativeEventEmitter (PcmStreamMeter) — folgt).
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
@@ -17,25 +24,28 @@ import {
StyleSheet,
Easing,
TouchableOpacity,
Pressable,
} from 'react-native';
import audioService, { RecordingResult } from '../services/audio';
import audioService, { RecordingState } from '../services/audio';
// --- Typen ---
interface VoiceButtonProps {
/** Wird aufgerufen wenn die Aufnahme fertig ist */
onRecordingComplete: (result: RecordingResult) => void;
/** User hat getippt — ChatScreen soll Bubble bauen + startStreamingRecording.
* Returns true wenn die Aufnahme tatsaechlich gestartet ist. */
onTapStart: () => Promise<boolean>;
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
onTapStop: () => Promise<void>;
/** Button deaktivieren */
disabled?: boolean;
/** Wake-Word-Modus aktiv (zeigt Indikator) */
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
wakeWordActive?: boolean;
}
// --- Komponente ---
const VoiceButton: React.FC<VoiceButtonProps> = ({
onRecordingComplete,
onTapStart,
onTapStop,
disabled = false,
wakeWordActive = false,
}) => {
@@ -45,6 +55,21 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
const pulseAnim = useRef(new Animated.Value(1)).current;
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// State via audioService.onStateChange spiegeln — der Service ist die
// Quelle der Wahrheit (Streaming-Session, Wake-Word-Multi-Turn, etc.
// koennen den Recording-State von extern aendern). isStreamingRecording
// ist auch true wenn die Wake-Word-Konversation gerade aufzeichnet —
// dann zeigt der Button "stop"-Symbol, und Tap stoppt die laufende
// Aufnahme (egal ob via Wake-Word oder Knopf gestartet).
useEffect(() => {
const unsub = audioService.onStateChange((next: RecordingState) => {
setIsRecording(next === 'recording');
});
// Initial-State synchronisieren
setIsRecording(audioService.getRecordingState() === 'recording');
return unsub;
}, []);
// Puls-Animation starten/stoppen
useEffect(() => {
if (isRecording) {
@@ -71,14 +96,13 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
}, [isRecording, pulseAnim]);
// Aufnahmedauer zaehlen + Metering
// Aufnahmedauer zaehlen + Metering (Pegel-Bar)
useEffect(() => {
if (isRecording) {
setDurationMs(0);
durationTimer.current = setInterval(() => {
setDurationMs(prev => prev + 100);
}, 100);
const unsubMeter = audioService.onMeterUpdate(setMeterDb);
return () => {
unsubMeter();
@@ -89,74 +113,28 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
clearInterval(durationTimer.current);
durationTimer.current = null;
}
setMeterDb(-160);
}
}, [isRecording]);
// VAD Silence Callback — Auto-Stop.
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
// feuert (silenceFired-Latch).
const onCompleteRef = useRef(onRecordingComplete);
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
useEffect(() => {
const unsubSilence = audioService.onSilenceDetected(async () => {
if (audioService.getRecordingState() !== 'recording') return;
const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 500) {
onCompleteRef.current(result);
}
});
return unsubSilence;
}, []);
// Auto-Start fuer Wake Word (extern getriggert)
const startAutoRecording = useCallback(async () => {
if (disabled || isRecording) return;
const started = await audioService.startRecording(true); // autoStop = true
if (started) {
setIsRecording(true);
}
}, [disabled, isRecording]);
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop.
// Tap-Handler. Guard gegen Doppel-Tap waehrend asyncer Start/Stop.
const tapBusy = useRef(false);
const handleTap = async () => {
const handleTap = useCallback(async () => {
if (disabled || tapBusy.current) return;
tapBusy.current = true;
try {
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
// Service-State fragen statt React-State (Closure koennte stale sein)
const svcState = audioService.getRecordingState();
if (svcState === 'recording') {
// Aufnahme manuell stoppen
const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
await onTapStop();
} else if (svcState === 'idle') {
// Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
setIsRecording(true);
}
await onTapStart();
}
// svcState === 'processing': Stopp in progress — nichts tun, User
// muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy
// kurz damit der User's UI-Feedback synchron bleibt.
// 'processing': Stop laeuft gerade — nichts tun, User muss nochmal tippen
} finally {
tapBusy.current = false;
}
};
// Expose startAutoRecording via ref fuer Wake Word
React.useImperativeHandle(
React.createRef(),
() => ({ startAutoRecording }),
[startAutoRecording],
);
}, [disabled, onTapStart, onTapStop]);
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
@@ -164,7 +142,11 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
return `${seconds}.${tenths}s`;
};
// Meter-Visualisierung (0-1 Skala)
// Meter-Visualisierung (-60..0 dB → 0..1). Bei Streaming-Mode liefert
// audio.ts (noch) keinen Pegel, also bleibt der Balken leer — wird in
// einem Folge-Commit nachgerueckt (PcmStreamRecorder-Module muss dafuer
// einen RMS-Wert mit-emitten). Tut der Streaming-Funktion keinen Abbruch,
// ist reines UI-Beiwerk.
const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60));
return (
@@ -198,9 +180,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
);
};
// Expose startAutoRecording fuer externe Aufrufe (Wake Word)
export type VoiceButtonHandle = { startAutoRecording: () => Promise<void> };
// --- Styles ---
const styles = StyleSheet.create({
+160 -77
View File
@@ -47,7 +47,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@@ -531,7 +531,14 @@ const ChatScreen: React.FC = () => {
if (bgDur > 30_000) {
wakeWordService.discardIfFreshlyTriggered(15_000).then(discarded => {
if (discarded) {
try { audioService.cancelRecording(); } catch {}
// Sowohl legacy als auch Streaming-Pfad abdecken
try {
if (audioService.isStreamingRecording()) {
audioService.cancelStreamingRecording('wake-discarded');
} else {
audioService.cancelRecording();
}
} catch {}
}
}).catch(() => {});
}
@@ -1256,71 +1263,104 @@ const ChatScreen: React.FC = () => {
return () => { unsubUpdate(); clearTimeout(timer); };
}, []);
// Gespraechsmodus: Nach TTS-Wiedergabe automatisch Aufnahme starten
// Gespraechsmodus: Nach TTS-Wiedergabe weiter im Multi-Turn (Conversation-
// Window) oder zurueck zu armed (Wake-Word lauscht wieder)?
//
// Foreground → resume() oeffnet das Mikro fuer N Sekunden Follow-Up
// (natuerlicher Dialog moeglich ohne erneutes "Computer")
// Background → endConversation() — Wake-Word direkt wieder armed.
//
// Grund: der setTimeout(800ms) in resume() wird im Doze stark verzoegert
// (siehe Wake-Detect-Bug von 0.1.7.0). Das hat zwei nervige Folgen:
// 1) Wake-Word ist solange "tot" — User kann ARIA nicht mehr triggern
// bis er die App vorholt
// 2) Wenn er die App dann vorholt, oeffnet der verspaetete Timer das
// Mikro — sieht aus wie ein Phantom-Wake-Word-Trigger
// Background = User nutzt das Handy anderweitig, das Multi-Turn-Konzept
// ist da eh nicht nuetzlich. Direkt re-armen ist robust und erwartungs-
// konform.
useEffect(() => {
const unsubPlayback = audioService.onPlaybackFinished(() => {
if (wakeWordService.isActive()) {
if (!wakeWordService.isActive()) return;
if (AppState.currentState === 'active') {
wakeWordService.resume();
} else {
console.log('[Chat] TTS fertig im Background → endConversation (kein Multi-Turn)');
wakeWordService.endConversation().catch(() => {});
}
});
return () => unsubPlayback();
}, []);
// Wake Word / Gespraechsmodus: Auto-Aufnahme starten
// Wake Word / Gespraechsmodus: Auto-Aufnahme starten (Streaming-Modus)
useEffect(() => {
const unsubWake = wakeWordService.onWakeWord(async () => {
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
console.log('[Chat] Gespraechsmodus — starte Streaming-Aufnahme');
import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'callback fired, calling startStreamingRecording')).catch(()=>{});
// Bubble SOFORT bauen — bevor Whisper-Bridge antwortet — damit der User
// sieht "Es passiert was". stt_endpoint kommt typisch <1s spaeter mit
// dem finalen Text, dann wird die Bubble ueber audioRequestId-Match
// aktualisiert (siehe chat-Handler oben).
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (started) {
// Erst JETZT signalisieren dass das Mikro wirklich offen ist —
// vorher war's noch in der Init-Phase. So weiss der User exakt
// ab wann er reden kann. "Bereit"-Sound (Ding-Dong) ist optional
// ueber Settings → Wake-Word abschaltbar.
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: 1500,
hardCapMs: 60000,
});
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
if (ok) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
scheduleStaleAudioCleanup(audioRequestId, 60000);
import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'gong played + streaming started')).catch(()=>{});
} else {
// Mikrofon nicht verfuegbar, naechsten Versuch
// Mikrofon nicht verfuegbar → Bubble wieder weg, naechster Versuch
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
wakeWordService.resume();
}
});
// Auto-Stop Callback: wenn Stille erkannt → Aufnahme senden + Wake Word wieder starten
const unsubSilence = audioService.onSilenceDetected(async () => {
const result = await audioService.stopRecording();
if (result && result.durationMs > 500) {
// User hat im Fenster gesprochen → Sprachnachricht senden
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
rvs.send('audio', {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }),
});
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
// STT-Endpoint-Callback ersetzt den alten onSilenceDetected.
// Feuert in 2 Faellen:
// - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
// aria-bridge bekommt das gleiche Event und triggert Brain
// direkt. App muss nix mehr senden.
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error).
// Konversation beenden wie frueher der "kein Speech"-Fall.
const unsubEndpoint = audioService.onSttEndpoint((ev) => {
if (ev.text && ev.text.trim()) {
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
// Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
// chat(sender=aria) wie im Legacy-Pfad.
} else {
// Kein Speech im Window → Konversation beenden (Ohr geht aus oder
// bleibt armed wenn Wake Word verfuegbar)
// Kein Speech im Window → Konversation beenden
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
// Placeholder-Bubble wieder weg
if (ev.audioRequestId) {
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
}
wakeWordService.endConversation();
// UI-State synchron halten
if (!wakeWordService.isActive()) setWakeWordActive(false);
}
});
@@ -1329,17 +1369,42 @@ const ChatScreen: React.FC = () => {
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
const unsubBarge = wakeWordService.onBargeIn(async () => {
console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Aufnahme');
console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Streaming-Aufnahme');
audioService.haltAllPlayback('barge-in via wake-word');
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
// Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
await new Promise(r => setTimeout(r, 150));
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (started) {
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: 1500,
hardCapMs: 60000,
});
if (ok) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
scheduleStaleAudioCleanup(audioRequestId, 60000);
} else {
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
}
});
@@ -1362,7 +1427,7 @@ const ChatScreen: React.FC = () => {
return () => {
unsubWake();
unsubSilence();
unsubEndpoint();
unsubBarge();
unsubTtsStart();
unsubTtsEnd();
@@ -1372,11 +1437,18 @@ const ChatScreen: React.FC = () => {
// Wake Word Toggle Handler
const toggleWakeWord = useCallback(async () => {
if (wakeWordActive) {
// Vor Porcupine-Stop: eventuelle laufende Aufnahme abbrechen. Sonst
// Vor Wake-Word-Stop: eventuelle laufende Aufnahme abbrechen. Sonst
// bleibt audioService.recordingState=='recording' haengen und der
// normale Aufnahme-Button wirkt nicht mehr (startRecording lehnt
// ab weil "Aufnahme laeuft bereits").
try { await audioService.stopRecording(); } catch {}
// ab weil "Aufnahme laeuft bereits"). Beide Pfade abdecken — legacy
// file-Aufnahme + neue Streaming-Aufnahme.
try {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('wake-toggle-off');
} else {
await audioService.stopRecording();
}
} catch {}
await wakeWordService.stop();
setWakeWordActive(false);
} else {
@@ -1708,49 +1780,59 @@ const ChatScreen: React.FC = () => {
return true;
}, [agentActivity]);
// Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
// Streaming-Variante: PcmStreamRecorder + Whisper-ML-Endpointer ersetzen
// die alte dB-VAD-Schleife. Knopf-1.-Tap startet, Knopf-2.-Tap stoppt.
// Bubble bauen wir SOFORT damit der User sofort Feedback hat — Text wird
// ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert.
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const cmid = nextClientMsgId();
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
};
setMessages(prev => capMessages([...prev, userMsg]));
dispatchWithAck(cmid, 'audio', {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }),
location: location || null,
// Manueller Knopf: kein no-speech-Watchdog (User kontrolliert via Tap-zum-
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
// die Session auch app-seitig haben wir +2s Toleranz.
noSpeechTimeoutMs: 0,
endpointMs: 1500,
hardCapMs: 300000,
});
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
if (!ok) {
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
return false;
}
scheduleStaleAudioCleanup(audioRequestId, 60000);
return true;
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit
// den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn-
// Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen.
// Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback
// und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"-
// Knopf.
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
await audioService.stopStreamingRecording('user');
if (wakeWordService.isConversing()) {
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
await wakeWordService.endConversation();
}
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
}, []);
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
@@ -2519,7 +2601,8 @@ const ChatScreen: React.FC = () => {
) : (
<>
<VoiceButton
onRecordingComplete={handleVoiceRecording}
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
+135 -2
View File
@@ -20,6 +20,7 @@ import {
Modal,
PermissionsAndroid,
useWindowDimensions,
DeviceEventEmitter,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
@@ -52,13 +53,17 @@ import {
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking';
import gpsTrackingService, {
isBackgroundGpsEnabled,
setBackgroundGpsEnabled,
ensureBackgroundLocationPermission,
} from '../services/gpsTracking';
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
import MemoryBrowser from '../components/MemoryBrowser';
import TriggerBrowser from '../components/TriggerBrowser';
import SkillBrowser from '../components/SkillBrowser';
import OAuthBrowser from '../components/OAuthBrowser';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
import {
isWakeReadySoundEnabled,
setWakeReadySoundEnabled,
@@ -134,6 +139,7 @@ const SettingsScreen: React.FC = () => {
const [currentMode, setCurrentMode] = useState('normal');
const [gpsEnabled, setGpsEnabled] = useState(false);
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
const [bgGpsEnabled, setBgGpsEnabled] = useState(false);
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [scannerVisible, setScannerVisible] = useState(false);
@@ -155,6 +161,7 @@ const SettingsScreen: React.FC = () => {
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
const [debugLogsToBridge, setDebugLogsToBridgeState] = useState<boolean>(isDebugLogsToBridge());
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
@@ -216,6 +223,8 @@ const SettingsScreen: React.FC = () => {
const offGps = gpsTrackingService.onChange(setGpsTracking);
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
gpsTrackingService.restoreFromStorage().catch(() => {});
// Background-GPS-Toggle initial laden
isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
@@ -380,6 +389,19 @@ const SettingsScreen: React.FC = () => {
setConnLog(prev => [...prev.slice(-99), entry]);
});
// Lokale App-Logs (reportAppDebug/Error) im Live-Logs-Tab anzeigen
// — damit Stefan ohne curl direkt in der App sieht was passiert.
const localLogSub = DeviceEventEmitter.addListener(APP_LOG_EVENT, (e: any) => {
const entry: LogEntry = {
id: `applog_${e.ts || Date.now()}_${logIdCounter++}`,
timestamp: e.ts || Date.now(),
source: e.scope || 'app',
message: e.message || '',
level: e.level || 'info',
};
setLogs(prev => [...prev.slice(-200), entry]);
});
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
if (message.type === 'log') {
const entry: LogEntry = {
@@ -475,6 +497,49 @@ const SettingsScreen: React.FC = () => {
})();
}
// Datei-Manager: Einzel-Datei-Download. ChatScreen subscribet auch auf
// file_response — der versucht aber nur Chat-Bubble-Attachments zu
// patchen und macht nix wenn die requestId nicht zu einer Nachricht
// passt. Hier behandeln wir die Manager-initiierten Downloads
// (requestId-Praefix 'single-' aus bulkDownload). Schreibt nach
// ~/Download/ wie der ZIP-Pfad.
if (message.type === ('file_response' as any)) {
const p: any = message.payload || {};
const reqId = (p.requestId as string) || '';
if (!reqId.startsWith('single-')) return; // nicht unsere Anfrage
if (p.error) {
ToastAndroid.show('Download fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return;
}
const b64 = (p.base64 as string) || '';
if (!b64) return;
const fileName = (p.name as string) ||
(p.serverPath as string || '').split('/').pop() ||
'aria-download';
(async () => {
try {
const dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/${fileName}`;
// Falls Datei schon existiert: Suffix anhaengen damit nichts
// ueberschrieben wird.
let target = filePath;
let i = 1;
while (await RNFS.exists(target)) {
const dot = fileName.lastIndexOf('.');
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
const ext = dot > 0 ? fileName.slice(dot) : '';
target = `${dir}/${base} (${i})${ext}`;
i++;
}
await RNFS.writeFile(target, b64, 'base64');
const sizeKb = Math.round(((b64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string;
@@ -515,6 +580,7 @@ const SettingsScreen: React.FC = () => {
unsubState();
unsubMessage();
unsubLog();
localLogSub.remove();
};
}, []);
@@ -1117,6 +1183,52 @@ const SettingsScreen: React.FC = () => {
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
/>
</View>
{/* Background-GPS opt-in — Default AUS. Braucht ACCESS_BACKGROUND_LOCATION
(User muss in Android-Settings 'Immer erlauben' aktivieren). */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS auch im Hintergrund</Text>
<Text style={styles.toggleHint}>
Damit ARIA auch unterwegs deine aktuelle Position kennt wenn die
App im Hintergrund ist (Auto, Handy-Tasche). Standard: aus.
{'\n\n'}
Android verlangt fuer Background-GPS, dass du in den
System-Einstellungen unter Standort "Immer erlauben" auswaehlst.
Beim Aktivieren wird Android-Settings geoeffnet falls noetig.
{'\n\n'}
Akku-Verbrauch: ~3-5% mehr pro Tag durch dauerhaftes Polling.
</Text>
</View>
<Switch
value={bgGpsEnabled}
onValueChange={async (v) => {
if (v) {
const ok = await ensureBackgroundLocationPermission();
if (!ok) {
// User muss in Android-Settings auf "Immer erlauben" — Toggle
// bleibt aus bis er zurueckkommt und nochmal tippt.
return;
}
await setBackgroundGpsEnabled(true);
setBgGpsEnabled(true);
// Wenn Tracking bereits laeuft: neu starten damit der
// Foreground-Service jetzt mit location-Slot kommt
if (gpsTrackingService.isActive()) {
gpsTrackingService.stop('bg-toggle');
gpsTrackingService.start('bg-aktiviert').catch(() => {});
}
ToastAndroid.show('Background-GPS aktiviert', ToastAndroid.SHORT);
} else {
await setBackgroundGpsEnabled(false);
setBgGpsEnabled(false);
ToastAndroid.show('Background-GPS aus nur noch Foreground', ToastAndroid.SHORT);
}
}}
trackColor={{ false: '#2A2A3E', true: '#FF3B30' }}
thumbColor={bgGpsEnabled ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Bubble-Anzeige === */}
@@ -1863,6 +1975,27 @@ const SettingsScreen: React.FC = () => {
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
Debuggen via adb logcat.
</Text>
{/* Debug-Logs an Bridge: scharf nur wenn aktiv gebraucht */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<Text style={styles.toggleLabel}>Debug-Logs an Bridge</Text>
<Switch
value={debugLogsToBridge}
onValueChange={(v) => {
setDebugLogsToBridge(v);
setDebugLogsToBridgeState(v);
}}
trackColor={{ false: '#3A3A52', true: '#FF9500' }}
thumbColor={debugLogsToBridge ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={styles.toggleHint}>
Schickt detaillierte Diagnose-Logs (Wake-Word-Pipeline, Audio-Focus,
Background-Service) per RVS an die Bridge abrufbar via
`curl /api/app-log?lines=N` ohne ADB. Default AUS damit kein
unnoetiger Traffic + Disk-Schreiben. Crash-Reports (Errors) gehen
IMMER, dieser Toggle betrifft nur Info-Logs.
</Text>
</View>
<View style={styles.card}>
+401 -7
View File
@@ -36,10 +36,11 @@ function btoaSafe(bin: string): string {
}
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
const { AudioFocus, PcmStreamPlayer } = NativeModules as {
const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
AudioFocus?: {
requestDuck: () => Promise<boolean>;
requestExclusive: () => Promise<boolean>;
nudgeMediaResume: () => Promise<boolean>;
release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
@@ -50,8 +51,15 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
end: () => Promise<boolean>;
stop: () => Promise<boolean>;
};
PcmStreamRecorder?: {
start: () => Promise<boolean>;
stop: () => Promise<boolean>;
isRecording: () => Promise<boolean>;
};
};
import rvs from './rvs';
// --- Typen ---
export interface RecordingResult {
@@ -69,6 +77,19 @@ type RecordingStateCallback = (state: RecordingState) => void;
type MeterCallback = (db: number) => void;
type SilenceCallback = () => void;
/** Endpoint-Event von der Streaming-Whisper-Bridge — finaler Text +
* Echo-Felder. ChatScreen reagiert darauf wie frueher auf
* onSilenceDetected, nur dass der Text schon da ist. */
export interface SttEndpointEvent {
audioRequestId: string;
text: string;
reason: string; // 'endpoint' | 'stream_end' | 'hardcap'
durationS: number;
sttMs: number;
}
type SttEndpointCallback = (e: SttEndpointEvent) => void;
type SttPartialCallback = (text: string) => void;
// --- Konstanten ---
const AUDIO_SAMPLE_RATE = 16000;
@@ -285,6 +306,30 @@ class AudioService {
// Position-Berechnen vom playbackStarted abziehen
private readonly LEADING_SILENCE_SEC = 0.3;
// ── Streaming-STT-Session-State ──
// Aktuelle Session-ID (requestId der whisper-bridge). Leer wenn kein Stream
// aktiv. Wird beim Eintreffen von Chunks geprueft damit wir nicht versehent-
// lich Chunks einer alten Session in eine neue mischen.
private streamRequestId: string = '';
private streamAudioRequestId: string = '';
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
private streamEndpointFired: boolean = false;
// Subscriber-Handles fuer Native-Events + RVS-Listener (cleanup beim stop)
private streamPcmChunkSub: { remove: () => void } | null = null;
private streamPcmErrorSub: { remove: () => void } | null = null;
private streamRvsUnsub: (() => void) | null = null;
// No-speech-Watchdog: wenn nach N ms noch kein einziger stt_partial kam,
// brechen wir die Session ab (Stille → User hat nix gesagt → Konversation
// beenden). Ersetzt den alten vad noSpeechTimer.
private streamNoSpeechTimer: ReturnType<typeof setTimeout> | null = null;
private streamGotPartial: boolean = false;
private streamHardCapTimer: ReturnType<typeof setTimeout> | null = null;
// Endpoint/Partial-Callbacks fuer ChatScreen
private endpointListeners: SttEndpointCallback[] = [];
private partialListeners: SttPartialCallback[] = [];
constructor() {
this.recorder = new AudioRecorderPlayer();
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
@@ -296,8 +341,21 @@ class AudioService {
try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
emitter.addListener('PcmPlaybackFinished', () => {
console.log('[Audio] PcmPlaybackFinished — Focus jetzt freigeben');
console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
this._releaseFocusDeferred();
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
// braucht nach end() noch 1-2s zum Drainen seines Hardware-Buffers.
// Wenn wir die Listener zu frueh feuern, re-armt OpenWakeWord
// waehrend ARIA noch hoerbar spricht → ARIAs Stimme verwirrt die
// Wake-Word-Detection (kein gemeinsames AEC zwischen AudioTrack-
// und AudioRecord-Session). Stefan-Reproduktion: nach jeder ARIA-
// Antwort schluckte das Wake-Word den naechsten Trigger.
import('./logger').then(m => m.reportAppDebug('audio.playback',
'PcmPlaybackFinished native event → fire listeners')).catch(()=>{});
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
});
} catch (err) {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
@@ -309,6 +367,58 @@ class AudioService {
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
// RVS-Listener fuer Streaming-STT-Antworten der Whisper-Bridge.
// Wir subscribed permanent — gefiltert wird ueber streamRequestId-Match.
// Das macht startStreamingRecording einfacher (kein subscribe/unsubscribe
// pro Session noetig).
try {
this.streamRvsUnsub = rvs.onMessage((msg) => {
const t = msg?.type;
if (t !== 'stt_partial' && t !== 'stt_endpoint' && t !== 'stt_stream_done') return;
const p = (msg as any).payload || {};
const reqId = String(p.requestId || '');
if (!reqId || reqId !== this.streamRequestId) return;
if (t === 'stt_partial') {
const text = String(p.text || '');
this.streamGotPartial = true;
// Sobald wir ueberhaupt mal Text gekriegt haben, ist der no-speech
// Watchdog erledigt.
if (this.streamNoSpeechTimer) {
clearTimeout(this.streamNoSpeechTimer);
this.streamNoSpeechTimer = null;
}
this.partialListeners.forEach(cb => {
try { cb(text); } catch (e) { console.warn('[Audio] partial listener err:', e); }
});
return;
}
if (t === 'stt_endpoint') {
const ev: SttEndpointEvent = {
audioRequestId: String(p.audioRequestId || ''),
text: String(p.text || ''),
reason: String(p.reason || ''),
durationS: Number(p.durationS || 0),
sttMs: Number(p.sttMs || 0),
};
console.log('[Audio] stt_endpoint: %dms, %.1fs Audio, text=%r',
ev.sttMs, ev.durationS, ev.text.slice(0, 80));
// Wir stoppen die Aufnahme — whisper hat alles was es braucht.
// Kein stt_stream_end senden: das Endpoint kam von der Bridge,
// sie hat schon finalisiert.
this._fireEndpoint(ev);
this._cleanupStreamLocal('endpoint');
return;
}
if (t === 'stt_stream_done') {
// Idempotent — falls cleanup nach endpoint schon lief, harmlos.
this._cleanupStreamLocal('stream_done');
return;
}
});
} catch (err) {
console.warn('[Audio] RVS-Listener-Subscribe fehlgeschlagen:', err);
}
}
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
@@ -332,6 +442,13 @@ class AudioService {
}
console.log('[Audio] AudioFocus jetzt released');
AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
// 50ms Delay damit das Abandon erst durch ist.
setTimeout(() => {
AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50);
}, this.FOCUS_RELEASE_DELAY_MS);
}
@@ -814,6 +931,282 @@ class AudioService {
}
}
// ──────────────────────────────────────────────────────────────
// STREAMING-AUFNAHME (Phase 1+2 — PCM live an Whisper-Bridge)
// ──────────────────────────────────────────────────────────────
/** Startet eine Streaming-STT-Session.
*
* Statt eine MP4-Datei aufzunehmen und am Ende hochzuladen, oeffnet der
* PcmStreamRecorder (16 kHz mono s16le) ein AudioRecord und schickt
* alle 200ms einen PCM-Chunk via rvs.send('stt_audio_chunk') an die
* whisper-bridge. Diese transkribiert live und feuert stt_endpoint
* sobald der erkannte Text fuer endpointMs nicht mehr waechst.
*
* Auf stt_endpoint reagiert audio.ts indem es PcmStreamRecorder stoppt
* und endpointListeners feuert — ChatScreen baut dann die Chat-Bubble.
* Den eigentlichen Brain-Call macht aria-bridge direkt nach stt_endpoint,
* KEIN Audio-Roundtrip ueber die App noetig.
*
* Args:
* audioRequestId — eindeutige Korrelations-ID fuer die "wird
* verarbeitet"-Bubble (gleiche Semantik wie beim
* Legacy-Pfad mit rvs.send('audio')).
* voice/speed — TTS-Echo-Felder, werden an Brain weitergegeben.
* interrupted — true bei Barge-In waehrend ARIA noch sprach.
* location — GPS, falls vorhanden.
* noSpeechTimeoutMs — wenn nach so vielen ms KEIN stt_partial kam
* (= Whisper hat nix erkannt), brechen wir die
* Session ab. 0 = kein Watchdog.
* endpointMs — Schwellwert fuer Endpoint (Stille = kein neuer
* Text). Default 1500ms — Whisper-Bridge nutzt
* den Wert wenn mitgesendet.
* hardCapMs — Schmerzgrenze. Default 60s.
*/
async startStreamingRecording(opts: {
audioRequestId: string;
voice?: string;
speed?: number;
interrupted?: boolean;
location?: any;
noSpeechTimeoutMs?: number;
endpointMs?: number;
hardCapMs?: number;
}): Promise<{ requestId: string; ok: boolean }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
return { requestId: '', ok: false };
}
if (!PcmStreamRecorder) {
console.warn('[Audio] PcmStreamRecorder Native-Modul nicht verfuegbar');
return { requestId: '', ok: false };
}
const hasPermission = await this.requestMicrophonePermission();
if (!hasPermission) {
console.warn('[Audio] Keine Mikrofon-Berechtigung');
return { requestId: '', ok: false };
}
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
this.stopPlayback();
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
this.streamAudioRequestId = opts.audioRequestId || '';
this.streamGotPartial = false;
this.streamEndpointFired = false;
this.recordingStartTime = Date.now();
try {
await acquireBackgroundAudio('rec');
// PcmStreamChunk-Subscriber AUFSETZEN BEVOR der Recorder startet —
// sonst koennten die ersten 1-2 Chunks ins Leere gehen.
try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamRecorder as any);
this.streamPcmChunkSub = emitter.addListener('PcmStreamChunk', (e: any) => {
// Nur Chunks der aktuellen Session weiterleiten — verhindert dass
// ein verspaeteter Chunk in einer neuen Session landet.
if (!this.streamRequestId) return;
const sessionId = this.streamRequestId;
rvs.send('stt_audio_chunk' as any, {
requestId: sessionId,
pcm: String(e?.pcm || ''),
seq: Number(e?.seq || 0),
});
});
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
console.warn('[Audio] PcmStreamRecorder-Fehler:', e?.error);
this._cleanupStreamLocal('pcm-error');
});
} catch (err) {
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
}
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
this.setState('recording');
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
rvs.send('stt_stream_start' as any, {
requestId,
audioRequestId: opts.audioRequestId || '',
voice: opts.voice || '',
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
interrupted: !!opts.interrupted,
location: opts.location || null,
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
// hat der User vermutlich nix gesagt → Session beenden.
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
if (noSpeechMs > 0) {
this.streamNoSpeechTimer = setTimeout(() => {
if (this.streamRequestId === requestId && !this.streamGotPartial) {
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
requestId.slice(0, 12), noSpeechMs);
this.cancelStreamingRecording('no-speech').catch(() => {});
}
}, noSpeechMs);
}
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
const hardCapMs = Number(opts.hardCapMs || 60000);
this.streamHardCapTimer = setTimeout(() => {
if (this.streamRequestId === requestId) {
console.log('[Audio] Stream %s: app-side hardcap %dms erreicht → end',
requestId.slice(0, 12), hardCapMs);
this.stopStreamingRecording('hardcap').catch(() => {});
}
}, hardCapMs + 2000); // +2s damit Bridge zuerst feuert wenn moeglich
console.log('[Audio] Streaming-Aufnahme gestartet (requestId=%s, audioRequestId=%s)',
requestId.slice(0, 12), (opts.audioRequestId || '').slice(0, 16));
return { requestId, ok: true };
} catch (err) {
console.error('[Audio] startStreamingRecording fehlgeschlagen:', err);
this._cleanupStreamLocal('start-failed');
return { requestId: '', ok: false };
}
}
/** Sauberer User-initiated Stop. Sendet stt_stream_end an die Bridge,
* die noch ihren Final-Transcribe macht.
*
* Plus: Fallback-Timer (3s). Wenn die Bridge nicht antwortet (z.B. weil
* veraltete Version ohne Streaming-Handler laeuft), feuern wir den
* Endpoint-Listener trotzdem mit text='' damit die App-UI nicht in
* "wird verarbeitet..." haengt. ChatScreen behandelt das wie den
* No-Speech-Fall (Bubble weg + endConversation). */
async stopStreamingRecording(reason: string = 'user'): Promise<void> {
const reqId = this.streamRequestId;
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason });
} catch (e) {
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
}
// Recorder lokal abschalten — Bridge feuert dann ihrerseits noch
// stt_endpoint + stt_stream_done.
this._cleanupStreamLocal(`stop:${reason}`);
// Fallback-Watchdog: nach 3s noch immer kein Endpoint via RVS angekommen
// → _fireEndpoint mit text='' (idempotent via streamEndpointFired-Latch,
// d.h. wenn echtes stt_endpoint zwischen jetzt und +3s ankommt feuert
// dieser Fallback NICHT).
setTimeout(() => {
if (this.streamEndpointFired) return;
console.log('[Audio] stopStreamingRecording: 3s ohne Bridge-Antwort — fallback fire');
this._fireEndpoint({
audioRequestId: audioReqId,
text: '',
reason: `stop:${reason}:no-response`,
durationS: 0,
sttMs: 0,
});
}, 3000);
}
/** Abbruch ohne dass Brain den Text verarbeitet — z.B. wenn der User
* im Conversation-Window nichts sagt oder cancel drueckt.
*
* Feuert endpointListeners mit text='' damit ChatScreen den Fall genauso
* behandeln kann wie frueher onSilenceDetected→stopRecording()→null:
* Konversation beenden, Ohr zurueck auf armed. */
async cancelStreamingRecording(reason: string = 'cancel'): Promise<void> {
const reqId = this.streamRequestId;
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}` });
} catch {}
this._cleanupStreamLocal(`cancel:${reason}`);
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
this._fireEndpoint({
audioRequestId: audioReqId,
text: '',
reason: `cancel:${reason}`,
durationS: 0,
sttMs: 0,
});
}
/** Feuert den Endpoint-Listener — aber nur einmal pro Session-Cycle.
* Wird sowohl vom RVS-stt_endpoint-Pfad als auch vom Fallback-Watchdog
* und cancelStreamingRecording aufgerufen. */
private _fireEndpoint(ev: SttEndpointEvent): void {
if (this.streamEndpointFired) return;
this.streamEndpointFired = true;
this.endpointListeners.forEach(cb => {
try { cb(ev); } catch (e) { console.warn('[Audio] endpoint listener err:', e); }
});
}
/** Nur-lokale Cleanup: PcmStreamRecorder stoppen, Listener entfernen,
* AudioFocus freigeben, State zurueck auf idle. Nicht ueber RVS
* kommunizieren — Caller hat das schon erledigt (oder eben nicht
* noetig wenn Bridge das Endpoint gefeuert hat). */
private _cleanupStreamLocal(reason: string): void {
if (!this.streamRequestId) return;
console.log('[Audio] Stream cleanup (%s)', reason);
this.streamRequestId = '';
this.streamAudioRequestId = '';
this.streamGotPartial = false;
if (this.streamNoSpeechTimer) {
clearTimeout(this.streamNoSpeechTimer);
this.streamNoSpeechTimer = null;
}
if (this.streamHardCapTimer) {
clearTimeout(this.streamHardCapTimer);
this.streamHardCapTimer = null;
}
if (this.streamPcmChunkSub) {
try { this.streamPcmChunkSub.remove(); } catch {}
this.streamPcmChunkSub = null;
}
if (this.streamPcmErrorSub) {
try { this.streamPcmErrorSub.remove(); } catch {}
this.streamPcmErrorSub = null;
}
PcmStreamRecorder?.stop().catch(() => {});
this._releaseFocusDeferred();
this.setState('idle');
}
/** True wenn aktuell eine Streaming-Session laeuft. */
isStreamingRecording(): boolean {
return !!this.streamRequestId;
}
/** Subscribe auf stt_endpoint — feuert wenn die Whisper-Bridge erkannt
* hat, dass der User fertig gesprochen hat (ML-Endpointer). */
onSttEndpoint(callback: SttEndpointCallback): () => void {
this.endpointListeners.push(callback);
return () => {
this.endpointListeners = this.endpointListeners.filter(cb => cb !== callback);
};
}
/** Subscribe auf stt_partial — Live-Transkript-Updates (optional fuer
* UI-Feedback in der Voice-Bubble). */
onSttPartial(callback: SttPartialCallback): () => void {
this.partialListeners.push(callback);
return () => {
this.partialListeners = this.partialListeners.filter(cb => cb !== callback);
};
}
// --- Wiedergabe ---
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
@@ -988,12 +1381,13 @@ class AudioService {
// releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
// 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
// triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
// wirklich am Ende ist (siehe ensurePlaybackFinishedListener).
// wirklich am Ende ist (siehe Constructor-PcmPlaybackFinished-Handler).
//
// playbackFinishedListeners feuern AUCH erst dort — frueher feuerten
// sie hier (beim Eintreffen des letzten Chunks), das fuehrte zu
// einem Race: OpenWakeWord re-armte waehrend AudioTrack noch hoerbar
// ARIAs Stimme abspielte → naechstes Wake-Word ging unter.
try { await PcmStreamPlayer!.end(); } catch {}
// playbackFinished-Listener informieren (UI-Logik)
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
}
this.pcmStreamActive = false;
+7 -3
View File
@@ -9,13 +9,14 @@
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'location' : Background-GPS-Tracking (opt-in in Settings)
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
* → Trigger-Replies, Reconnects, Push-Reaktionen.
*
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
* den hoechstprioren Slot an (tts > rec > wake > background).
* den hoechstprioren Slot an (tts > rec > wake > location > background).
*/
import { NativeModules } from 'react-native';
@@ -27,13 +28,13 @@ interface BackgroundAudioNative {
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake' | 'background';
type Slot = 'tts' | 'rec' | 'wake' | 'location' | 'background';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
// ist die fallback-Anzeige wenn nichts anderes laeuft.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'location', 'background'];
function topReason(): string {
for (const s of PRIORITY) {
@@ -47,6 +48,7 @@ async function applyState(): Promise<void> {
if (slots.size === 0) {
try { await BackgroundAudio.stop(); } catch {}
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
import('./logger').then(m => m.reportAppDebug('bg.stop', 'service stopped')).catch(()=>{});
return;
}
const reason = topReason();
@@ -54,8 +56,10 @@ async function applyState(): Promise<void> {
await BackgroundAudio.start(reason);
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
reason, [...slots].join('+'));
import('./logger').then(m => m.reportAppDebug('bg.start', `slot=${reason} all=[${[...slots].join(',')}]`)).catch(()=>{});
} catch (err: any) {
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
import('./logger').then(m => m.reportAppDebug('bg.start.fail', err?.message || String(err))).catch(()=>{});
}
}
+64 -1
View File
@@ -14,9 +14,62 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
import { Linking, PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
import Geolocation from '@react-native-community/geolocation';
import rvs from './rvs';
import { acquireBackgroundAudio, releaseBackgroundAudio } from './backgroundAudio';
// Opt-in Background-GPS — Settings-Toggle "GPS auch im Hintergrund".
// Default AUS. Wenn AN: ACCESS_BACKGROUND_LOCATION-Permission noetig
// (kann nicht ueber Standard-Dialog angefordert werden, User muss in
// Android-Settings auf "Immer erlauben" gehen) + ForegroundService mit
// foregroundServiceType=location wird hochgezogen.
export const BG_GPS_STORAGE_KEY = 'aria_gps_background_enabled';
export async function isBackgroundGpsEnabled(): Promise<boolean> {
try {
const v = await AsyncStorage.getItem(BG_GPS_STORAGE_KEY);
return v === 'true';
} catch {
return false;
}
}
export async function setBackgroundGpsEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BG_GPS_STORAGE_KEY, String(enabled));
} catch {}
}
/** Prueft ob ACCESS_BACKGROUND_LOCATION gewaehrt ist und oeffnet sonst die
* Android-App-Settings damit der User "Immer erlauben" auswaehlen kann.
* Returns true wenn permission ok, false wenn User Settings oeffnen muss. */
export async function ensureBackgroundLocationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
try {
const granted = await PermissionsAndroid.check(
'android.permission.ACCESS_BACKGROUND_LOCATION' as any,
);
if (granted) return true;
// Erst FINE_LOCATION anfordern falls noch nicht da
const fine = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
);
if (fine !== PermissionsAndroid.RESULTS.GRANTED) return false;
// Ab Android 10+ kann BACKGROUND_LOCATION NICHT ueber den normalen
// PermissionsAndroid.request abgefragt werden — User muss in Settings
// auf "Immer erlauben" wechseln. Wir oeffnen die App-Settings-Seite.
ToastAndroid.show(
'Bitte in Android-Einstellungen unter Standort "Immer erlauben" auswaehlen',
ToastAndroid.LONG,
);
Linking.openSettings();
return false;
} catch (e) {
console.warn('[gps-track] BG-Permission-Check fehlgeschlagen:', e);
return false;
}
}
type Listener = (active: boolean) => void;
@@ -86,6 +139,14 @@ class GpsTrackingService {
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
return false;
}
// Background-GPS opt-in: wenn aktiv, ForegroundService mit type=location
// hochziehen. Brauche ACCESS_BACKGROUND_LOCATION (User muss in Android-
// Settings 'Immer erlauben' aktivieren). Wenn die fehlt, watchPosition
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
const bgEnabled = await isBackgroundGpsEnabled();
if (bgEnabled) {
try { await acquireBackgroundAudio('location'); } catch {}
}
try {
this.watchId = Geolocation.watchPosition(
(pos) => {
@@ -142,6 +203,8 @@ class GpsTrackingService {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
// Location-Foreground-Service-Slot freigeben (falls vorher acquired)
try { releaseBackgroundAudio('location'); } catch {}
this.active = false;
this.lastChangeAt = Date.now();
this.notify();
+73 -2
View File
@@ -7,10 +7,28 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import { Platform, DeviceEventEmitter } from 'react-native';
import rvs from './rvs';
// Lokales Event damit die SettingsScreen Live Logs / Events Tabs
// auch das sehen was die App SELBST loggt (reportAppDebug/Error).
// Bisher gingen die nur via RVS an die Bridge. Lokal sichtbar = Mama-
// tauglich Debug ohne curl.
export const APP_LOG_EVENT = 'AriaLocalAppLog';
interface LocalLogEntry {
ts: number;
level: 'info' | 'warn' | 'error';
scope: string;
message: string;
}
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
// Eigener Toggle fuer Debug-Logs die ueber RVS an die Bridge gehen
// (/shared/logs/app.log → Diagnostic /api/app-log). Damit der Default-User
// nicht stuendlich Traffic + Disk-Schreiben hat, dieser ist DEFAULT AUS.
// Stefan schaltet's nur ein wenn er ein konkretes Problem debuggen muss.
export const DEBUG_LOGS_TO_BRIDGE_KEY = 'aria_debug_logs_to_bridge';
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
@@ -18,6 +36,7 @@ const originalLog = console.log.bind(console);
const noop = () => {};
let _verbose = true;
let _debugLogsToBridge = false;
function applyState(): void {
console.log = _verbose ? originalLog : noop;
@@ -29,6 +48,10 @@ export async function initLogger(): Promise<void> {
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
_verbose = v !== 'false'; // default: true
} catch {}
try {
const d = await AsyncStorage.getItem(DEBUG_LOGS_TO_BRIDGE_KEY);
_debugLogsToBridge = d === 'true'; // default: false
} catch {}
applyState();
}
@@ -42,6 +65,15 @@ export function setVerboseLogging(verbose: boolean): void {
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
}
export function isDebugLogsToBridge(): boolean {
return _debugLogsToBridge;
}
export function setDebugLogsToBridge(enabled: boolean): void {
_debugLogsToBridge = enabled;
AsyncStorage.setItem(DEBUG_LOGS_TO_BRIDGE_KEY, String(enabled)).catch(() => {});
}
// ─── App-Crash-Reporting via RVS ────────────────────────────────────
//
// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder
@@ -61,9 +93,10 @@ let _reportingInstalled = false;
/** Schickt einen App-Fehler via RVS an die Bridge. */
export function reportAppError(ev: AppErrorEvent): void {
const ts = Date.now();
try {
rvs.send('app_log' as any, {
ts: Date.now(),
ts,
platform: Platform.OS,
level: ev.level || 'error',
scope: ev.scope,
@@ -73,11 +106,49 @@ export function reportAppError(ev: AppErrorEvent): void {
} catch {
// RVS noch nicht connected — Fehler geht im console weiter.
}
// Lokal in den App-Logs-Tab emitten — Errors gehen IMMER durch
// (unabhaengig vom Debug-Toggle).
try {
const entry: LocalLogEntry = {
ts, level: ev.level || 'error', scope: ev.scope, message: ev.message,
};
DeviceEventEmitter.emit(APP_LOG_EVENT, entry);
} catch {}
// Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar)
// den Crash sieht.
console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || '');
}
/** Schickt eine Debug-/Info-Message via RVS an die Bridge. Landet ebenfalls
* in /shared/logs/app.log — abrufbar via `curl /api/app-log?lines=N`.
* Im Gegensatz zu reportAppError: keine Stacktrace, level=info, kein
* console.error. Fuer Live-Diagnose im Hintergrund wenn ADB nicht da ist.
*
* Nur aktiv wenn Settings → Protokoll → Debug-Logs an Bridge AN ist.
* Default aus damit Mama-Modus keine Disk-Schreiblast hat. Error-Reports
* (reportAppError) gehen weiterhin IMMER durch. */
export function reportAppDebug(scope: string, message: string): void {
if (!_debugLogsToBridge) return;
const ts = Date.now();
const trimmed = String(message).slice(0, 2000);
try {
rvs.send('app_log' as any, {
ts,
platform: Platform.OS,
level: 'info',
scope,
message: trimmed,
});
} catch {}
// Plus lokal in den App-Logs-Tab emitten — damit Stefan in der App
// selbst (Settings → Protokoll → Live Logs) sieht was passiert,
// ohne curl gegen Bridge.
try {
const entry: LocalLogEntry = { ts, level: 'info', scope, message: trimmed };
DeviceEventEmitter.emit(APP_LOG_EVENT, entry);
} catch {}
}
/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
export function installGlobalCrashReporter(): void {
+1 -1
View File
@@ -189,7 +189,7 @@ class UpdateService {
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
await RNFS.writeFile(destPath, apkData.base64, 'base64');
const fileSize = await RNFS.stat(destPath);
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
if (Platform.OS === 'android') {
+79 -13
View File
@@ -179,6 +179,8 @@ class WakeWordService {
try {
await OpenWakeWord.start();
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
// Debug-Log via RVS damit wir auch ohne ADB sehen wann es greift
import('./logger').then(m => m.reportAppDebug('wake.start', `armed, keyword=${this.keyword}`)).catch(()=>{});
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return true;
@@ -236,15 +238,24 @@ class WakeWordService {
}
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening);
import('./logger').then(m => m.reportAppDebug('wake.detect',
`keyword=${this.keyword} state=${this.state} barge=${this.bargeListening}`)).catch(()=>{});
this.lastTriggerAt = now;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
try {
await OpenWakeWord.stop();
import('./logger').then(m => m.reportAppDebug('wake.detect', 'native stop ok')).catch(()=>{});
} catch (e: any) {
import('./logger').then(m => m.reportAppDebug('wake.detect', `native stop FAIL ${e?.message}`)).catch(()=>{});
}
}
this.bargeListening = false;
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
if (this.state === 'conversing') {
import('./logger').then(m => m.reportAppDebug('wake.detect',
`barge path: cbs=${this.bargeCallbacks.length}`)).catch(()=>{});
this.bargeCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
});
@@ -252,11 +263,16 @@ class WakeWordService {
return;
}
this.setState('conversing');
setTimeout(() => {
if (this.state === 'conversing') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 200);
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
// nicht zuendekommen, weil Hermes auf einen Native-Wake-Event wartet.
// OpenWakeWord.stop() oben ist awaited → Mikro ist schon frei, kein
// 200ms-Sicherheitsabstand noetig.
import('./logger').then(m => m.reportAppDebug('wake.detect',
`state→conversing, firing ${this.wakeCallbacks.length} callback(s) directly`)).catch(()=>{});
this.wakeCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] wake cb err:', e); }
});
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
@@ -328,21 +344,51 @@ class WakeWordService {
/** Konversation beenden — User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'.
*
* WICHTIG: setzt bargeListening=false BEVOR OpenWakeWord.start() laeuft.
* Grund: wenn endConversation aus dem onPlaybackFinished-Handler kommt,
* feuert direkt danach ein zweiter Listener (stopBargeListening) — der
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
* ist, und unseren frisch re-armierten Listener killen.
*/
async endConversation(): Promise<void> {
if (this.state !== 'conversing') return;
if (this.state !== 'conversing') {
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
return;
}
const wasBarge = this.bargeListening;
// Flag NULLEN bevor wir die Listener triggern. Sonst killt der parallele
// stopBargeListening-Listener (TTS-end) gleich danach unseren Native-
// OpenWakeWord, weil er bargeListening=true sieht und annimmt er muss
// den Listener stoppen.
this.bargeListening = false;
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
if (this.nativeReady && OpenWakeWord) {
// Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
// OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
// und resolved sofort). Wir koennen es trotzdem rufen — billiger
// als state extra zu fragen, garantiert dass nach diesem Pfad
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
try {
await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() OK → state=armed, wasBarge=${wasBarge}`)).catch(()=>{});
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
} catch (err: any) {
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() FAIL: ${err?.message || err} → state=off`,
)).catch(()=>{});
}
}
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
import('./logger').then(m => m.reportAppDebug('wake.end',
`fallback: nativeReady=${this.nativeReady} → state=off`)).catch(()=>{});
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
this.setState('off');
}
@@ -374,15 +420,35 @@ class WakeWordService {
return true;
}
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten.
*
* WICHTIG: setTimeout(800ms) kann im Hintergrund (Display aus) verspaetet
* feuern — JS-Thread ist geparkt. Wenn der Timer >2s ueberfaellig ist,
* hat der User offensichtlich die App verlassen und kommt erst spaeter
* wieder — wir oeffnen das Mikro dann NICHT, sondern beenden die
* Konversation. Sonst sieht der User nach dem App-Resume "Mikro plus-
* aufnahme laeuft" obwohl er gar nichts gesagt hat → wirkt wie Phantom-
* Wake-Word. Klassische Doze-Throttling-Falle wie bei wake.detect frueher. */
async resume(): Promise<void> {
if (this.state !== 'conversing') return;
const scheduledAt = Date.now();
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
await new Promise(resolve => setTimeout(resolve, 800));
if (this.state === 'conversing') {
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
this.wakeCallbacks.forEach(cb => cb());
if (this.state !== 'conversing') return;
const delay = Date.now() - scheduledAt;
if (delay > 2800) {
// Timer war stark verspaetet — JS-Thread war im Hintergrund geparkt.
// Conversation als beendet behandeln statt das Mikro zu oeffnen.
console.log('[WakeWord] resume(): %dms statt ~800ms — App war im Background. endConversation statt mic-open', delay);
import('./logger').then(m => m.reportAppDebug('wake.resume',
`delayed ${delay}ms (>2800) — endConversation statt mic-open`)).catch(()=>{});
// Asynchroner Aufruf — endConversation ist async, kein await damit wir
// hier nicht in einem Promise-Chain haengen.
this.endConversation().catch(() => {});
return;
}
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window (delay=%dms)', delay);
this.wakeCallbacks.forEach(cb => cb());
}
/** True solange das Ohr aktiv ist (armed ODER conversing). */
+108
View File
@@ -556,6 +556,12 @@ class ARIABridge:
for k in ("fluxDefaultModel", "fluxKeywordRaw", "fluxKeywordSwitch", "huggingfaceToken"):
if k in vc:
self._flux_config[k] = vc[k]
# Debug-Log-Toggles fuer Whisper / F5TTS Bridges (Diagnostic-Toggle).
# Default: aus — sonst muellen wir uns volle Disk wenn alles laeuft.
self._debug_log_config: dict = {}
for k in ("whisperDebugLog", "f5ttsDebugLog"):
if k in vc:
self._debug_log_config[k] = bool(vc[k])
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s flux=%s",
self.tts_enabled, self.xtts_voice or "default",
self._f5tts_config or "defaults",
@@ -1304,6 +1310,7 @@ class ARIABridge:
payload["xttsSpeed"] = self._persistent_xtts_speed
payload.update(getattr(self, "_f5tts_config", {}) or {})
payload.update(getattr(self, "_flux_config", {}) or {})
payload.update(getattr(self, "_debug_log_config", {}) or {})
await self._send_to_rvs({
"type": "config",
"payload": payload,
@@ -1978,6 +1985,15 @@ class ARIABridge:
self._flux_config = {}
self._flux_config[k] = payload[k]
changed = True
# Debug-Log-Toggles fuer Whisper- und F5TTS-Bridge — werden via
# naechstem config-Broadcast an die jeweiligen Bridges weitergegeben.
# Persistent damit Toggle einen Container-Restart ueberlebt.
for k in ("whisperDebugLog", "f5ttsDebugLog"):
if k in payload:
if not hasattr(self, "_debug_log_config"):
self._debug_log_config = {}
self._debug_log_config[k] = bool(payload[k])
changed = True
# Persistent speichern in Shared Volume
if changed:
try:
@@ -1991,6 +2007,7 @@ class ARIABridge:
config_data["xttsSpeed"] = self._persistent_xtts_speed
config_data.update(getattr(self, "_f5tts_config", {}))
config_data.update(getattr(self, "_flux_config", {}))
config_data.update(getattr(self, "_debug_log_config", {}))
with open("/shared/config/voice_config.json", "w") as f:
json.dump(config_data, f, indent=2)
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
@@ -2520,6 +2537,59 @@ class ARIABridge:
future.set_result(text)
return
elif msg_type == "stt_endpoint":
# Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus
# einen Endpoint erkannt und schickt den finalen Text direkt.
# Wir uebernehmen die Rolle die sonst _process_app_audio NACH dem
# STT-Schritt hat: STT-Text fuer UI broadcasten + send_to_core.
# Kein Audio-Roundtrip mehr — App-Latenz sinkt deutlich.
text = (payload.get("text") or "").strip()
if not text:
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
payload.get("reason", ""))
return
audio_request_id = payload.get("audioRequestId", "") or ""
voice = payload.get("voice", "") or ""
speed_raw = payload.get("speed")
interrupted = bool(payload.get("interrupted", False))
location = payload.get("location") or None
# Voice-Override fuer Folgenachrichten — gleiche Semantik wie beim
# 'audio'-Event. Nur setzen wenn vom App-Stream mitgegeben.
if voice:
self._next_voice_override = voice or None
logger.info("[rvs] Voice fuer Antworten (via stt_endpoint): %s",
self._next_voice_override or "(Default)")
if speed_raw is not None:
try:
sp = float(speed_raw)
self._next_speed_override = sp if 0.1 <= sp <= 5.0 else None
except (TypeError, ValueError):
self._next_speed_override = None
# State-Persist wie bei _process_app_audio
self._persist_location(location)
self._persist_user_activity()
logger.info("[rvs] stt_endpoint: '%s' (%dms, reason=%s)%s%s reqId=%s",
text[:80],
payload.get("sttMs", 0),
payload.get("reason", ""),
" [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "",
audio_request_id[:16] if audio_request_id else "?")
# Idempotenz ueber audioRequestId — falls App den Stream irgendwie
# nochmal triggern sollte (Reconnect-Race etc.).
client_msg_id = audio_request_id or None
if self._is_duplicate_client_msg(client_msg_id):
return
asyncio.create_task(self._process_endpoint_text(
text, interrupted, audio_request_id, location,
client_msg_id=client_msg_id))
return
elif msg_type == "oauth_callback":
# RVS hat einen OAuth-Provider-Callback empfangen (z.B. Spotify
# nach User-Authorize) und broadcastet ihn. Wir forwarden an Brain,
@@ -2662,6 +2732,44 @@ class ARIABridge:
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
async def _process_endpoint_text(self, text: str,
interrupted: bool = False,
audio_request_id: str = "",
location: Optional[dict] = None,
client_msg_id: Optional[str] = None) -> None:
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
Spiegel-Methode zu _process_app_audio NACH dem STT-Schritt. Bewusst
eigene Methode statt Code-Pfade in _process_app_audio aufdroeseln,
damit der Legacy-Pfad (App schickt 'audio') unangetastet bleibt.
"""
try:
stt_payload = {
"text": text,
"sender": "stt",
}
if audio_request_id:
stt_payload["audioRequestId"] = audio_request_id
if location:
stt_payload["location"] = location
ok = await self._send_to_rvs({
"type": "chat",
"payload": stt_payload,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
if ok:
logger.info("[rvs] STT-Text (endpoint) broadcastet")
else:
logger.warning("[rvs] STT-Text (endpoint) NICHT broadcastet")
except Exception as e:
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
core_text = self._build_core_text(text, interrupted, location)
await self.send_to_core(core_text,
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id)
async def _stt_remote(self, audio_b64: str, mime_type: str) -> Optional[str]:
"""Schickt Audio an die whisper-bridge und wartet auf stt_response.
+4
View File
@@ -38,6 +38,10 @@ const ALLOWED_TYPES = new Set([
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",
// Streaming-STT (Phase 1+2): App schickt PCM live an whisper-bridge,
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
"stt_partial", "stt_endpoint", "stt_stream_done",
"service_status",
"config_request",
"flux_request", "flux_response",
+59
View File
@@ -375,6 +375,41 @@ async def _send(ws, mtype: str, payload: dict) -> None:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
# ──────────────────────────────────────────────────────────────
# DEBUG-LOG ueber RVS → /shared/logs/app.log
#
# Gleiches Pattern wie in whisper-bridge: Stefan's Gamebox ist
# Windows (kein SSH), in Zukunft koennten whisper + f5tts auf
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
#
# Toggle via aria-bridge config broadcast: f5ttsDebugLog (bool).
# ──────────────────────────────────────────────────────────────
_DEBUG_LOG_TO_BRIDGE: bool = False # default OFF — TTS-Renders sind teurer
# zu debuggen, normalerweise nicht noetig
async def _debug_log(ws, scope: str, message: str, level: str = "info") -> None:
"""Schickt einen app_log via RVS → /shared/logs/app.log mit platform='f5tts'.
No-op wenn Toggle aus."""
if not _DEBUG_LOG_TO_BRIDGE:
return
try:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "f5tts",
"level": level,
"scope": scope,
"message": str(message)[:2000],
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
except Exception:
pass
# ── Interne Transkription via whisper-bridge ────────────────
_pending_stt: dict[str, asyncio.Future] = {}
@@ -867,6 +902,30 @@ async def run_loop(runner: F5Runner) -> None:
else:
fut.set_result(payload.get("text") or "")
elif mtype == "config":
# Debug-Toggle (gleiche Semantik wie in whisper-bridge)
if "f5ttsDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE
_DEBUG_LOG_TO_BRIDGE = bool(payload.get("f5ttsDebugLog", False))
if old != _DEBUG_LOG_TO_BRIDGE:
logger.info("Debug-Log-to-Bridge: %s", "ON" if _DEBUG_LOG_TO_BRIDGE else "OFF")
# Last gasp wenn ausgeschaltet wird
if not _DEBUG_LOG_TO_BRIDGE:
try:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "f5tts",
"level": "info",
"scope": "config",
"message": "debug-log OFF (toggle aus)",
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
except Exception:
pass
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
async def _update_with_status(p):
# Schaut ob ein Modell-Wechsel ansteht — falls ja:
+446 -29
View File
@@ -2,8 +2,19 @@
"""
ARIA Whisper Bridge laeuft auf der Gamebox (RTX 3060).
Empfaengt stt_request via RVS FFmpeg-Konvertierung faster-whisper auf GPU
sendet stt_response zurueck an die aria-bridge.
Zwei Modi:
1) Legacy One-Shot: stt_request mit komplettem Audio (mp4/wav/ogg base64)
ffmpeg faster-whisper stt_response. Bleibt fuer Fallback/alte App.
2) Streaming + ML-Endpointer (neu): App schickt live PCM-Chunks waehrend
der Aufnahme. Bridge transkribiert alle ~700ms auf dem Ringbuffer und
feuert stt_endpoint sobald der Transkript-String N ms nicht mehr
waechst. Ersetzt dB/VAD-Stille endpointet auf SEMANTISCHE Stille,
funktioniert im Auto / mit Musik im Hintergrund.
Erwartetes PCM-Format vom App-Native-Modul: 16 kHz mono s16le (genau
das was OpenWakeWord/AudioRecord schon liefert kein Resampling).
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
@@ -21,6 +32,7 @@ import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
@@ -47,6 +59,13 @@ WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
STREAM_TRANSCRIBE_INTERVAL_MS = 700 # alle 700ms transkribieren waehrend Stream laeuft
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
class WhisperRunner:
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
@@ -55,6 +74,9 @@ class WhisperRunner:
self.model_size: str = WHISPER_MODEL
self.model: Optional[WhisperModel] = None
self._lock = asyncio.Lock()
# Serialisiert transcribe()-Calls — faster-whisper ist nicht
# parallel-safe auf einer GPU-Instanz, plus VRAM-Fragmentierung.
self._transcribe_lock = asyncio.Lock()
def _load_blocking(self, size: str) -> None:
logger.info(
@@ -78,19 +100,21 @@ class WhisperRunner:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_blocking, desired_size)
async def transcribe(self, audio: np.ndarray, language: str) -> tuple[str, float]:
async def transcribe(self, audio: np.ndarray, language: str,
beam_size: int = 5, vad_filter: bool = True) -> tuple[str, float]:
if self.model is None:
return "", 0.0
def _run():
segments, info = self.model.transcribe(
audio, language=language, beam_size=5, vad_filter=True,
audio, language=language, beam_size=beam_size, vad_filter=vad_filter,
)
text = " ".join(seg.text.strip() for seg in segments)
return text, info.duration
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _run)
async with self._transcribe_lock:
return await loop.run_in_executor(None, _run)
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
@@ -128,6 +152,14 @@ def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
pass
def pcm_s16le_to_float32(pcm_bytes: bytes) -> np.ndarray:
"""16-bit signed little-endian PCM → float32 in [-1, 1]. Whisper-Format."""
if not pcm_bytes:
return np.zeros(0, dtype=np.float32)
arr = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
return arr
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
@@ -139,14 +171,326 @@ async def _send(ws, mtype: str, payload: dict) -> None:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
# ──────────────────────────────────────────────────────────────
# DEBUG-LOG ueber RVS → /shared/logs/app.log
#
# Stefan's Gamebox ist Windows, kein SSH → wir brauchen Whisper-Bridge-
# Logs ueber den gleichen Pfad wie die App: app_log-Messages via RVS,
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
# Logs-Tab zeigen sie dann mit platform="whisper".
#
# Toggle via aria-bridge config broadcast: whisperDebugLog (bool).
# Default ON solange wir Phase-1/2-Pipeline einfahren — danach
# defaultet aria-bridge ihn aus damit kein Spam.
# ──────────────────────────────────────────────────────────────
_DEBUG_LOG_TO_BRIDGE: bool = True
async def _debug_log(ws, scope: str, message: str, level: str = "info") -> None:
"""Schickt einen app_log via RVS → landet in /shared/logs/app.log mit
platform='whisper'. Idempotent: wenn Toggle aus no-op."""
if not _DEBUG_LOG_TO_BRIDGE:
return
try:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "whisper",
"level": level,
"scope": scope,
"message": str(message)[:2000],
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
except Exception:
pass
# ──────────────────────────────────────────────────────────────
# STREAMING-SESSIONS
# ──────────────────────────────────────────────────────────────
@dataclass
class StreamSession:
"""State pro laufendem Streaming-STT-Request."""
request_id: str
audio_request_id: str
language: str
model: str
endpoint_ms: int
hard_cap_ms: int
voice: str = "" # echoed back via stt_endpoint fuer ChatScreen → TTS-Override
speed: float = 1.0
interrupted: bool = False # Barge-In
location: Optional[dict] = None
sample_rate: int = 16000
pcm_buffer: bytearray = field(default_factory=bytearray)
started_at: float = field(default_factory=time.time)
last_chunk_at: float = field(default_factory=time.time)
last_partial: str = ""
last_growth_at: float = 0.0
last_transcribe_at: float = 0.0
closed: bool = False # nach stream_end gesetzt
endpoint_sent: bool = False # Endpoint nur einmal feuern
class SessionManager:
"""Haelt alle aktiven Streaming-Sessions + Endpointer-Loop."""
def __init__(self, runner: WhisperRunner) -> None:
self.runner = runner
self._sessions: dict[str, StreamSession] = {}
self._ws = None # wird vom run_loop gesetzt
self._loop_task: Optional[asyncio.Task] = None
def attach_ws(self, ws) -> None:
self._ws = ws
def detach_ws(self) -> None:
self._ws = None
# Sessions ueberleben Disconnect — der naechste Reconnect kann sie weiter
# fuettern, falls die App das gleiche requestId nochmal schickt.
# Aber unsere App startet nach Reconnect eine neue Aufnahme; alte Sessions
# werden vom Cleanup-Task entsorgt nach STREAM_SESSION_TTL_S.
def start_session(self, payload: dict) -> Optional[StreamSession]:
request_id = payload.get("requestId", "").strip()
if not request_id:
logger.warning("stt_stream_start ohne requestId — ignoriert")
return None
if request_id in self._sessions:
logger.warning("stt_stream_start: requestId %s schon aktiv — alte Session wird ersetzt",
request_id[:8])
try:
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
except (TypeError, ValueError):
endpoint_ms = STREAM_DEFAULT_ENDPOINT_MS
try:
hard_cap_ms = int(payload.get("hardCapMs") or STREAM_DEFAULT_HARD_CAP_MS)
except (TypeError, ValueError):
hard_cap_ms = STREAM_DEFAULT_HARD_CAP_MS
try:
speed = float(payload.get("speed") or 1.0)
except (TypeError, ValueError):
speed = 1.0
session = StreamSession(
request_id=request_id,
audio_request_id=payload.get("audioRequestId", "") or "",
language=payload.get("language") or WHISPER_LANGUAGE,
model=payload.get("model") or self.runner.model_size or WHISPER_MODEL,
endpoint_ms=endpoint_ms,
hard_cap_ms=hard_cap_ms,
voice=payload.get("voice", "") or "",
speed=speed,
interrupted=bool(payload.get("interrupted", False)),
location=payload.get("location") or None,
sample_rate=int(payload.get("sampleRate") or 16000),
)
self._sessions[request_id] = session
logger.info("Stream-Session offen: id=%s lang=%s model=%s endpointMs=%d hardCapMs=%d voice=%r",
request_id[:8], session.language, session.model,
session.endpoint_ms, session.hard_cap_ms, session.voice or "(default)")
return session
def feed_chunk(self, payload: dict) -> bool:
request_id = payload.get("requestId", "")
session = self._sessions.get(request_id)
if session is None or session.closed:
return False
pcm_b64 = payload.get("pcm", "")
if not pcm_b64:
return False
try:
pcm_bytes = base64.b64decode(pcm_b64)
except Exception:
logger.warning("Stream %s: ungueltige base64-PCM-Daten", request_id[:8])
return False
session.pcm_buffer.extend(pcm_bytes)
session.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> Optional[StreamSession]:
"""Markiert Session als geschlossen. Der Endpointer-Loop macht das
Final-Transcribe + Cleanup."""
session = self._sessions.get(request_id)
if session is None:
return None
session.closed = True
return session
def drop(self, request_id: str) -> None:
self._sessions.pop(request_id, None)
async def run_endpointer(self) -> None:
"""Background-Loop: alle ~200ms ueber alle Sessions iterieren."""
logger.info("Endpointer-Loop gestartet (transcribe-interval=%dms, default-endpoint=%dms)",
STREAM_TRANSCRIBE_INTERVAL_MS, STREAM_DEFAULT_ENDPOINT_MS)
while True:
await asyncio.sleep(0.2)
now = time.time()
# Snapshot — sonst RuntimeError wenn wir waehrend Iteration sessions[]
# mutieren (Endpoint-Drop).
for sid, sess in list(self._sessions.items()):
try:
await self._tick_session(sess, now)
except Exception:
logger.exception("Endpointer-Tick crashed (session=%s)", sid[:8])
# Cleanup: tote Sessions (ohne Chunk seit STREAM_SESSION_TTL_S)
for sid, sess in list(self._sessions.items()):
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
logger.info("Stream %s: TTL ueberschritten (ohne Daten seit %.0fs) — drop",
sid[:8], now - sess.last_chunk_at)
self.drop(sid)
async def _tick_session(self, sess: StreamSession, now: float) -> None:
ws = self._ws
if ws is None:
return # disconnected — Endpointer pausiert bis Reconnect
audio_ms = self._buffer_duration_ms(sess)
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
elapsed_ms = (now - sess.started_at) * 1000.0
if elapsed_ms > sess.hard_cap_ms and not sess.endpoint_sent and not sess.closed:
logger.info("Stream %s: HardCap %dms erreicht — forciere Endpoint",
sess.request_id[:8], sess.hard_cap_ms)
await self._finalize(sess, ws, reason="hardcap")
return
# Closed (stream_end empfangen) → finalisieren mit dem gesammelten Buffer
if sess.closed and not sess.endpoint_sent:
await self._finalize(sess, ws, reason="stream_end")
return
# Noch zu wenig Audio fuer eine erste Transkription
if audio_ms < STREAM_MIN_AUDIO_MS:
return
# Transcribe-Throttling
since_last = (now - sess.last_transcribe_at) * 1000.0
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
return
sess.last_transcribe_at = now
try:
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
except Exception:
logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8])
return
try:
# Kleinere beam_size fuer Streaming-Partials — wir wollen Latenz,
# nicht maximale Genauigkeit. Final-Transcribe (in _finalize) faehrt
# dann mit beam_size=5.
text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=1, vad_filter=True)
except Exception:
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
return
text = text.strip()
grew = bool(text) and text != sess.last_partial
if grew:
sess.last_partial = text
sess.last_growth_at = now
# Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's
# mit damit Diagnostic / ChatScreen Live-Text zeigen kann.
await _send(ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": text,
})
await _debug_log(ws, "stream.partial",
f"id={sess.request_id[:12]} text={text[:80]!r}")
else:
# Stagnation pruefen — Endpoint-Bedingung
if sess.last_growth_at == 0.0:
# Noch gar kein Text erkannt. Wenn der User gar nichts sagt
# springt Brain irgendwann aus eigenem Conversation-Window-
# Timeout in der App raus; wir machen hier nix.
return
silence_ms = (now - sess.last_growth_at) * 1000.0
if silence_ms >= sess.endpoint_ms and not sess.endpoint_sent:
logger.info("Stream %s: Endpoint nach %dms ohne neuen Text — Text=%r",
sess.request_id[:8], int(silence_ms), sess.last_partial[:80])
await self._finalize(sess, ws, reason="endpoint")
def _buffer_duration_ms(self, sess: StreamSession) -> float:
# 16-bit s16le mono → 2 bytes pro Sample
samples = len(sess.pcm_buffer) // 2
if samples == 0:
return 0.0
return (samples / sess.sample_rate) * 1000.0
async def _finalize(self, sess: StreamSession, ws, reason: str) -> None:
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
feuert stt_endpoint + stt_stream_done, droppt Session."""
if sess.endpoint_sent:
return
sess.endpoint_sent = True
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
if audio.size == 0:
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
final_text = ""
stt_ms = 0
duration_s = 0.0
else:
t0 = time.time()
try:
final_text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=5, vad_filter=True)
except Exception:
logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
final_text = sess.last_partial # fallback auf letzten Partial
stt_ms = int((time.time() - t0) * 1000)
duration_s = audio.size / 16000.0
final_text = final_text.strip()
logger.info("Stream %s: FINAL (reason=%s, %.1fs Audio, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
await _debug_log(ws, "stream.final",
f"id={sess.request_id[:12]} reason={reason} "
f"audio={duration_s:.1f}s stt={stt_ms}ms text={final_text[:80]!r}")
# stt_endpoint: das ist DAS Event auf das aria-bridge horcht fuer den
# Brain-Shortcut. Enthaelt alle Felder die bisher in 'audio' lagen,
# ohne den Audio-Roundtrip (App → aria-bridge → whisper → aria-bridge).
endpoint_payload = {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": final_text,
"reason": reason,
"durationS": duration_s,
"sttMs": stt_ms,
"voice": sess.voice,
"speed": sess.speed,
"interrupted": sess.interrupted,
}
if sess.location:
endpoint_payload["location"] = sess.location
await _send(ws, "stt_endpoint", endpoint_payload)
# stt_stream_done: an die App — damit sie ihre Recording-State-Machine
# zurueck auf armed setzt (Mikro aus, ggf. Wake-Word wieder an).
await _send(ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": final_text,
"reason": reason,
})
self.drop(sess.request_id)
# ──────────────────────────────────────────────────────────────
# LEGACY ONE-SHOT (unveraendert)
# ──────────────────────────────────────────────────────────────
async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
request_id = payload.get("requestId", "")
audio_b64 = payload.get("audio", "")
mime_type = payload.get("mimeType", "audio/mp4")
# Modell-Auswahl:
# payload.model gesetzt → nimm das (aria-bridge sendet's basierend auf Config)
# sonst + Modell geladen → behalt das aktuelle (kein sinnloser Swap)
# sonst → fallback auf ENV-Default
model = payload.get("model") or (runner.model_size if runner.model is not None else WHISPER_MODEL)
language = payload.get("language") or WHISPER_LANGUAGE
@@ -156,8 +500,6 @@ async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
try:
t_load = time.time()
# Falls Modell noch nicht geladen (Race-Condition: stt_request vor config)
# → Status-Broadcast loading→ready damit der App-Banner aufpoppt
needs_load = runner.model is None or runner.model_size != model
if needs_load:
await _broadcast_status(ws, "loading", model=model)
@@ -205,7 +547,11 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
await _send(ws, "service_status", payload)
async def run_loop(runner: WhisperRunner) -> None:
# ──────────────────────────────────────────────────────────────
# WS-LOOP
# ──────────────────────────────────────────────────────────────
async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
@@ -216,20 +562,12 @@ async def run_loop(runner: WhisperRunner) -> None:
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try:
logger.info("Verbinde zu RVS: %s", masked)
# max_size 50MB damit grosse stt_request (Voice-Cloning-WAVs als
# base64 koennen mehrere MB werden) nicht das Frame-Limit sprengen
# und die Verbindung mit 1009 'message too big' killen.
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
sessions.attach_ws(ws)
# Initialer Status-Broadcast — uebertont alten "ready"-State
# im App/Diagnostic Banner (sonst denkt der User noch alles ist
# gut von vorher). Wenn Modell schon geladen → ready, sonst
# loading mit aktuellem (Default-)Namen.
# Plus: config_request an aria-bridge — wir wissen nicht ob
# sie auch grad reconnected hat oder schon laenger online ist.
async def _initial_handshake():
try:
if runner.model is not None:
@@ -241,6 +579,11 @@ async def run_loop(runner: WhisperRunner) -> None:
await _broadcast_status(ws, "loading", model=init_model)
logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "whisper"})
# Startup-Marker — App-Logs zeigen damit ob Streaming-Code
# ueberhaupt aktiv ist (Stefan baut auf Gamebox via PS,
# Build/Restart kann unbeabsichtigt alte Version weiterfahren).
await _debug_log(ws, "boot",
"whisper-bridge online — streaming-mode ENABLED, debug-log ON")
except Exception as e:
logger.exception("Initial-Handshake crashed: %s", e)
asyncio.create_task(_initial_handshake())
@@ -259,9 +602,84 @@ async def run_loop(runner: WhisperRunner) -> None:
logger.info("stt_request empfangen (id=%s, %dKB Audio)",
req_id[:8] if req_id != "?" else "?", audio_len // 1365)
asyncio.create_task(handle_stt_request(ws, payload, runner))
elif mtype == "stt_stream_start":
await _debug_log(ws, "stream.start",
f"received id={payload.get('requestId', '?')[:12]} "
f"audioReqId={payload.get('audioRequestId', '?')[:16]} "
f"endpointMs={payload.get('endpointMs')} "
f"hardCapMs={payload.get('hardCapMs')}")
# Ggf. Modell sicherstellen — sonst antwortet der erste
# transcribe-Call mit Leerstring weil Model None.
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
needs_load = (runner.model is None) or (target_model != runner.model_size)
if needs_load:
async def _load_then_start(p, target):
await _broadcast_status(ws, "loading", model=target)
try:
await runner.ensure_loaded(target)
await _broadcast_status(ws, "ready", model=runner.model_size)
except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
sessions.start_session(p)
asyncio.create_task(_load_then_start(payload, target_model))
else:
sessions.start_session(payload)
elif mtype == "stt_audio_chunk":
ok = sessions.feed_chunk(payload)
if not ok:
# Sehr verbose im Schlimmstfall — debug-Level reicht.
logger.debug("stt_audio_chunk: unbekannte/closed session %s",
payload.get("requestId", "")[:8])
await _debug_log(ws, "stream.chunk.reject",
f"unknown/closed session id={payload.get('requestId', '?')[:12]}",
level="warn")
else:
# Nur alle 25 Chunks loggen (=5s Audio) — sonst Spam.
try:
seq = int(payload.get("seq", 0) or 0)
if seq % 25 == 0:
await _debug_log(ws, "stream.chunk",
f"id={payload.get('requestId', '?')[:12]} seq={seq}")
except (TypeError, ValueError):
pass
elif mtype == "stt_stream_end":
req_id = payload.get("requestId", "")
logger.info("stt_stream_end empfangen: id=%s reason=%s",
req_id[:8], payload.get("reason", ""))
await _debug_log(ws, "stream.end",
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
sessions.end_session(req_id)
elif mtype == "config":
# Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog
# damit Stefan im laufenden Betrieb via Diagnostic-Settings
# die Logs an/aus schalten kann.
if "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE
_DEBUG_LOG_TO_BRIDGE = bool(payload.get("whisperDebugLog", False))
if old != _DEBUG_LOG_TO_BRIDGE:
logger.info("Debug-Log-to-Bridge: %s", "ON" if _DEBUG_LOG_TO_BRIDGE else "OFF")
# Last gasp wenn ausgeschaltet wird damit Stefan im Log sieht
# dass der Toggle griff.
if not _DEBUG_LOG_TO_BRIDGE:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "whisper",
"level": "info",
"scope": "config",
"message": "debug-log OFF (toggle aus)",
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
new_model = payload.get("whisperModel") or WHISPER_MODEL
# Laden wenn (a) noch nix geladen, oder (b) Modell wechselt
needs_load = (runner.model is None) or (new_model != runner.model_size)
if needs_load:
logger.info("Config-Broadcast: Whisper-Modell -> %s%s",
@@ -280,11 +698,10 @@ async def run_loop(runner: WhisperRunner) -> None:
await _broadcast_status(ws, "error", error=str(e)[:200])
asyncio.create_task(_swap_with_status(new_model))
else:
# Alle anderen Nachrichten debug-loggen — hilft beim Diagnostizieren,
# ob stt_request ueberhaupt durch den RVS kommt
logger.debug("Unbeachteter Type: %s", mtype)
except Exception as e:
logger.warning("Verbindung verloren: %s", e)
sessions.detach_ws()
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
use_tls = False
@@ -292,10 +709,6 @@ async def run_loop(runner: WhisperRunner) -> None:
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
# Sticky-Fallback verhindern: nach jedem Disconnect-Cycle wieder
# mit wss anfangen. Sonst klebt der Client nach einem temporaeren
# TLS-Hick auf ws:// fest und kommt nie mehr auf wss zurueck —
# genau das Problem das die App + Bridge frueher schon hatten.
use_tls = RVS_TLS
tls_fallback_tried = False
@@ -305,7 +718,11 @@ async def main() -> None:
logger.error("RVS_HOST ist nicht gesetzt — Abbruch")
sys.exit(1)
runner = WhisperRunner()
await run_loop(runner)
sessions = SessionManager(runner)
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
# schlaeft solange das nicht gesetzt ist.
asyncio.create_task(sessions.run_endpointer())
await run_loop(runner, sessions)
if __name__ == "__main__":