Compare commits

...

79 Commits

Author SHA1 Message Date
duffyduck b1ccf29295 release: bump version to 0.0.7.1 2026-05-03 22:11:08 +02:00
duffyduck 4cd9faece2 release: bump version to 0.0.7.0 2026-05-03 21:59:38 +02:00
duffyduck fec8aa977b feat(audio): TTS pausiert bei Anruf + Conversation-Focus haelt Spotify durchgehend gepaust
Bug 1a — Anruf-Pause:
Neues PhoneCallModule.kt nutzt TelephonyCallback (API 31+) bzw.
PhoneStateListener (Pre-12) um auf RINGING/OFFHOOK/IDLE zu reagieren.
Bei Klingeln/Gespraech ruft phoneCall.ts → audioService.haltAllPlayback,
ARIA verstummt sofort. READ_PHONE_STATE Permission wird beim ersten
Start angefragt; ohne Permission failt der Listener leise.

Bug 1b — Spotify-Resume:
AudioFocus wird jetzt an den Conversation-Lifecycle gekoppelt statt an
einzelne Streams. Solange wakeWordState 'conversing' ist, blockt
acquireConversationFocus() jeden per-Stream-Release. Erst beim Wechsel
auf 'armed'/'off' darf der Focus tatsaechlich freigegeben werden.
Verhindert das "Spotify kommt nach 10s wieder hoch"-Phaenomen auch
ueber Render-Pausen + zwischen mehreren ARIA-Antworten hinweg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:44:58 +02:00
duffyduck 20123de827 fix: Sprachnachricht-Bubble defensiv + Bild+Text als eine Anfrage
Bug 2: STT-Result schreibt jetzt eine neue User-Bubble wenn keine
Placeholder im State gefunden wird (statt das Update zu verwerfen).
Schuetzt vor Race-Conditions zwischen audio-send und State-Updates,
damit der gesprochene Text immer im Chat erscheint.

Bug 3: Bild + Text wurden als zwei getrennte Events ('file' + 'chat')
gesendet, jeder triggerte einen eigenen send_to_core. ARIA antwortete
zweimal — einmal "warte auf Anweisung" beim Bild, dann nochmal auf
den Text. Bridge buffert jetzt eingehende file-Events 800ms; kommt in
dem Fenster ein chat, werden alle Files + Text zu einer einzigen
aria-core-Nachricht gemerged. Kein chat → Files alleine wie bisher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:40:15 +02:00
duffyduck 8761d1a1b7 release: bump version to 0.0.6.9 2026-05-01 00:08:08 +02:00
duffyduck abc5b971f4 fix(voice): Stimmen-Wechsel greift wieder — Override bleibt bis naechster Chat-Event
Bug: Voice-Override wurde nach der ersten ARIA-Antwort konsumiert.
Eine ARIA-Antwort triggert aber oft mehrere TTS-Calls (Tool-Use →
Zwischenmeldung → finale Antwort). Der erste nutzte die neue Stimme,
alle folgenden fielen auf self.xtts_voice (= alte Voice aus
voice_config.json) zurueck. Die App schickt nie ein config-Update,
daher blieb voice_config.json fuer immer auf der alten Stimme.

Neue Semantik:
- chat-/audio-Event mit voice="X" → Override="X", gilt fuer alle
  folgenden TTS-Calls bis zum naechsten chat-Event
- chat-Event mit voice="" → Override geloescht, fallback auf
  Default-Voice (voice_config.json / Diagnostic)
- chat-Event ohne voice-Field → Override unveraendert

Audio-Send in ChatScreen.tsx (Push-to-Talk-Pfad) gab voice/speed
gar nicht mit; jetzt konsistent mit dem Tap-to-Talk-Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:04:19 +02:00
duffyduck b588dd7e3b release: bump version to 0.0.6.8 2026-04-26 13:26:00 +02:00
duffyduck 309df9d851 fix(wake-word): Embedding-Output ist rank-4, nicht rank-2 — Trigger funktioniert jetzt
Hauptursache warum kein Wake-Word je triggerte: das Google-Speech-
Embedding-Modell liefert (1,1,1,96), nicht (1,96). Der Cast
`as Array<FloatArray>` warf eine ClassCastException, die vom try/catch
geschluckt wurde — Pipeline lief still ins Leere.

Zusaetzlich:
- WW-Input-Frame-Count wird jetzt aus den Modell-Metadaten gelesen
  (variiert pro Keyword; hey_jarvis=16, computer_v2evtl. anders)
- "Computer" als Wake-Word erweitert (Community-Modell aus
  fwartner/home-assistant-wakewords-collection)

"ARIA" als Wake-Word: gibt's nicht fertig trainiert. Muesste ueber
das openWakeWord Colab-Notebook trainiert werden (~1h auf gratis-GPU).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:24:47 +02:00
duffyduck f2e643d1fb fix(app): Underrun-Schutz im PcmStreamPlayer — Spotify resumed nicht mehr nach 10s
Wenn die Bridge zwischen zwei Saetzen rendert (1-2s pro Satz auf der
Gamebox-RTX 3060), kommen keine neuen PCM-Chunks rein und der AudioTrack-
Buffer laeuft leer. Spotify hat eine eigene Heuristik die nach ~10s
"stummer Lücke" eigenmaechtig die Wiedergabe wiederaufnimmt — auch wenn
wir den AudioFocus formal noch halten.

Fix: Writer-Thread fuettert Stille rein wenn der Puffer unter ~100ms
faellt (~50ms pro Refill-Tick alle 50ms). AudioTrack bleibt damit
durchgehend aktiv, andere Apps respektieren weiterhin den Fokus.

Bonus: 30s-Idle-Cutoff falls die Bridge crashed und kein final-Marker
mehr kommt — sonst wuerde der Writer-Thread ewig Stille fuettern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:18:25 +02:00
duffyduck 6ac374621c release: bump version to 0.0.6.7 2026-04-26 13:08:13 +02:00
duffyduck efbd306597 build(android): ABI-Split auf arm64-v8a — APK von 136 MB auf ~35 MB
Mit ONNX Runtime fuer das Wake-Word kommen Native-Libs fuer alle 4
Architekturen rein (arm64-v8a, armeabi-v7a, x86, x86_64). Das
sprengt sowohl den Gitea-Upload (nginx-Limit) als auch unnoetig die
Auto-Update-Downloads aufs Phone. Per ABI-Split jetzt nur noch
arm64-v8a — deckt jedes Android-Phone seit 2017 ab.

build.sh greift den neuen APK-Pfad (app-arm64-v8a-release.apk),
faellt auf app-release.apk zurueck falls die Splits in build.gradle
deaktiviert werden.

versionCode 606 / versionName 0.0.6.6 (vom Linter mitgehoben).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:04:32 +02:00
duffyduck 4454613a98 release: bump version to 0.0.6.6 2026-04-26 12:59:26 +02:00
duffyduck 55cfb752a2 feat(app): Wake-Word komplett on-device via openWakeWord (ONNX)
Picovoice/Porcupine raus — neuer Stack ist openWakeWord (Apache 2.0,
on-device, ONNX Runtime). Kein API-Key, keine Lizenzgebuehren, Audio
verlaesst das Geraet nicht. Eigene Wake-Words sind via openWakeWord-
Notebook gratis trainierbar.

Pipeline (alles im OpenWakeWordModule.kt):
  1. AudioRecord 16kHz mono int16 in 1280-Sample-Chunks (80ms)
  2. melspectrogram.onnx → 32-mel Frames (mel/10 + 2 wie in Python)
  3. embedding_model.onnx, 76-Frame Sliding Window (stride 8) → 96-dim
  4. hey_jarvis.onnx (oder anderes Keyword) auf letzten 16 Embeddings
  5. Sigmoid-Score, threshold/patience/debounce-Filter
  6. RN-Event "WakeWordDetected" raus

Mitgelieferte Modelle in assets/openwakeword/: hey_jarvis (Default),
alexa, hey_mycroft, hey_rhasspy. Externe Service-API (start/stop/
configure/onWakeWord/...) bleibt identisch — ChatScreen unveraendert.

build.gradle: com.microsoft.onnxruntime:onnxruntime-android:1.17.1
package.json: @picovoice/porcupine-react-native + voice-processor raus
SettingsScreen: AccessKey-Feld weg, neue Keyword-Liste mit Labels
README: Wake-Word-Sektion komplett umgeschrieben (kein Picovoice mehr)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:56:33 +02:00
duffyduck a4d3449e3a release: bump version to 0.0.6.5 2026-04-25 22:51:00 +02:00
duffyduck 44d2c6b4fe fix(app): Spotify-Bounce zwischen ARIA-Antworten + Wake-Word-Doku
AudioFocus wird jetzt mit 800ms Verzoegerung freigegeben — wenn ARIA
direkt eine zweite Antwort hinterherschickt oder das Recording ins TTS
uebergeht, wird das Release abgebrochen. Spotify/YouTube haben damit
keine Mikro-Sekunden-Luecke mehr zum Hochkommen waehrend ARIA spricht.

README: neue Sektion zur Wake-Word-Einrichtung mit Picovoice
(7-Tage-Trial, Console-Link, Anleitung fuer eigene Keywords) und
veraltete Wake-Word-Limitation entfernt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:49:45 +02:00
duffyduck 0309c95aa5 release: bump version to 0.0.6.4 2026-04-25 20:58:10 +02:00
duffyduck 2aa2cc70c9 debug: explicit speed-Log direkt vor F5-TTS infer()-Call
Damit man am Logfile schwarz auf weiss sieht ob der Wert wirklich an
die Library geht — falls bei Stefan speed=0.30 ankommt aber Maia
trotzdem schnell, ist's F5-TTS-Verhalten, nicht Pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:45:16 +02:00
duffyduck 9d0776c819 fix: Text-Auswahl in MessageText — selectable an alle nested Texts
Android-Eigenheit: bei nested Text-Komponenten muss selectable=true
auch an die Kinder; der Wert auf dem Parent erbt sich nicht zuverlaessig.
Plus: dataDetectorType="all" als Fallback fuer System-Linkifizierung,
falls unsere Regex einen Match verpasst.

suppressHighlighting=false damit Long-Press auf den Link-Texten den
Selection-Mode nicht blockt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:41:20 +02:00
duffyduck f031fa159e release: bump version to 0.0.6.3 2026-04-25 20:35:25 +02:00
duffyduck be373466a3 fix: klares UI-Feedback fuer Wake-Word-State
Stefan's Verwirrung: Ohr-Button + KEIN Porcupine = Direkt-Aufnahme,
nicht passives Lauschen. Wenn er lange wartet, schnappt das Mikro
Hintergrundgeraeusche/Sprache auf, sendet ab, Ohr aus. Sah aus wie
"Wake-Word triggerte" — war aber stinknormales Recording.

Fixes fuer klares Feedback:
- Toast bei jedem State-Wechsel:
  * Direkt-Aufnahme (kein Porcupine): "Wake-Word nicht aktiv —
    direkte Aufnahme startet (Mikro hoert mit)"
  * armed: "Lausche auf X..."
  * Wake erkannt: "Wake-Word X erkannt — sprich jetzt"
  * endConversation: "Lausche wieder auf X" oder "Mikro aus"
- Ohr-Button-Icon zeigt drei States:
  🔇 off
  👂 armed (Porcupine lauscht passiv)
  🎙️ conversing (aktive Aufnahme laeuft)
- ChatScreen subscribed wakeWordService.onStateChange fuer Live-
  Updates des Icons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:34:07 +02:00
duffyduck bbf9aed3ba fix: 4 Bugs — STT-Mapping, Speed-Logging, VAD-Logs, Wake-Word-Toast
Bug 2: STT-Result ueberschrieb beide noch unaufgeloeste Audio-Bubbles
mit gleichem Text. Fix: nur die ERSTE matchende Bubble aktualisieren
(findIndex + index-Update statt map). Reihenfolge ist FIFO weil Whisper
sequenziell verarbeitet.

Bug 3: Speed-Param wird nun in jedem Hop geloggt:
  - ChatScreen: "[Chat] sende mit voice=X speed=Y"
  - aria-bridge: "XTTS-Request gesendet (voice=X, speed=Y.YYx)"
  - f5tts-bridge: "F5-TTS: N Satz(e), voice=X, speed=Y.YYx"
Damit kann man im logcat/docker-logs eindeutig sehen wo speed evtl.
verloren geht oder ob die Stimme einfach von Natur aus schnell ist.

Bug 4: VAD-Trigger-Reason mit Schwelle: "VAD NNN ms Stille (Schwelle=NNN ms)".
Plus startRecording loggt jetzt VAD-Stille + MAX-Recording.

Bug 1 (Porcupine): mehr Debug + Toast-Meldungen.
  - init failure: err.name/code/stack ins Log
  - start() ohne Porcupine: Toast "Access Key in Settings setzen"
  - start() Fehler: Toast mit Fehlermeldung
  - configure(): Toast wenn init scheitert
  - Erfolgreiches arming: Toast "Lausche auf X"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:28:46 +02:00
duffyduck 745b4a07c0 release: bump version to 0.0.6.2 2026-04-25 20:20:25 +02:00
duffyduck 23ca815cb2 fix: handlePcmChunk serialisiert — fixes Race bei kurzen Streams
Bei kurzen Saetzen (nur ein paar Chunks + sofort final) konnten die
async handlePcmChunk-Calls parallel laufen. Der final-Chunk konnte
native end() aufrufen BEVOR der vorherige Chunk seinen native start()
abgeschlossen hatte. Der Writer-Thread startete dann mit endRequested
bereits true, verarbeitete keine Chunks sauber → Audio ging verloren.

Fix: Wrapper chaint alle Chunk-Calls an eine Promise-Queue:
  _pcmChunkQueue = Promise.resolve()
  handlePcmChunk → _pcmChunkQueue.then(() => _handlePcmChunkImpl(p))

So werden start/writeChunk/end garantiert in der richtigen Reihenfolge
verarbeitet. Der API-Contract bleibt (gleiches return-Promise).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:58:27 +02:00
duffyduck cc3fac8142 release: bump version to 0.0.6.1 2026-04-25 01:24:31 +02:00
duffyduck cd89e36ec2 fix: alte APKs im Cache werden jetzt aufgeraeumt
Die heruntergeladenen Update-APKs (~20-30MB pro Release) landeten in
CachesDirectoryPath und wurden nie geloescht. Bei regelmaessigen
Updates sammelt sich das auf mehrere 100MB an.

Fix: cleanupOldApks() wird gerufen
  - einmal beim App-Start (Constructor) — alte APKs sind sowieso nicht
    mehr relevant, die aktuelle Version laeuft ja aus dem System
  - vor jedem neuen Download — falls jemand zwei Updates in einer
    Session zieht

Loescht alle *.apk Dateien im CachesDirectoryPath und loggt die
freigemachte Groesse pro Datei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:22:22 +02:00
duffyduck f5b4285d15 release: bump version to 0.0.6.0 2026-04-25 01:13:42 +02:00
duffyduck 248e7c9ae4 fix: preroll=0 wirklich sofort + Trailing-Silence gegen Wort-Cutoff
Zwei Bugs die zusammen dafuer sorgen dass Worte "verschluckt" werden:

1) play() wurde bei preroll=0 erst beim ersten echten Chunk aufgerufen
   — nicht schon nach der Leading-Silence. Dadurch musste AudioTrack
   gleichzeitig Startup UND Audio abspielen, die Hardware-Anfahr-Latenz
   schluckt die ersten Samples.

   Fix: Bei prerollBytes==0 direkt nach dem silence-write play() rufen.
   AudioTrack haelt den Play-State und wartet auf mehr Samples — die
   naechsten Chunks kommen in den bereits laufenden Stream rein.

2) Nach letztem Chunk ging der Writer via return@Thread in den finally-
   Block. Der wartete zwar auf playbackHeadPosition >= totalFrames, aber
   Android's Hardware-Pipeline puffert oft noch ein paar Samples nach —
   stop() kam, Samples futsch.

   Fix: 300ms TRAILING_SILENCE am Ende schreiben. playbackHeadPosition
   erreicht echt bis zum Ende der echten Samples bevor die Stille abspielt.
   Loop umgeschrieben auf mainLoop-Label (break statt return@Thread) damit
   Trailing-Silence garantiert laeuft.

LEADING_SILENCE auf 300ms erhoeht fuer bessere AudioTrack-Warmup-Toleranz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:11:23 +02:00
duffyduck 7058cc8d8d release: bump version to 0.0.5.9 2026-04-25 01:04:00 +02:00
duffyduck 7919489543 feat: Pre-Roll-Buffer kann jetzt auf 0 (sofort abspielen)
F5-TTS ist schnell genug dass der Puffer bei kurzen Saetzen eher
schadet als nuetzt — er verzoegert den play()-Start fuer Sekunden die
dann als Wartezeit auffallen.

Aenderungen:
- audio.ts: TTS_PREROLL_MIN_SEC 1.0 → 0 (Einstellbar in Settings)
- PcmStreamPlayerModule.kt: MIN_PREROLL_SECONDS auf 0.0, Fallback-
  Logic respektiert jetzt 0 als gueltigen Wert (vorher hat der
  .let { if (it > 0) it else DEFAULT } 0 zu 3.5s umgebogen).

Bei preroll=0 greift der Leading-Silence von 200ms immer noch, d.h.
AudioTrack-Startup bleibt sauber. play() wird dann beim allerersten
echten PCM-Chunk aufgerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:02:48 +02:00
duffyduck feac7f2479 feat(diagnostic): Speed-Slider im Voice-Preview-Modal (nicht persistiert)
Neue −0.1 / +0.1 Buttons im Preview-Modal mit aktuellem Wert-Label.
Bei jedem Oeffnen wird der Speed auf 1.0 zurueckgesetzt (bewusst kein
persist — nur zum Experimentieren waehrend das Modal offen ist).

- Range 0.1-5.0, gleiche wie in App-Settings
- Wird beim Play an f5tts-bridge als speed-Param mitgegeben
- Server clampt auf 0.1-5.0, Fallback 1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:54:40 +02:00
duffyduck b80b813703 release: bump version to 0.0.5.8 2026-04-25 00:51:13 +02:00
duffyduck e7bb6c37cb feat: Sprechgeschwindigkeit-Range auf 0.1-5.0 erweitert
TTS_SPEED_MIN 0.5 → 0.1, TTS_SPEED_MAX 2.0 → 5.0.
Bridge-seitige Validierungen (aria_bridge.py + f5tts/bridge.py) mit-
gezogen auf den gleichen Bereich.

Hinweis: Extremwerte (unter 0.5 oder ueber 2.0) koennen bei F5-TTS
verzerrte Ausgaben produzieren — Stefan bekommt die Freiheit zum
Experimentieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:49:05 +02:00
duffyduck d146ca92c4 fix: Aufnahme-Crashes/Double-Tap durch VAD-Multi-Fire + stale closure
Drei zusammenhaengende Bugs:

1. VAD-Timer feuerte im 200ms setInterval WEITER nachdem die Stille-
   Schwelle erreicht war — listeners wurden pro Aufnahme bis zu 5x
   getriggert. Parallel laufende stopRecording()-Calls lieferten
   audio-recorder-player's nativen Layer OOM / Crash.

   Fix: silenceFired-Latch + Timer-Clear SOFORT beim ersten Feuer
   (fireSilenceOnce-Helper). Gleiche Logik fuer Max-Dauer + Conv-Window.

2. VoiceButton silence-listener re-registrierte bei jedem isRecording-
   Flip (deps [isRecording, onRecordingComplete]). Closure-State war
   stale, und bei schnellen flips gabs register/unregister-Races.

   Fix: empty deps, state direkt vom audioService via getRecordingState()
   lesen. onRecordingComplete via Ref (damit der Callback aktuell bleibt
   ohne re-register).

3. handleTap las den Button-State aus React (isRecording), der bei
   schnellen Taps stale sein konnte — "erst zweiter Tap geht" Symptom.

   Fix: audioService.getRecordingState() als Source-of-Truth, plus
   tapBusy-Ref als Anti-Doppel-Tap-Guard waehrend asyncer start/stop.
   'processing'-State wird korrekt ignoriert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:47:53 +02:00
duffyduck fd95af2c40 debug: Log wenn Pre-Roll-Fallback bei kurzem Text greift
Stefan hat aufgeklaert: Auto-Playback geht nur bei LANGEN Saetzen, bei
kurzen nicht. Das passt zur Pre-Roll-Logik: wenn weniger als pre-roll
Bytes gepuffert werden, soll eigentlich der Fallback in end() greifen,
der nach queue-Timeout play() aufruft.

Neuer Log-Eintrag zeigt ob der Fallback ausgeloest wurde:
  "Playback gestartet VOR Pre-Roll (kurzer Text, NNNNB gepuffert)"

Beim naechsten Test mit adb logcat sehen wir direkt:
  * Fallback-Log kommt → play() wurde aufgerufen, Problem liegt woanders
  * Fallback-Log kommt NICHT → endRequested wird nicht rechtzeitig
    erkannt oder Race mit concurrent handlePcmChunk-Calls

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:42:28 +02:00
duffyduck 9e12e0001c debug: Logs fuer Auto-Playback-Bug — canPlay + silent-state sichtbar
Stefan berichtet dass Auto-Playback trotz Closure-Fix nicht greift.
Zwei neue Log-Zeilen die beim naechsten Test direkt zeigen was schief
laeuft:
  - ChatScreen: "[Chat] audio-msg canPlay=X (enabled=Y muted=Z)"
  - audio.ts:   "[Audio] PCM-Stream start: silent=X messageId=Y ..."

Ausreichend um zu unterscheiden:
  * canPlay=false trotz Mund-an → ttsMuted bleibt im State haengen
  * canPlay=true aber silent=true in audio.ts → Ref-Bug oder race
  * silent=false aber nichts hoerbar → native-module oder audio-routing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:38:22 +02:00
duffyduck 1d34143be5 release: bump version to 0.0.5.7 2026-04-25 00:31:12 +02:00
duffyduck 0fc11e33c8 docs: NO_REPLY-Bug raus — schon durch NO_REPLY-Filter (Zeile 45) erledigt
War doppelt gelistet: einmal als erledigter Filter und einmal als
offener "wird als NO angezeigt"-Bug. Der Filter in aria-bridge verwirft
NO_REPLY-Antworten heute still, der Anzeige-Bug ist damit praktisch weg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:29:29 +02:00
duffyduck dae603541b docs: issue.md aufgeraeumt — erledigte Items in die Done-Liste
Offene Bugs auf 3 reduziert (NO_REPLY-Anzeige, Porcupine-Jarvis,
Porcupine-Crash — die zwei letzten abhaengig vom ADB-Logcat-Test).
App-Features-Backlog auf 2 (History-Race, Background-Audio) weil
Text-Auswahl/Autolink/Speed-Setting/Voice-Preview jetzt fertig sind.

Diagnostic Features-Abschnitt leer → geloescht.

Erledigt-Liste um ~18 Punkte ergaenzt (F5-TTS Pre-processing, deutsches
Fine-Tune, maxPayload-Fix, service_status, config_request, Conversation-
Window, Porcupine, HF-Cache Bind-Mount, cleanup-windows, Mute-Bug,
Zombie-Recording, Autolink, Speed-Setting, Preview-Modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:28:37 +02:00
duffyduck 87b4cd305c docs: veraltete F5-TTS-Backlog-Items ausmisten
- Audio-Normalisierung: nie aufgefallen, bei Bedarf zurueck
- F5-TTS Streaming-Inferenz: Upstream-Feature, nicht unseres
- Deepspeed: premature optimization, Render ist durch Pre-Roll
  kaschiert schnell genug
- BigVGAN-Support: obsolet seit Vocos + aihpi German Fine-Tune laeuft

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:26:51 +02:00
duffyduck 190352820c feat: Bug-Runde + 5 App/Diagnostic-Features
Bugs:
- App Mute-/Auto-Playback: onMessage-Closure hielt stale ttsDeviceEnabled/
  ttsMuted → Mute wurde ignoriert + AsyncStorage-Load kam nicht durch.
  Fix via ttsCanPlayRef (live gespiegelt) statt Closure-Variablen.
- App Zombie-Recording: toggleWakeWord hat die laufende Aufnahme nicht
  gestoppt → audioService.recordingState blieb 'recording' → normaler
  Aufnahme-Button wirkungslos. Fix: await stopRecording() vor stop().
- Porcupine robuster: BuiltInKeywords-Enum Mapping mit String-Fallback,
  errorCallback fuer Runtime-Crashes (state zurueck auf off statt
  App-Crash), mehr Logging damit man beim naechsten Issue debuggen kann.

App-Features:
- MessageText Komponente: Text ist durchgehend selektierbar, erkennt
  URLs (http/https), E-Mails, Telefonnummern und macht sie anklickbar
  (oeffnet Browser / Mail-App / Android-Dialer via Linking).
- TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings ->
  "Sprechgeschwindigkeit", 0.5-2.0 in 0.1-Schritten, Default 1.0).
  Wird als speed-Param an die F5-TTS-Bridge durchgereicht.

Bridge-Durchreichen:
- ChatScreen: speed aus AsyncStorage via ttsSpeedRef, an chat/audio/
  tts_request mitgeschickt
- aria-bridge: _next_speed_override wie voice_override, an xtts_request
  weitergereicht
- f5tts-bridge: speed-Param an F5TTS.infer() durchgereicht

Diagnostic-Feature:
- Voice-Preview-Button (Play-Icon) vor dem Delete-X in der Stimmen-Liste
- Modal mit Textfeld (Default-Beispieltext wird bei jedem Oeffnen neu
  gesetzt) und Play-Button
- Server sammelt audio_pcm Frames der Preview-Anfrage, baut WAV,
  schickt base64 zurueck, Browser spielt im <audio>-Tag ab
- 60s Timeout-Safety-Net

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:24:02 +02:00
duffyduck 2264f4e3bc fix: Leeres Feld im Diagnostic bedeutet jetzt wirklich "reset auf default"
Bug: User leert "Custom Checkpoint" in Diagnostic, klickt Anwenden, aber
die Bridge behielt den alten Wert weiter (z.B. BigVGAN-Pfad). Ursache:
  - Server loeschte den Key bei leerem String aus voice_config.json
  - Bridge's update_config sah key absent → "keep current" Semantik
  - Resultat: kein Reset, alter Pfad blieb aktiv, NaN-Output blieb

Fix auf beiden Seiten:
  - diagnostic/server.js: Keys werden immer mit dem Wert gesetzt (auch "")
    statt geloescht. "" landet jetzt explizit in der config.json.
  - f5tts/bridge.py: update_config unterscheidet jetzt:
      * key fehlt in payload  → current behalten (unveraendert)
      * key da + leer         → RESET auf DEFAULT_F5TTS_* (User-Wunsch)
      * key da + Wert         → neuen Wert nehmen

Damit kann der User in Diagnostic ein Feld leeren + Anwenden und die
Bridge schaltet wirklich auf Hard-Default zurueck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:51:10 +02:00
duffyduck 58fd8721e3 fix: Voice-Transkription erzwingt kein "small" mehr — nutzt geladenes Modell
f5tts/bridge.py: das hardcoded model="small" in request_transcription war
ein Fehler — whisper-bridge hat dadurch stumm zwischen Modellen geswappt.
Wenn User large-v3 in Diagnostic eingestellt hatte:
  - f5tts Voice-Transkribierung triggerte Swap zu "small"  (+~500MB Laden)
  - Danach aria-bridge schickte naechsten stt_request mit large-v3
    (+~3GB Laden weil small jetzt im RAM war)
Doppelter Load, unnoetiger Traffic.

Fix:
  - f5tts: kein model mehr im payload, whisper-bridge entscheidet
  - whisper: wenn kein payload.model UND bereits ein Modell geladen →
    das behalten. Nur wenn gar nichts da ist fallback auf WHISPER_MODEL
    env default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:03:45 +02:00
duffyduck 4f494daffb docs: BigVGAN-Warnung deutlich — funktioniert nicht mit unserem Vocos-Setup
Die BigVGAN-Variante des aihpi F5-TTS Checkpoints ist nicht einfach ein
"optional besser" Fallback — sie ist mit dem Default-Vocos-Vocoder den die
f5-tts Library laedt inkompatibel. Output wird NaN, App bleibt stumm.

Stefan hat das probiert, App stumm, 10 Minuten Debugging. README war zu
locker formuliert ("Meist hoehere Quali") — jetzt klar als "funktioniert
AKTUELL NICHT" gekennzeichnet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:49:54 +02:00
duffyduck 958c8d6fc6 fix(f5tts): NaN/Inf in Modell-Output sauber abfangen vor int16-Cast
F5-TTS generiert gelegentlich NaN/Inf samples — ohne sanitize lief der
int16-Cast in undefined behavior (RuntimeWarning + kaputter Sound in den
entsprechenden Stellen). Jetzt: nan_to_num vor clip, plus Warnung wie
viele samples betroffen waren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:35:57 +02:00
duffyduck 5ba89c7191 docs: README-Abschnitt fuer deutsches F5-TTS Fine-Tune (aihpi)
Konfig-Tabelle mit den konkreten Diagnostic-Werten fuer das deutsche
Fine-Tune von aihpi/F5-TTS-German — Modell-Architektur, hf:// Pfade,
empfohlene cfg_strength / nfe_step. Plus Hinweis auf die BigVGAN-
Variante als Alternative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:34:36 +02:00
duffyduck b373f915b5 feat(f5tts): HF-URL Support fuer Custom Checkpoints (aihpi/F5-TTS-German)
_resolve_hf_path wandelt hf://user/repo/path → lokaler Download via
huggingface_hub.hf_hub_download. So kann man in Diagnostic einfach die
HF-Pfade fuer custom Modelle reinschreiben, ohne erst manuell zu
downloaden + zu mounten.

Format: hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors
        hf://aihpi/F5-TTS-German/vocab.txt

Diagnostic UI: Placeholders + Labels angepasst mit Beispiel-HF-Pfaden
und Hinweis dass fuer Fine-Tunes "F5TTS_Base" statt "F5TTS_v1_Base"
als Architektur-Name gesetzt werden muss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:16:44 +02:00
duffyduck 7748834a0f fix(f5tts): Ref-WAV Preprocessing — Loudness + Silence-Trim
F5-TTS reagiert empfindlich auf leise / verrauschte / zerhackte
Referenzen — wir haben bisher nur auf 24kHz mono + 10s geclipped.
Jetzt zusaetzlich:
  - silenceremove am Anfang (bis Speech einsetzt, <-50dB)
  - silenceremove am Ende (0.5s Stille nach letzter Speech = Cutoff)
  - loudnorm -16 LUFS (EBU R128) fuer konsistente Amplitude

Damit sieht das Modell saubere, konstant laute Referenz-Audios statt
kaputter Clips mit Ausklang oder leiser Aufnahme. Besonders bei Deutsch
(wo F5TTS_v1_Base schwach ist) hilft jede Input-Konsistenz der Quali.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:07:58 +02:00
duffyduck 8b52f4c92b fix(f5tts): Referenz-WAV auf 10s clippen + txt neu transkribieren
F5-TTS hat ein Hard-Limit von 12s fuer das Referenz-Audio — laengere
WAVs werden intern abgeschnitten, aber unser ref_text war das komplette
Transkript. Text und Audio wurden dadurch unaligned, Render-Qualitaet
leidet und der initial Warmup-Render dauerte 57s statt 5s.

Fix:
  - normalize_ref_wav(max_seconds=10): ffmpeg schneidet auf 10s + 24kHz
    mono, gibt was_modified zurueck damit Caller den txt invalidieren kann
  - handle_voice_upload: clippt VOR der Transkription, Whisper sieht also
    nur die 10s → txt passt garantiert zum Audio
  - _do_tts: checkt vor jedem Render die WAV-Dauer. WAVs > 10.5s werden
    geclippt, .txt geloescht → on-the-fly Neu-Transkription beim Render

Bestehende kaputte Voices (wie MAIA mit 600+ Worten txt zu einem 20s
Audio) werden beim naechsten Render automatisch gefixt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:42:33 +02:00
duffyduck dc20570f6d debug: Initial-Handshake Logs damit man sieht was passiert
Beim user kommt nach 'RVS verbunden' nichts mehr — Modell-Download
startet nicht, banner aktualisiert sich nicht. Vermutung: alter Code
laeuft noch (kein neu gebauter Container) ODER der Initial-Handshake
crashed silent (asyncio.create_task ohne await schluckt Exceptions).

- whisper + f5tts: Initial-Handshake mit logger.info Zeilen, damit
  man sieht ob er ueberhaupt ausgefuehrt wird
- f5tts: zusaetzlich exception-Catch + fehler-broadcast falls der
  Modell-Load crashed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:54:12 +02:00
duffyduck 744a27cfd1 fix: HF-Cache zurueck + Banner-Bug + config_request Pattern
Vier Bugs in einem Aufwasch:

1. HF-Cache als Bind-Mount zurueck
   xtts/hf-cache:/root/.cache/huggingface fuer beide Bridges. War vorher
   raus, dadurch jedes Container-Restart = ~3GB Whisper-Download +
   ~1GB F5-TTS-Download. User dachte 5min ist einmalig — ist aber bei
   jedem Restart. Jetzt: einmal pro Maschine geladen, fertig.

2. Banner zeigte stale "ready"
   whisper-bridge sendete beim Connect nur dann Status wenn Modell schon
   geladen war. Sonst blieb der App/Diagnostic Banner auf dem alten
   "ready" State von vor dem Restart haengen — User sah "bereit" obwohl
   gerade gar nichts geladen war. Jetzt wird IMMER ein Status broadcast:
   ready oder loading.

3. config_request Pattern
   aria-bridge wusste nicht wann Gamebox-Bridges sich (re)connecten.
   Wenn die nach aria-bridge kamen, verpassten sie den Config-Broadcast
   und blieben mit Hard-Defaults stehen.
   Jetzt: whisper- und f5tts-bridge senden beim Connect ein
   config_request, aria-bridge antwortet mit der persistierten Config
   (whisperModel, xttsVoice, f5tts*-Felder).

4. RVS ALLOWED_TYPES um config_request erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:46:47 +02:00
duffyduck 37c5f6c368 fix: dynamischer STT-Timeout — whisper Modell-Download nicht abkappen
aria-bridge horcht jetzt auf service_status fuer den Service 'whisper'.
Solange whisper-bridge im 'loading' steckt (Erst-Download large-v3 kann
1-2 Min dauern), gilt fuer stt_request ein Timeout von 300s statt 45s.
Sobald 'ready', zurueck auf 45s — reicht selbst fuer lange Audios.

Symptom vorher: Beim ersten Sprechen nach Container-Restart hat aria-
bridge nach 45s aufgegeben und lokal gefallback waehrend whisper-bridge
noch fleissig den Download laufen hatte. Damit wurde der Sinn der
Auslagerung kaputt gemacht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:06:04 +02:00
duffyduck a361015ff4 fix: WebSocket max_size hochgedreht — voice_upload sprengte Default 1MB
Symptom: aria-whisper-bridge bekam beim ersten internen stt_request
(via voice_upload mit WAV als base64, ~2.4MB) den Frame zu Gesicht,
default ws-max ist 1MB → mit Close-Code 1009 abgewiesen → Verbindung
tot → naechster stt_request lief in Timeout → lokales Fallback.

Fixes:
- whisper-bridge: max_size=50*1024*1024 in websockets.connect()
  (gleicher Wert wie f5tts-bridge schon hat)
- RVS-Server: maxPayload=50*1024*1024 in WebSocketServer-Optionen,
  damit der Server die Frames nicht selbst auf 1MB cappt bevor er
  sie an die Bridge weiterleitet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:54:36 +02:00
duffyduck d83b555209 fix(whisper): kein eager preload mehr — wartet auf config-Broadcast
Vorher: Container-Start lud erst 'small' (env default), dann nochmal
das in Diagnostic konfigurierte Modell (z.B. large-v3) wenn die
config-Broadcast vom aria-bridge ankam. Doppelter Download, doppelte
Wartezeit, doppelter VRAM-Peak.

Jetzt:
- Initial wird NICHTS geladen
- aria-bridge sendet die persistierte voice_config.json kurz nach
  RVS-Connect → whisper-bridge sieht den richtigen Modellnamen
- config-Handler erkennt: noch nichts geladen ODER Wechsel
  → loading-Broadcast → ensure_loaded → ready-Broadcast
- stt_request-Handler: gleicher Status-Broadcast falls Race-Condition
  (Spracheingabe in den ersten 1-2s nach Container-Start)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:50:46 +02:00
duffyduck a029267d9d release: bump version to 0.0.5.6 2026-04-24 16:25:53 +02:00
duffyduck 8ba6a71a49 feat(app): service_status Banner oben in ChatScreen
App-Pendant zum Diagnostic-Banner. Wenn die Gamebox-Bridges (F5-TTS /
Whisper) ihren Lade-Status broadcasten, zeigt die App oben unter der
Verbindungs-Statusleiste ein farbiges Banner:
  Gelb  = irgendwas laedt   (NICHT wegtippbar)
  Gruen = alles bereit      (tippbar zum Schliessen)
  Rot   = Fehler

Banner aggregiert beide Services in einer Kachel. Dismiss-State wird
zurueckgesetzt sobald irgendein Service wieder in 'loading' geht
(z.B. Modell-Wechsel via Diagnostic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:24:47 +02:00
duffyduck 2f625572fc feat: HF-Cache raus + service_status Banner in Diagnostic
Stefan akzeptiert die ~5min Modell-Download-Zeit nach jedem Container-
Start, dafuer keine 50GB Cache-Bloat mehr und kein Bind-Mount-Verzeichnis
zu pflegen.

- xtts/docker-compose.yml: hf-cache Bind-Mount entfernt fuer beide
  Bridges. Modelle werden im writable Container-Layer abgelegt und mit
  jedem `docker compose down` automatisch weggeraeumt.
- xtts/.gitignore: hf-cache/ Eintrag raus
- RVS ALLOWED_TYPES: service_status hinzu

Bridges broadcasten Lade-Status:
- f5tts-bridge: bei Connect 'loading' -> ensure_loaded -> 'ready'.
  Auch bei config-getriggertem Modell-Wechsel: erst 'loading' Broadcast,
  dann reload, dann 'ready'.
- whisper-bridge: gleiches Pattern. Modell wird jetzt erst nach
  RVS-Connect geladen damit der loading-Broadcast tatsaechlich rausgeht.

Diagnostic:
- server.js: service_status wird an Browser durchgereicht
- index.html: neues Banner unten rechts (fixed position) zeigt Status
  fuer beide Services. Aggregiert: Icon ist Lupe waehrend Loading,
  Check wenn alles ready, X bei Error.
- Wenn alles ready: X-Button erscheint (manuell schliessen) +
  nach 8s automatisches Fade-Out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:21:19 +02:00
duffyduck ac56916eb0 fix(android): minSdkVersion 23 -> 24 (Porcupine erfordert Android 7+)
@picovoice/porcupine-react-native deklariert minSdkVersion 24, dadurch
schlug der Manifest-Merger fehl wenn die App weiter auf 23 stand.
Android 7.0 ist eh das pragmatische Minimum (Geraete <7.0 sind <1% Markt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:08:10 +02:00
duffyduck ae08a5051c fix(deps): porcupine-react-native 3.0.6 existiert nicht — auf 3.0.5 pinnen
3.0.6 war geraten und gibt's nicht im npm Registry. Aktuelle stabile 3.x
ist 3.0.5; 4.0.0 hat Breaking Changes. Beide Picovoice-Packages auf
exakte Version gepinnt damit keine Auto-Bumps fies werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:03:35 +02:00
duffyduck d372cd638e release: bump version to 0.0.5.5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:02:37 +02:00
duffyduck 60c5cb7e59 fix(cleanup-windows): ASCII-only damit Windows-PowerShell 5.1 parsen kann
Windows-PowerShell 5.1 liest .ps1 ohne UTF-8 BOM als Windows-1252;
em-dashes (-), Pfeile (->), Box-Zeichen, Emojis (OK/FAIL/WARN) wurden
als Mojibake interpretiert ("â€"" fuer "-") und sprengten den Parser.

Loesung: alle Sonderzeichen durch ASCII-Aequivalente ersetzt:
  - "-" / "->" statt em-dash / Pfeil
  - "===" statt Box-Linien
  - "[OK]" / "[FAIL]" / "[WARN]" statt Emojis
Funktioniert jetzt zuverlaessig auf jeder Windows-PS-Version ohne BOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:01:56 +02:00
duffyduck 607a4c9ff8 add: cleanup-windows Self-Elevation + .bat-Wrapper
cleanup-windows.ps1:
  - Defensive Set-ExecutionPolicy Bypass am Anfang
  - Self-Elevation: wenn nicht als Admin gestartet, relauncht das Script
    sich selbst als Admin mit -ExecutionPolicy Bypass + Original-Args.
    User muss nur einmal UAC bestaetigen, kein extra Befehl mehr noetig.

cleanup-windows.bat:
  - Wrapper der powershell.exe mit -ExecutionPolicy Bypass aufruft.
  - Funktioniert auch wenn Windows die .ps1 direkt blockt (z.B. unsignierte
    Scripts global gesperrt).
  - Aufruf: cleanup-windows.bat stefan [-SkipPrune] [-PruneOnly]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:59:55 +02:00
duffyduck 4ea16cfa8f add: cleanup-windows.ps1 — VHDX-Cleanup fuer Gamebox
Ein Script das auf der Gamebox (Windows + Docker Desktop + WSL2) alle
.vhdx Files findet und via diskpart compactet. Gibt den Speicherplatz
zurueck den man IN den Distros/Containern geloescht hat aber von der
VHDX bisher nicht freigegeben wurde.

Nutzung (PowerShell als ADMIN):
  .\cleanup-windows.ps1 stefan
  .\cleanup-windows.ps1 -User stefan -SkipPrune    # nur compacten
  .\cleanup-windows.ps1 -User stefan -PruneOnly    # nur prune

Default-Flow:
  1. docker system prune -a --volumes -f + builder prune
  2. wsl --shutdown
  3. Alle gefundenen ext4.vhdx (Docker Desktop + WSL-Distros) compacten
     via diskpart 'compact vdisk' (kein Hyper-V noetig)

Zeigt fuer jedes File "vor → nach (gespart X GB)" und am Ende eine
Gesamt-Summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:54:27 +02:00
duffyduck 6ce9880bc0 fix: HF-Modell-Cache als Bind-Mount statt Docker Volume
Beide Bridges teilen sich jetzt einen Bind-Mount ./hf-cache:/root/.cache/
huggingface. Vorher waren das zwei getrennte Named Volumes
(f5tts-models + whisper-models), die unter Docker Desktop / Windows
in der docker-desktop-data.vhdx gelandet sind und die VHDX nie wieder
freigegeben haben — auch nach docker volume rm bleibt der belegte Platz
in der VHDX bis zum Factory Reset.

Bind-Mount loest beides:
  - Files direkt im xtts/hf-cache/ sichtbar, einfach im Explorer zu loeschen
  - Kein VHDX-Bloat mehr
  - Beide Container teilen sich den Cache (HF-Struktur identisch, keine
    Konflikte da andere Modelle)

Cleanup von vorhandenen 50GB:
  docker compose down
  docker volume rm xtts_f5tts-models xtts_whisper-models  (oder via
    Docker Desktop UI)
  Anschliessend in Docker Desktop: Settings -> Resources -> Disk image
    location -> Disk usage -> "Clean up" / Reset wenn die VHDX nicht
    schrumpft.

xtts/.gitignore: hf-cache/ + voices/ + .env

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:47:18 +02:00
duffyduck 187ffad7ee feat: F5-TTS Tuning ueber Diagnostic statt .env
Folgt der "keine neuen Settings in .env" Regel.

f5tts/bridge.py:
  - F5TTS_MODEL/CKPT_FILE/VOCAB_FILE/CFG_STRENGTH/NFE_STEP ENV-Vars raus
  - Hard-coded Defaults im Code (DEFAULT_F5TTS_*)
  - F5Runner besitzt Live-Settings als Instance-Vars + update_config()
  - config-Broadcast triggert Modell-Reload nur wenn Modell-relevantes
    sich aendert (cfg_strength/nfe_step ohne Reload)
  - F5TTS_DEVICE bleibt ENV (Hardware-Bootstrap)

xtts/docker-compose.yml: F5TTS_* ENV-Vars rausgenommen, Kommentar
verweist auf Diagnostic-Config.

aria-bridge: nimmt f5tts*-Felder im config-Handler entgegen, persistiert
sie in voice_config.json. Beim RVS-Connect broadcastet die Bridge die
persistierte Config einmalig — damit die f5tts-bridge nach Container-
Restart automatisch die zuletzt gewaehlten Settings bekommt, ohne dass
der User in Diagnostic was klicken muss.

Diagnostic UI:
  - Neuer aufklappbarer "F5-TTS Modell-Tuning (advanced)" Bereich
  - Felder: Modell-ID, Custom-Checkpoint, Vocab, cfg_strength, nfe_step
  - voice_config beim Laden: Felder werden zurueck in die UI gesetzt
  - sendVoiceConfig schickt die neuen Felder mit
  - Server: send_voice_config persistiert die Felder, leere Strings
    werden geloescht damit die Hard-Defaults greifen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:44:58 +02:00
duffyduck 467f95424e fix: F5-TTS Voice-Referenztext + Standard-Eintrag raus
Bug-Root: voice_upload schrieb "Das ist ein Referenz Audio." als Platzhalter
wenn die whisper-bridge nicht erreichbar war. F5-TTS bekam dann diesen Text
als Sprach-Anker, sah aber im WAV ganz andere Worte → verwirrtes Modell,
halluziniert in beliebiger Sprache (z.B. Spanisch).

Fixes:
- handle_voice_upload: schreibt KEINE Platzhalter-.txt mehr. Bei Failure
  bleibt die .txt weg → naechste TTS-Nutzung zieht via on-the-fly retry
  nach.
- _do_tts: Legacy-Platzhalter wird beim Render erkannt und geloescht,
  Transkription on-the-fly neu angezogen. Bestehende kaputte voices
  reparieren sich automatisch beim ersten Render.

UI-Aufraeumung: F5-TTS hat keine "Standard"-Stimme — der Eintrag ist raus
in App SettingsScreen + Diagnostic. Diagnostic-Dropdown hat jetzt einen
disabled-Hinweis "(keine Stimme gewaehlt)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:53 +02:00
duffyduck c1a5518fb7 fix(f5tts): cfg_strength hochgezogen damit Deutsch nicht ins Spanische rutscht
F5TTS_v1_Base ist hauptsaechlich auf Englisch+Chinesisch trainiert; bei
Deutsch (oder anderen Romance/Germanic-Sprachen) schwimmt der Generator
ohne starkes Conditioning gerne in eine andere Sprache.

- cfg_strength 2.0 → 2.5 (per ENV F5TTS_CFG_STRENGTH ueberschreibbar)
- nfe_step bleibt 32 (per ENV ueberschreibbar)
- F5TTS_CKPT_FILE / F5TTS_VOCAB_FILE als ENV — damit man eine Community-
  German-Checkpoint einhaengen kann ohne Code-Aenderung

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:30:08 +02:00
duffyduck 22fa4b3ccf feat: Porcupine Wake-Word Integration (Built-In Keywords, "Jarvis" default)
WakeWordService wrappt jetzt Picovoice Porcupine:
  - loadFromStorage(): Access Key + Keyword aus AsyncStorage, init Porcupine
  - configure(key, keyword): Settings-Wechsel, Re-Init
  - start(): wenn Porcupine bereit → 'armed' (passives Lauschen),
    sonst Fallback auf direktes 'conversing' (klassischer Modus)
  - onWakeDetected: Porcupine pausieren → 'conversing' → wakeCallback
  - endConversation: Porcupine wieder starten → 'armed' (Wake-Word weiter
    aktiv im Hintergrund, kein erneuter Tap noetig)
  - Pro Geraet eigene Wahl: jeder User kann sein eigenes Wake-Word haben

Settings: neuer Bereich "Wake-Word"
  - Picovoice Access Key Input (mit Eye-Toggle), kostenlos auf
    console.picovoice.ai
  - Built-In Keyword Chips: jarvis, computer, picovoice, porcupine,
    bumblebee, terminator, alexa, hey google, ok google, hey siri
  - "Speichern + Aktivieren" Button mit Status-Feedback
  - Hinweis dass "ARIA" Custom-Keyword spaeter via Diagnostic kommt

ChatScreen: ruft wakeWordService.loadFromStorage() beim Mount.

package.json: @picovoice/porcupine-react-native + react-native-voice-processor
hinzugefuegt — npm install + native rebuild noetig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:23:51 +02:00
duffyduck 1b8a51aad0 feat: Conversation-Window — Gespraech endet nach Stille statt Endlos-Loop
Der Gespraechsmodus war bisher ein Endless-Loop: Mikro hat sich nach
jeder ARIA-Antwort wieder geoeffnet bis MAX_RECORDING_MS, danach Speech-
Gate verworfen und neu starten. Das Ohr blieb ewig an.

Neue Logik:
  audio.ts: startRecording(autoStop, noSpeechTimeoutMs?) — wenn der User
    innerhalb des Timeouts nicht anfaengt zu sprechen, wird Stille
    gemeldet → stopRecording → Speech-Gate verwirft → result=null.
  wakeword.ts: drei States off/armed/conversing. start() geht direkt in
    'conversing' (kein Wake-Word verfuegbar; Stub fuer spaetere Porcupine-
    Integration). endConversation() bei No-Speech.
  ChatScreen: Aufnahme bekommt das Window aus AsyncStorage durchgereicht.
    Bei null-Result → endConversation, UI-State synchron.
  Settings: neuer +/- Block "Konversations-Fenster" 3-20s (Default 8).

Mit dem Stub ist die Architektur bereit fuer Porcupine: dann geht
endConversation auf 'armed' statt 'off' und der Wake-Word-Detector
laeuft passiv weiter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:14:01 +02:00
duffyduck 578ade3544 docs: README + issue.md auf Stand mit F5-TTS, Whisper-Gamebox, App-Settings
README:
- Architektur-Diagramm: Gamebox-Stack mit f5tts-bridge + whisper-bridge
- Voice Bridge: STT primaer remote (Gamebox), TTS via F5-TTS
- Diagnostic-Section: Voice-Status, Disk-Voll Banner, Auto-Transkription
- App-Features: VAD-Toleranz/Pre-Roll/Audio-Pause konfigurierbar
- XTTS-Section ersetzt durch "Gamebox-Stack — F5-TTS + Whisper"
- Roadmap Phase 1: alle juengsten Erledigungen ergaenzt

issue.md: alle erledigten Punkte der letzten Iterationen aufgenommen
(Pre-Roll, Decimal-TTS, voice_ready, Whisper-Gamebox, F5-TTS, AudioFocus
Pause, VAD-Setting, ...). Offene Liste auf den aktuellen Stand reduziert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:51:27 +02:00
duffyduck ed2f1bb5ee release: bump version to 0.0.5.4 2026-04-24 14:45:17 +02:00
duffyduck 0a04972455 feat: Stille-Toleranz fuer Aufnahme einstellbar in App-Settings
Neuer +/- Block in SettingsScreen → Spracheingabe → "Stille-Toleranz",
1.0-8.0s, Default 2.8s. Wert in AsyncStorage (aria_vad_silence_sec).
audio.ts liest den Wert beim Aufnahme-Start und nutzt ihn fuer den
VAD-Auto-Stop-Schwellwert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:44:17 +02:00
duffyduck 2a4379eb64 release: bump version to 0.0.5.3 2026-04-24 14:41:59 +02:00
duffyduck e64df23bb7 fix: TTS pausiert andere Apps statt zu ducken + VAD/Mic laenger
AudioFocus.requestDuck nutzt jetzt AUDIOFOCUS_GAIN_TRANSIENT (statt
TRANSIENT_MAY_DUCK) — Spotify/YouTube pausieren komplett solange ARIA
spricht und kommen nicht mitten drin wieder hoch.

PcmStreamPlayer.end() resolved jetzt erst wenn der native Writer-Thread
wirklich fertig ist (alle Samples aus dem Pre-Roll-Puffer ausgespielt).
audio.ts wartet entsprechend, bevor AudioFocus.release() gerufen wird —
behebt das "Musik dreht hoch waehrend Antwort noch laeuft"-Problem.

Mic-Aufnahme: VAD_SILENCE_DURATION_MS 1800 → 2800ms (mehr Toleranz fuer
Sprechpausen), MAX_RECORDING_MS 30s → 120s (laengere Erklaerungen
moeglich, Notbremse bleibt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:40:58 +02:00
duffyduck 576ae925dd feat(phase2): XTTS durch F5-TTS ersetzt — Voice Cloning auf der Gamebox
Neuer aria-f5tts-bridge Container:
  - Python-Service, laedt F5TTS_v1_Base beim Start
  - Empfaengt xtts_request via RVS, synthetisiert mit Voice-Cloning,
    streamt PCM-Chunks (audio_pcm, 16-bit s16le) wie zuvor die XTTS-Bridge
  - Teilt lange Texte an Satzgrenzen, streamt satzweise
  - Fade-In auf erstem Chunk, Queue gegen parallel-Render

Voice-Management:
  - Speicherort weiterhin /voices/, aber jetzt als Paar
    {name}.wav + {name}.txt (F5-TTS braucht Referenz-Transkription)
  - voice_upload: WAV speichern, intern stt_request an whisper-bridge
    senden, Transkription als .txt ablegen → user muss nichts eintippen
  - On-the-fly Transkribierung: wenn eine WAV ohne .txt liegt, wird
    bei erstem Render/Preload nachgezogen
  - Bestehende RVS-Messages (voice_upload/xtts_list_voices/... etc.)
    bleiben unveraendert → keine App/Diagnostic-Aenderung noetig

Gaming-PC docker-compose:
  - xtts + xtts-bridge Services entfernt
  - f5tts-bridge + whisper-bridge bleiben/kommen rein
  - Volume xtts-models → f5tts-models

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:34:11 +02:00
duffyduck e170991222 fix: _send_to_rvs gibt Success-Bool zurueck, _stt_remote bricht bei
Send-Fehler sofort ab statt in den 45s-Timeout zu laufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:16:08 +02:00
duffyduck a1343ee18f debug: Logs beim stt_request-Roundtrip — aria-bridge loggt beim Senden,
whisper-bridge loggt eingehende stt_request (id + Audio-Groesse).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:13:41 +02:00
duffyduck b2d3c935d8 fix(whisper): requests explizit als Dependency — faster-whisper 1.0.3
zieht sie selber nicht rein, Container crashed sonst beim Import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:59:59 +02:00
duffyduck 49089eee4b release: bump version to 0.0.5.2 2026-04-24 13:50:19 +02:00
duffyduck e544992c9f feat(phase1): Whisper STT auf die Gamebox ausgelagert
Neuer Container aria-whisper-bridge auf der Gamebox — faster-whisper
CUDA mit float16. Der Container verbindet sich per WebSocket an den RVS,
nimmt stt_request entgegen, laeuft ffmpeg+Whisper, antwortet mit
stt_response. Hoert zusaetzlich auf config-Broadcasts und lädt das
Modell hot-swap bei Diagnostic-Wechsel.

aria-bridge ruft jetzt primaer die Gamebox an; nur wenn die nicht binnen
45s antwortet, faellt auf lokales Whisper (CPU) zurueck. Das lokale
Modell wird lazy geladen, spart RAM auf der VM.

RVS: stt_request/stt_response zur ALLOWED_TYPES-Liste.

Diagnostic-Voice-Config (whisperModel-Feld) bleibt unveraendert —
die Auswahl wird an die Gamebox durchgereicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:42:07 +02:00
46 changed files with 4326 additions and 966 deletions

227
README.md
View File

@ -34,13 +34,21 @@ ARIA hat zwei Rollen:
└───────────┬───────────────────────────┬─────────────────┘
│ WebSocket Tunnel │ WebSocket Tunnel
▼ ▼
┌───────────────────────────┐
│ Gaming-PC (optional) │
│ RTX 3060, Docker+WSL2 │
│ XTTS v2 (natuerliche │
│ Stimmen, Voice Cloning) │
│ xtts/docker-compose.yml │
└───────────────────────────┘
┌─────────────────────────────────┐
│ Gamebox (Windows + WSL2) │
│ RTX 3060, Docker Desktop │
│ ┌──────────────────────────┐ │
│ │ aria-f5tts-bridge │ │
│ │ F5-TTS Voice Cloning │ │
│ │ PCM-Streaming an die App │ │
│ ├──────────────────────────┤ │
│ │ aria-whisper-bridge │ │
│ │ Faster-Whisper CUDA │ │
│ │ STT in fast-Echtzeit │ │
│ └──────────────────────────┘ │
│ Beide teilen ./voices Volume │
│ xtts/docker-compose.yml │
└─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ARIA-VM (Proxmox, Debian 13) — ARIAs Wohnung │
│ Basissystem + Docker. Rest richtet ARIA selbst ein. │
@ -57,8 +65,10 @@ ARIA hat zwei Rollen:
│ │ Liest BOOTSTRAP.md + AGENT.md │ │
│ │ │ │
│ │ [bridge] ARIA Voice Bridge Container │ │
│ │ Whisper STT · Wake-Word │ │
│ │ TTS remote via XTTS v2 auf Gaming-PC │ │
│ │ Wake-Word (lokales Mikro auf VM) │ │
│ │ STT primaer remote (Gamebox-Whisper) │ │
│ │ Fallback: lokales faster-whisper (CPU) │ │
│ │ TTS via F5-TTS auf Gamebox │ │
│ │ Bruecke: App <> RVS <> Bridge <> ARIA │ │
│ │ │ │
│ │ [diagnostic] Selbstcheck-UI + Einstellungen │ │
@ -79,9 +89,12 @@ ARIA hat zwei Rollen:
|-----|----|-----|
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
| ARIA Core | Debian 13 VM | `docker compose up -d && ./aria-setup.sh` |
| XTTS v2 (optional) | Gaming-PC (GPU) | `cd xtts && docker compose up -d` |
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
> langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text).
---
## Installation — Schritt fuer Schritt
@ -147,11 +160,12 @@ in den Proxy gemountet. Die Credentials ueberleben Container-Restarts.
```bash
cp aria-data/config/aria.env.example aria-data/config/aria.env
# Bei Bedarf anpassen (Whisper-Modell, Sprache, Wake-Word)
# Bei Bedarf anpassen (Whisper-Modell als Fallback, Sprache, Wake-Word)
```
TTS laeuft ausschliesslich ueber XTTS v2 auf dem Gaming-PC — siehe Abschnitt
"XTTS v2 — High-Quality TTS" weiter unten.
STT laeuft primaer auf der Gamebox (faster-whisper auf GPU), TTS ausschliesslich
ueber F5-TTS auf der Gamebox — siehe Abschnitt "Gamebox-Stack — F5-TTS + Whisper"
weiter unten.
### 5. RVS-Token generieren & Container starten
@ -284,25 +298,34 @@ braucht ARIA mehrere API-Roundtrips.
## Voice Bridge
Die Bridge verbindet die Android App mit ARIA und bietet lokale Sprachverarbeitung.
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
auf der Gamebox.
**Nachrichtenfluss:**
```
Text: App → RVS → Bridge → chat.send → aria-core
Audio: App → RVS → Bridge → FFmpeg → Whisper STT → chat.send → aria-core
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox)
→ stt_response → Bridge → chat.send → aria-core
Fallback bei Timeout: lokales faster-whisper (CPU)
Datei: App → RVS → Bridge → /shared/uploads/ → chat.send (mit Pfad) → aria-core
aria-core → Antwort → Gateway → Diagnostic → RVS → App
→ Bridge → XTTS (PCM-Stream) → RVS → App AudioTrack
→ Bridge → xtts_request (RVS) → f5tts-bridge
→ audio_pcm Stream → RVS → App AudioTrack
```
### Features
- **STT**: faster-whisper (lokal, offline, 16kHz mono)
- **TTS**: XTTS v2 (remote auf Gaming-PC, GPU, Voice Cloning) — Streaming ueber PCM-Chunks
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt, Markdown/Code/Einheiten/URLs werden TTS-gerecht aufbereitet
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM)
- **App-Audio**: Base64 Audio von App → FFmpeg → Whisper STT → Text an aria-core
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast.
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit
Referenz-Audio + automatisch transkribiertem Referenz-Text.
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
ausgeschrieben (`0,1` → "null komma eins"). Acronyme bis 5 Buchstaben werden
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
### Betriebsmodi
@ -324,14 +347,16 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit aria-core.
### Features
- **Status-Karten**: Gateway (Handshake), RVS (TLS-Fallback), Proxy (Auth)
- **Disk-Voll Banner**: Rotes Overlay wenn die VM-Disk knapp wird, mit copy-baren Cleanup-Befehlen (safe + aggressiv)
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS), Vollbild-Modus
- **"ARIA denkt..." Indikator**: Zeigt live was ARIA gerade tut (Denken, Tool, Schreiben)
- **Abbrechen-Button**: Stoppt laufende Anfragen + doctor --fix
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen, als Markdown exportieren (⬇ Button)
- **Chat-History**: Wird beim Laden und Session-Wechsel angezeigt (read-only aus JSONL)
- **TTS-Diagnose Tab**: Stimmen testen, Status pruefen, Fehler anzeigen
- **Einstellungen**: TTS aktiv-Toggle, XTTS-Voice (gecloned), Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload)
- **XTTS Voice Cloning**: Audio-Samples hochladen, eigene Stimme erstellen
- **Einstellungen**: TTS aktiv-Toggle, F5-TTS-Voice (gecloned), Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload auf der Gamebox)
- **Voice-Status**: Beim Wechsel der globalen Stimme zeigt ein Status-Text "Lade…" → "bereit (X.Ys)" — getriggert ueber `voice_preload`/`voice_ready`
- **Voice Cloning**: Audio-Samples hochladen, Referenz-Text wird automatisch via Whisper transkribiert
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
- **Core Terminal**: Shell in aria-core (openclaw CLI)
- **Container-Logs**: Echtzeit-Logs aller Container (gefiltert nach Tab + Pipeline)
@ -354,22 +379,65 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- Text-Chat mit ARIA
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her, ohne Buttons druecken
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt (kein Rauschen an Whisper)
- **STT (Speech-to-Text)**: Audio wird als 16kHz mono aufgenommen und in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
- **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s.
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
- **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher — XTTS v2 PCM-Streaming direkt in AudioTrack, keine Wait-Gaps
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.06.0s, Default 3.5s) gegen Gaps bei Render-Pausen
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
- **Einstellungen**: TTS aktiv, XTTS-Voice, Speicherort, Auto-Download, GPS
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
- GPS-Position (optional)
- QR-Code Scanner fuer Token-Pairing
### Wake-Word (openWakeWord, on-device)
Wake-Word-Erkennung laeuft komplett **on-device** ueber [openWakeWord](https://github.com/dscripka/openWakeWord)
mit ONNX Runtime — kein API-Key, kein Cloud-Roundtrip, kein Cent Lizenzgebuehren,
und das Audio verlaesst das Geraet nie.
**Mitgelieferte Wake-Words** (ONNX-Dateien in `android/android/app/src/main/assets/openwakeword/`):
- `Hey Jarvis` (Default, openWakeWord-Original)
- `Computer` (Star-Trek-Style, Community-Modell)
- `Alexa`, `Hey Mycroft`, `Hey Rhasspy` (openWakeWord-Originale)
Community-Modelle stammen aus [fwartner/home-assistant-wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection).
**Bedienung:**
- App → **Einstellungen****Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren**
- **Ohr-Button (👂)** in der Statusleiste tippen → Wake-Word ist scharf, App hoert passiv mit
- Wake-Word sagen → Symbol wechselt auf 🎙️, Konversation laeuft
- Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂
- Erneut tippen → Ohr aus (🔇)
**Eigene Wake-Words trainieren** (gratis, ~30 Min):
1. openWakeWord Trainings-Notebook auf Colab oeffnen (Link im
[openWakeWord Repo](https://github.com/dscripka/openWakeWord) unter "Training Custom Models")
2. Wake-Word-Phrase eingeben (z.B. "ARIA", "Hey Stefan"), Notebook ausfuehren —
das Notebook generiert synthetische Trainings-Beispiele und trainiert das Modell.
3. Resultierende `.onnx`-Datei runterladen
4. Datei in `android/android/app/src/main/assets/openwakeword/` ablegen
5. In `android/src/services/wakeword.ts` den Dateinamen (ohne `.onnx`) zur
`WAKE_KEYWORDS`-Liste hinzufuegen
6. APK neu bauen
*(Diagnostic-Upload fuer Custom-`.onnx` ohne Rebuild kommt spaeter.)*
**Tuning** (in [wakeword.ts](android/src/services/wakeword.ts)):
- `DEFAULT_THRESHOLD = 0.5` — Score-Schwelle (raise auf 0.60.7 bei False-Positives)
- `DEFAULT_PATIENCE = 2` — wie viele Frames ueber Threshold noetig
- `DEFAULT_DEBOUNCE_MS = 1500` — Mindestabstand zwischen zwei Triggern
### Ersteinrichtung (Dev-Maschine, einmalig)
```bash
@ -540,7 +608,7 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
---
## XTTS v2 — GPU TTS Server (optional)
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060).
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
@ -549,22 +617,27 @@ ueber verschiedene Netze hinweg.
### Architektur
```
Gaming-PC (Windows, RTX 3060, Docker Desktop + WSL2)
├── aria-xtts XTTS v2 GPU Server (Port 8020 intern)
└── aria-xtts-bridge RVS-Relay (empfaengt Requests, sendet Audio)
└── Beide teilen ./voices/ Volume fuer Voice Cloning
Gamebox (Windows, RTX 3060, Docker Desktop + WSL2)
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay
│ Hoert auf xtts_request, streamt audio_pcm
├── aria-whisper-bridge faster-whisper auf CUDA (float16)
│ Hoert auf stt_request, antwortet mit stt_response
└── ./voices/ Geteilt zwischen beiden:
{name}.wav — Referenz-Audio (~6-10s)
{name}.txt — Referenz-Text (auto via Whisper)
↕ RVS (Rechenzentrum, WebSocket Relay)
ARIA-VM
└── aria-bridge: tts_engine="xtts" → xtts_request via RVS → wartet auf xtts_response
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
TTS via xtts_request → audio_pcm Stream
```
### Voraussetzungen
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
- NVIDIA Container Toolkit
- GPU mit mindestens 4GB VRAM (6GB+ empfohlen)
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
### Setup
@ -574,38 +647,76 @@ cd xtts
cp .env.example .env
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
docker compose up -d
# Erster Start laedt ~2GB Model herunter (danach gecacht)
# Erster Start laedt die Modelle (Whisper ~1-3GB je nach Groesse, F5-TTS ~1GB)
```
**Wichtig:** Der XTTS-Server laeuft intern auf Port **8020** (nicht 8000).
Das Model wird im Volume `xtts-models` gecacht und muss nur einmal geladen werden.
Die Modelle werden in den Volumes `f5tts-models` und `whisper-models` gecacht
und muessen nur einmal geladen werden.
### Features
- **Natuerliche Stimmen**: Deutlich bessere Qualitaet als TTS der alten Generation
- **Voice Cloning**: Eigene Stimme mit 6-10s Audio-Sample (~2s Latenz auf RTX 3060)
- **Streaming**: PCM-Chunks alle ~170ms → App spielt ohne Warten nahtlos
- **16 Sprachen**: Deutsch, Englisch, Franzoesisch, etc.
**F5-TTS (Sprachausgabe):**
- Hochqualitatives Voice Cloning auf Basis von 6-10s Referenz-Audio
- Renderzeit ~0.3x Realtime auf RTX 3060 (RTF ≈ 0.3)
- Satzweises Streaming, fade-in auf erstem Chunk gegen Warmup-Glitches
- Sequentielle Queue gegen GPU-OOM bei parallelen Requests
**Whisper (Spracherkennung):**
- faster-whisper mit CUDA + float16 — fast Echtzeit-Transkription
- Modelle: tiny / base / small / medium / large-v3 (Hot-Swap via Diagnostic)
- Wird zusaetzlich von der f5tts-bridge intern genutzt um den Referenz-Text
beim Voice-Upload automatisch zu erzeugen
### TTS-Config
In der Diagnostic unter Einstellungen → Sprachausgabe:
- **TTS aktiv**: Global An/Aus
- **XTTS Stimme**: Default oder gecloned (Maia, etc.)
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
> XTTS ist die einzige Engine — wenn der Gaming-PC offline ist, bleibt ARIA stumm.
> F5-TTS ist die einzige Engine — wenn die Gamebox offline ist, bleibt ARIA stumm.
> Chat-Antworten kommen weiter an (nur kein Audio).
### Stimme klonen
1. "Stimme klonen" → Audio-Dateien hochladen (WAV/MP3, 1-10 Dateien, min. 6-10s gesamt)
1. App oder Diagnostic → "Stimme klonen" → Audio-Dateien hochladen
(WAV/MP3, 1-10 Dateien, ~6-10s gesamt)
2. Name vergeben → "Stimme erstellen"
3. "Laden" klicken → neue Stimme in der Auswahl
4. Stimme auswaehlen → Config wird automatisch gespeichert
3. f5tts-bridge speichert das WAV, schickt einen `stt_request` an die
whisper-bridge, legt die Transkription als `.txt` daneben ab und meldet
`xtts_voice_saved` zurueck. Der Toast in der App zeigt "Stimme bereit".
4. Stimme auswaehlen → ein Voice-Preload (stiller Mini-Render) waermt die
Latents auf, "voice_ready" Toast bestaetigt es.
> **Tipp:** Fuer beste Ergebnisse: saubere Aufnahme, eine Stimme, kein Hintergrund,
> 10-30 Sekunden Gesamtlaenge. Mehrere kurze Dateien werden zusammengefuegt.
### Deutsches Fine-Tune (bessere Qualitaet auf Deutsch)
Das Default-Modell `F5TTS_v1_Base` ist primaer auf Englisch + Chinesisch trainiert
und liefert auf Deutsch merklich schwaechere Voice-Cloning-Qualitaet als XTTS es
tat. Community-Fine-Tune von [aihpi](https://huggingface.co/aihpi/F5-TTS-German)
auf dem Emilia-Dataset + Common Voice 19.0 funktioniert deutlich besser.
**Konfiguration ueber Diagnostic → "F5-TTS Modell-Tuning (advanced)":**
| Feld | Wert |
|------|------|
| Modell-Architektur | `F5TTS_Base` *(nicht v1_Base! Fine-Tune basiert auf der alten Architektur)* |
| Custom Checkpoint | `hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors` |
| Custom Vocab | `hf://aihpi/F5-TTS-German/vocab.txt` |
| cfg_strength | `2.0` |
| nfe_step | `32` |
→ "Anwenden" klicken. Die `hf://`-Pfade werden einmalig automatisch runter-
geladen (~3-5GB, landet im `xtts/hf-cache/`) und bei Container-Restart aus
dem Cache wiederverwendet.
> **Warnung zur BigVGAN-Variante** (`F5TTS_Base_bigvgan/model_295000.safetensors`):
> funktioniert AKTUELL NICHT mit dieser Bridge. Die f5-tts Library laedt
> per Default den Vocos-Vocoder, die BigVGAN-Weights sind damit inkompatibel
> → Modell produziert NaN, App bleibt stumm. Nur die **Vocos-Variante
> (F5TTS_Base/model_365000.safetensors)** nutzen.
---
## Docker Volumes
@ -673,8 +784,10 @@ docker exec aria-core ssh aria-wohnung hostname
- **Proxy Cold Start**: Jede Nachricht spawnt einen neuen `claude --print` Prozess.
Dadurch ist ARIA langsamer als die direkte Claude CLI. Timeout ist auf 900s (15 Min).
- **Kein Streaming zur App**: Die App zeigt erst die fertige Antwort, keine Streaming-Tokens.
- **Wake Word nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM.
In der App gibt es Energy-basierte Erkennung (Phase 1). On-device "ARIA"-Keyword (Porcupine) ist Phase 2.
- **Wake-Word in der App nur eingebaute Keywords**: `Hey Jarvis`, `Alexa`, `Hey Mycroft`,
`Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als
`.onnx`-Datei ins App-Bundle gelegt + zur Liste in `wakeword.ts` hinzugefuegt werden.
Die Diagnostic-Upload-UI ist Phase 2.
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
@ -720,6 +833,16 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] "ARIA denkt..."-Indicator + Abbrechen-Button in App (via Bridge → RVS)
- [x] Whisper-Modell waehlbar in Diagnostic (tiny…large-v3, Hot-Reload)
- [x] App-Aufnahme explizit 16kHz mono (optimal fuer Whisper, kein Resample)
- [x] Streaming TTS Pre-Roll-Buffer + Wartezeit auf playbackHeadPosition (kein Cutoff mid-Satz mehr)
- [x] Pre-Roll-Buffer einstellbar in App-Settings
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
- [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit)
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
### Phase 2 — ARIA wird produktiv
@ -735,5 +858,5 @@ docker exec aria-core ssh aria-wohnung hostname
- [ ] STARFACE Telefonie-Skill
- [ ] Desktop Client (Tauri)
- [ ] bKVM Remote IT-Support
- [ ] Porcupine Wake Word (on-device "ARIA" in der App)
- [ ] Custom-`.onnx`-Upload fuer Wake-Word ueber Diagnostic (ohne App-Rebuild)
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)

View File

@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 501
versionName "0.0.5.1"
versionCode 701
versionName "0.0.7.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@ -104,6 +104,19 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
// ABI-Split: nur arm64-v8a (jedes Android-Phone seit ~2017). Bringt die
// APK von ~136 MB auf ~35 MB relevant weil ONNX Runtime + die anderen
// Native-Libs sonst pro Architektur dazukommen. Wer 32-bit oder Emulator
// braucht, kann hier "armeabi-v7a", "x86_64" etc. ergaenzen.
splits {
abi {
enable true
reset()
include "arm64-v8a"
universalApk false
}
}
}
dependencies {
@ -111,6 +124,9 @@ dependencies {
implementation("com.facebook.react:react-android")
implementation("com.facebook.react:flipper-integration")
// ONNX Runtime fuer on-device Wake-Word (openWakeWord ONNX-Modelle in assets/openwakeword/)
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.1")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {

View File

@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application
android:name=".MainApplication"

View File

@ -53,11 +53,17 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
}
/** Andere Apps werden leiser (TTS spricht). */
/** Andere Apps werden pausiert (TTS spricht).
*
* TRANSIENT (statt TRANSIENT_MAY_DUCK): Spotify/YouTube pausieren komplett
* statt nur leiser zu werden. Verhindert auch das "kommt-wieder-hoch"-
* Problem mit MAY_DUCK, wo das System nach kurzer Zeit den Duck-Effekt
* wieder aufgehoben hat obwohl wir den Fokus noch hielten.
*/
@ReactMethod
fun requestDuck(promise: Promise) {
requestFocus(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
AudioAttributes.USAGE_ASSISTANT,
promise,
)

View File

@ -21,6 +21,8 @@ class MainApplication : Application(), ReactApplication {
add(ApkInstallerPackage())
add(AudioFocusPackage())
add(PcmStreamPlayerPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
}
override fun getJSMainModuleName(): String = "index"

View File

@ -0,0 +1,369 @@
package com.ariacockpit
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
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.nio.FloatBuffer
import java.util.concurrent.atomic.AtomicBoolean
/**
* Wake-Word Erkennung on-device via openWakeWord (https://github.com/dscripka/openWakeWord).
*
* Drei-stufige ONNX Pipeline:
* 1. Audio (16kHz mono int16, 1280-Sample-Chunks) Melspectrogram 32-mel Frames
* 2. 76 Mel-Frames Sliding Window (stride 8) Speech-Embedding 96-dim Vektor
* 3. Letzte 16 Embeddings (~1.28s Kontext) Wake-Word-Klassifikator Sigmoid-Score
*
* Modelle liegen in assets/openwakeword/ (mel + embedding shared, plus pro Keyword
* ein eigenes .onnx). Erkennung feuert nach `patience` aufeinanderfolgenden
* Frames ueber `threshold` und unterdrueckt Wiederholungen fuer `debounceMs`.
*
* Emittiert "WakeWordDetected" als RN-Event wenn ein Trigger erkannt wurde.
*/
class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "OpenWakeWord"
companion object {
private const val TAG = "OpenWakeWord"
private const val SAMPLE_RATE = 16000
private const val CHUNK_SAMPLES = 1280 // 80ms @ 16kHz
private const val MEL_FRAMES_PER_EMBEDDING = 76 // Embedding-Fenster
private const val EMBEDDING_STRIDE = 8 // Slide um 8 Mel-Frames
private const val EMBEDDING_DIM = 96
private const val MEL_BINS = 32
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
}
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
private var melSession: OrtSession? = null
private var embSession: OrtSession? = null
private var wwSession: OrtSession? = null
private var melInputName: String = "input"
private var embInputName: String = "input_1"
private var wwInputName: String = "input"
// Anzahl Embedding-Frames die der Wake-Word-Klassifikator pro Inferenz erwartet —
// hey_jarvis hat 16, andere Community-Modelle koennen abweichen (z.B. 28).
// Wird beim init() aus den Modell-Metadaten gelesen.
private var wwInputFrames: Int = DEFAULT_WW_INPUT_FRAMES
// Konfiguration
private var threshold: Float = 0.5f
private var patience: Int = 2
private var debounceMs: Long = 1500
private var modelName: String = "hey_jarvis"
// Audio-Capture-Thread
private var audioRecord: AudioRecord? = null
private val running = AtomicBoolean(false)
private var captureThread: Thread? = null
// Inferenz-State
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
private var melProcessedIdx: Int = 0
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L
/**
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
*/
@ReactMethod
fun init(modelName: String, threshold: Double, patience: Int, debounceMs: Int, promise: Promise) {
try {
disposeSessions()
this.modelName = modelName
this.threshold = threshold.toFloat()
this.patience = patience.coerceAtLeast(1)
this.debounceMs = debounceMs.toLong()
val ctx = reactApplicationContext
val melBytes = ctx.assets.open("openwakeword/melspectrogram.onnx").use { it.readBytes() }
val embBytes = ctx.assets.open("openwakeword/embedding_model.onnx").use { it.readBytes() }
val wwBytes = ctx.assets.open("openwakeword/$modelName.onnx").use { it.readBytes() }
val opts = OrtSession.SessionOptions()
melSession = env.createSession(melBytes, opts)
embSession = env.createSession(embBytes, opts)
wwSession = env.createSession(wwBytes, opts)
melInputName = melSession!!.inputNames.first()
embInputName = embSession!!.inputNames.first()
wwInputName = wwSession!!.inputNames.first()
// WW-Input-Frame-Count aus dem Modell lesen — variiert pro Keyword.
// Erwartete Form: (1, N, 96), N steht in der Modell-Metadaten.
val wwInputInfo = wwSession!!.inputInfo[wwInputName]
val wwShape = (wwInputInfo?.info as? ai.onnxruntime.TensorInfo)?.shape
wwInputFrames = wwShape?.getOrNull(1)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_WW_INPUT_FRAMES
Log.i(TAG, "Init OK: model=$modelName wwFrames=$wwInputFrames threshold=$threshold patience=$patience " +
"debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "Init fehlgeschlagen: ${e.message}", e)
disposeSessions()
promise.reject("INIT_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun start(promise: Promise) {
if (running.get()) {
promise.resolve(true)
return
}
if (melSession == null || embSession == null || wwSession == null) {
promise.reject("NOT_INITIALIZED", "init() muss vor start() aufgerufen werden")
return
}
// Berechtigung pruefen — der App-Code holt die ueblicherweise schon vorher,
// aber wir bestehen hier explizit darauf damit AudioRecord nicht stumm
// failt.
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_SAMPLES * 2 * 4)
val record = AudioRecord(
MediaRecorder.AudioSource.MIC,
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?)")
return
}
audioRecord = record
resetInferenceState()
running.set(true)
record.startRecording()
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
}
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
running.set(false)
audioRecord?.release()
audioRecord = null
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
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
}
@ReactMethod
fun dispose(promise: Promise) {
running.set(false)
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
disposeSessions()
promise.resolve(true)
}
@ReactMethod
fun isAvailable(promise: Promise) {
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
promise.resolve(true)
}
// RN-Event-Subscriptions — RN-Konvention, sonst Warnung im Debug-Build
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
private fun disposeSessions() {
try { melSession?.close() } catch (_: Exception) {}
try { embSession?.close() } catch (_: Exception) {}
try { wwSession?.close() } catch (_: Exception) {}
melSession = null
embSession = null
wwSession = null
}
private fun resetInferenceState() {
melBuffer.clear()
melProcessedIdx = 0
embBuffer.clear()
consecutiveAboveThreshold = 0
lastDetectionMs = 0L
}
private fun emitDetected() {
val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName)
}
try {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("WakeWordDetected", params)
} catch (e: Exception) {
Log.w(TAG, "emit fehlgeschlagen: ${e.message}")
}
}
private fun captureLoop() {
val buf = ShortArray(CHUNK_SAMPLES)
val record = audioRecord ?: return
Log.i(TAG, "Capture-Loop gestartet")
while (running.get()) {
var read = 0
while (read < CHUNK_SAMPLES && running.get()) {
val n = record.read(buf, read, CHUNK_SAMPLES - read)
if (n <= 0) {
Log.w(TAG, "AudioRecord.read returned $n — Loop ende")
running.set(false)
return
}
read += n
}
if (!running.get()) break
try {
processChunk(buf)
} catch (e: Exception) {
Log.w(TAG, "processChunk: ${e.message}")
}
}
Log.i(TAG, "Capture-Loop beendet")
}
/** Verarbeitet einen 1280-Sample int16 Audio-Chunk. */
private fun processChunk(audio: ShortArray) {
// 1) Audio → mel (output (1, 1, frames, 32))
val floats = FloatArray(audio.size) { audio[it].toFloat() }
val melTensor = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(floats),
longArrayOf(1L, audio.size.toLong()),
)
val melResult = melSession!!.run(mapOf(melInputName to melTensor))
val melOut = melResult.get(0).value
melTensor.close()
@Suppress("UNCHECKED_CAST")
val mel4 = melOut as Array<Array<Array<FloatArray>>>
val frames = mel4[0][0]
// openWakeWord wendet `mel/10 + 2` an, bevor es ans Embedding-Modell geht
for (frame in frames) {
val scaled = FloatArray(frame.size) { frame[it] / 10f + 2f }
melBuffer.add(scaled)
}
melResult.close()
// 2) Sliding window: alle vollstaendigen 76-Frame-Fenster verarbeiten
while (melBuffer.size >= melProcessedIdx + MEL_FRAMES_PER_EMBEDDING) {
val flat = FloatArray(MEL_FRAMES_PER_EMBEDDING * MEL_BINS)
var pos = 0
for (i in 0 until MEL_FRAMES_PER_EMBEDDING) {
val src = melBuffer[melProcessedIdx + i]
System.arraycopy(src, 0, flat, pos, MEL_BINS)
pos += MEL_BINS
}
val embIn = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(flat),
longArrayOf(1L, MEL_FRAMES_PER_EMBEDDING.toLong(), MEL_BINS.toLong(), 1L),
)
val embRes = embSession!!.run(mapOf(embInputName to embIn))
val embOut = embRes.get(0).value
embIn.close()
// Erwartete Output-Form: (1, 1, 1, 96) — rank-4, NICHT (1, 96).
// Die Google-Embedding-Pipeline behaelt extra Dimensionen.
@Suppress("UNCHECKED_CAST")
val embArr = embOut as Array<Array<Array<FloatArray>>>
embBuffer.addLast(embArr[0][0][0].copyOf())
while (embBuffer.size > wwInputFrames) embBuffer.removeFirst()
embRes.close()
melProcessedIdx += EMBEDDING_STRIDE
}
// Mel-Buffer trimmen — verhindert Memory-Wachstum
if (melProcessedIdx > MEL_FRAMES_PER_EMBEDDING) {
val keepFrom = melProcessedIdx - MEL_FRAMES_PER_EMBEDDING
val newList = ArrayList<FloatArray>(melBuffer.size - keepFrom)
for (i in keepFrom until melBuffer.size) newList.add(melBuffer[i])
melBuffer.clear()
melBuffer.addAll(newList)
melProcessedIdx = MEL_FRAMES_PER_EMBEDDING
}
// 3) Klassifikation — sobald wir 16 Embeddings haben
if (embBuffer.size < wwInputFrames) return
val flatEmb = FloatArray(wwInputFrames * EMBEDDING_DIM)
var p = 0
// Letzte wwInputFrames Embeddings nehmen (embBuffer ist auf wwInputFrames begrenzt)
for (e in embBuffer) {
System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM)
p += EMBEDDING_DIM
}
val wwIn = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(flatEmb),
longArrayOf(1L, wwInputFrames.toLong(), EMBEDDING_DIM.toLong()),
)
val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn))
val wwOut = wwRes.get(0).value
wwIn.close()
// Erwartete Output-Form: (1, 1) → Array<FloatArray>
@Suppress("UNCHECKED_CAST")
val score = (wwOut as Array<FloatArray>)[0][0]
wwRes.close()
if (score >= threshold) {
consecutiveAboveThreshold++
if (consecutiveAboveThreshold >= patience) {
val now = System.currentTimeMillis()
if (now - lastDetectionMs >= debounceMs) {
lastDetectionMs = now
consecutiveAboveThreshold = 0
Log.i(TAG, "Wake-Word erkannt! score=$score model=$modelName")
emitDetected()
}
}
} else {
consecutiveAboveThreshold = 0
}
}
}

View File

@ -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 OpenWakeWordPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(OpenWakeWordModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@ -32,11 +32,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
private const val TAG = "PcmStreamPlayer"
// Fallback wenn JS keinen Wert uebergibt.
private const val DEFAULT_PREROLL_SECONDS = 3.5
private const val MIN_PREROLL_SECONDS = 0.5
// 0.0 = sofortige Wiedergabe — play() direkt beim ersten Chunk.
// Macht Sinn fuer F5-TTS weil Render so schnell ist dass ein Puffer
// unnoetig ist und bei kurzen Saetzen sogar stoeren kann.
private const val MIN_PREROLL_SECONDS = 0.0
private const val MAX_PREROLL_SECONDS = 10.0
// Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die
// ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz).
private const val LEADING_SILENCE_SECONDS = 0.2
private const val LEADING_SILENCE_SECONDS = 0.3
// Stille am Ende — puffert das Hardware-Flushen damit die letzten
// echten Samples garantiert ausgespielt werden bevor stop() kommt.
private const val TRAILING_SILENCE_SECONDS = 0.3
}
override fun getName() = "PcmStreamPlayer"
@ -59,9 +65,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
// Alte Session beenden falls vorhanden
stopInternal()
val prerollSec = prerollSeconds
.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
.let { if (it.isFinite() && it > 0) it else DEFAULT_PREROLL_SECONDS }
// Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe).
val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) {
prerollSeconds.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
} else {
DEFAULT_PREROLL_SECONDS
}
val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT
@ -103,9 +112,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val t = track ?: return@Thread
try {
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
val silenceBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (silenceBytes > 0) {
val silence = ByteArray(silenceBytes)
val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (leadingBytes > 0) {
val silence = ByteArray(leadingBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
@ -114,18 +123,74 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
bytesBuffered += silence.size
}
while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run {
// Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen,
// nicht erst bei Ankunft des ersten echten Chunks. Android's
// AudioTrack haelt den Play-State und wartet auf neue Samples.
// So verschluckt es keine Worte wenn der erste Chunk erst
// nach play()-Startup-Latenz eintrifft.
if (prerollBytes == 0 && !playbackStarted) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback sofort gestartet (preroll=0, ${bytesBuffered}B silence)")
} catch (e: Exception) {
Log.w(TAG, "play() sofort failed: ${e.message}")
}
}
// Idle-Cutoff: wenn endRequested NICHT kam aber 30s nichts mehr
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
var idleMs = 0L
val maxIdleMs = 30_000L
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
// Stille rein damit AudioTrack nicht underrunt waehrend die
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
// sonst mit eigenmaechtiger Wiederaufnahme nach ~10s Stille.
val underrunGuardFrames = sampleRate / 10 // ~100ms
val silenceFillFrames = sampleRate / 20 // ~50ms pro Refill
mainLoop@ while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) {
if (endRequested) {
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
if (!playbackStarted) {
try { t.play() } catch (_: Exception) {}
playbackStarted = true
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
} catch (e: Exception) {
Log.w(TAG, "play() fallback failed: ${e.message}")
}
}
return@Thread
break@mainLoop
}
null
} ?: continue
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
// Puffer leerzulaufen droht. Spotify resumed sonst nach
// ~10s Pause auf eigene Faust, obwohl wir den Fokus halten.
if (playbackStarted) {
val framesWritten = bytesBuffered / streamBytesPerFrame
val framesPlayed = t.playbackHeadPosition.toLong()
val framesInBuffer = framesWritten - framesPlayed
if (framesInBuffer < underrunGuardFrames) {
val fillBytes = silenceFillFrames * streamBytesPerFrame
val silence = ByteArray(fillBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
if (w <= 0) break
silOff += w
}
bytesBuffered += silence.size
}
}
idleMs += 50L
if (idleMs >= maxIdleMs) {
Log.w(TAG, "Idle-Cutoff: ${maxIdleMs}ms keine Daten — Stream wird beendet")
break@mainLoop
}
continue@mainLoop
}
idleMs = 0L
// Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
@ -146,6 +211,19 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
bytesBuffered += data.size
}
// Trailing-Silence damit die letzten echten Samples garantiert
// durch das Hardware-Buffering kommen bevor stop() sie abschneidet
val trailingBytes = ((sampleRate * channels * 2) * TRAILING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (trailingBytes > 0 && !writerShouldStop) {
val silence = ByteArray(trailingBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
if (w <= 0) break
silOff += w
}
bytesBuffered += silence.size
}
} catch (e: Exception) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally {
@ -201,11 +279,27 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
}
/** Signalisiert: keine weiteren Chunks. Writer wartet auf Queue-Abschluss, dann stoppt. */
/** Signalisiert: keine weiteren Chunks. Writer spielt aus, dann stoppt.
* Das Promise resolved erst wenn der Writer-Thread fertig ist
* wichtig damit der Aufrufer den AudioFocus erst NACH dem letzten
* abgespielten Sample wieder freigibt (sonst dreht Spotify hoch
* waehrend das Pre-Roll noch ausspielt).
*/
@ReactMethod
fun end(promise: Promise) {
endRequested = true
promise.resolve(true)
val t = writerThread
if (t == null || !t.isAlive) {
promise.resolve(true)
return
}
// Im Hintergrund auf den Writer warten — kein Threading-Block fuer JS-Bridge
Thread({
try {
t.join(15_000) // hartes Cap, falls Writer haengt
} catch (_: InterruptedException) {}
promise.resolve(true)
}, "PcmStreamEndWaiter").start()
}
/** Harter Stop (Cancel) — Queue verwerfen. */

View File

@ -0,0 +1,126 @@
package com.ariacockpit
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
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
/**
* Lauscht auf Anruf-Statusaenderungen wenn das Telefon klingelt oder ein
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
*
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise der Rest
* der App funktioniert wie bisher.
*
* State-Strings: "idle" | "ringing" | "offhook"
*/
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PhoneCall"
companion object { private const val TAG = "PhoneCall" }
private var telephonyManager: TelephonyManager? = null
private var legacyListener: PhoneStateListener? = null
private var modernCallback: Any? = null // TelephonyCallback ab API 31
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
@ReactMethod
fun start(promise: Promise) {
try {
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
if (perm != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
promise.resolve(false)
return
}
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) {
Log.w(TAG, "TelephonyManager nicht verfuegbar")
promise.resolve(false)
return
}
telephonyManager = tm
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
handleStateChange(state)
}
}
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
modernCallback = cb
} else {
@Suppress("DEPRECATION")
val l = object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
handleStateChange(state)
}
}
@Suppress("DEPRECATION")
tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
legacyListener = l
}
Log.i(TAG, "PhoneCall-Listener aktiv")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val tm = telephonyManager
if (tm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
modernCallback = null
} else {
@Suppress("DEPRECATION")
legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
legacyListener = null
}
}
telephonyManager = null
lastState = TelephonyManager.CALL_STATE_IDLE
promise.resolve(true)
} catch (e: Exception) {
promise.reject("STOP_FAILED", e.message ?: "")
}
}
private fun handleStateChange(state: Int) {
if (state == lastState) return
lastState = state
val name = when (state) {
TelephonyManager.CALL_STATE_RINGING -> "ringing"
TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
TelephonyManager.CALL_STATE_IDLE -> "idle"
else -> return
}
Log.i(TAG, "Telefon-State: $name")
val params = Arguments.createMap().apply { putString("state", name) }
try {
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PhoneCallStateChanged", params)
} catch (e: Exception) {
Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}

View File

@ -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 PhoneCallPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PhoneCallModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@ -1,7 +1,9 @@
buildscript {
ext {
buildToolsVersion = "34.0.0"
minSdkVersion = 23
// 24 = Android 7.0 (Nougat). Verlangt von Porcupine (Picovoice).
// Realistisch eh das Minimum: alles unter 7.0 hat <1% Marktanteil.
minSdkVersion = 24
compileSdkVersion = 34
targetSdkVersion = 34
ndkVersion = "25.1.8937393"

View File

@ -167,10 +167,23 @@ export CI=true
if [ "$MODE" = "debug" ]; then
./gradlew assembleDebug
APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
OUT_DIR="app/build/outputs/apk/debug"
else
./gradlew assembleRelease
APK_PATH="app/build/outputs/apk/release/app-release.apk"
OUT_DIR="app/build/outputs/apk/release"
fi
# Mit ABI-Splits heisst die APK z.B. app-arm64-v8a-release.apk statt
# app-release.apk. arm64-v8a-Variante zuerst probieren (das ist unser
# Standard), Universal-APK als Fallback falls Splits deaktiviert sind.
if [ -f "$OUT_DIR/app-arm64-v8a-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-arm64-v8a-${MODE}.apk"
elif [ -f "$OUT_DIR/app-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-${MODE}.apk"
else
echo -e "${RED}Keine passende APK in $OUT_DIR gefunden${NC}"
cd ..
exit 1
fi
cd ..

View File

@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.5.1",
"version": "0.0.7.1",
"private": true,
"scripts": {
"android": "react-native run-android",

View File

@ -0,0 +1,105 @@
/**
* MessageText rendert Chat-Text mit Auto-Linkifizierung:
* - http(s)://... → tippbar, oeffnet im Browser
* - mailto: oder plain E-Mail tippbar, oeffnet Mail-App
* - Telefonnummern tippbar, oeffnet Android-Dialer
*
* Text ist durchgaengig markierbar/kopierbar (selectable).
*/
import React from 'react';
import { Text, Linking, TextStyle, StyleProp } from 'react-native';
// Regex kombiniert URL | Email | Telefonnummer.
// Gruppenreihenfolge ist wichtig fuer die Erkennung unten.
//
// URL: http://... oder https://... bis zum ersten Whitespace / Anfuehrungszeichen.
// Email: simpler Standard-Match (kein RFC-kompatibel aber gut genug).
// Telefon: internationale Form (+49..., 0049..., 0176...), darf Leerzeichen
// / Bindestriche / Schraegstriche / Klammern enthalten, mindestens 7
// Ziffern insgesamt. Vermeidet banale Zahlen (Uhrzeiten, Datum).
const LINK_REGEX = new RegExp(
'(https?:\\/\\/[^\\s<>"]+)' + // 1: URL
'|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})' + // 2: Email
'|((?:\\+|00)\\d[\\d\\s()\\-\\/]{6,}\\d|0\\d{2,4}[\\s\\/\\-]?[\\d\\s\\-\\/]{5,}\\d)', // 3: Telefon
'g',
);
const LINK_STYLE = { color: '#0096FF', textDecorationLine: 'underline' } as TextStyle;
interface Segment {
text: string;
kind: 'text' | 'url' | 'email' | 'phone';
}
function tokenize(raw: string): Segment[] {
const out: Segment[] = [];
let lastEnd = 0;
LINK_REGEX.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = LINK_REGEX.exec(raw)) !== null) {
if (m.index > lastEnd) {
out.push({ text: raw.slice(lastEnd, m.index), kind: 'text' });
}
if (m[1]) out.push({ text: m[1], kind: 'url' });
else if (m[2]) out.push({ text: m[2], kind: 'email' });
else if (m[3]) out.push({ text: m[3], kind: 'phone' });
lastEnd = LINK_REGEX.lastIndex;
}
if (lastEnd < raw.length) out.push({ text: raw.slice(lastEnd), kind: 'text' });
return out;
}
function onPress(seg: Segment) {
try {
if (seg.kind === 'url') {
Linking.openURL(seg.text);
} else if (seg.kind === 'email') {
Linking.openURL(`mailto:${seg.text}`);
} else if (seg.kind === 'phone') {
// Android-Dialer erwartet tel:-Schema ohne Leerzeichen/Bindestriche
const clean = seg.text.replace(/[\s\-\/()]/g, '');
Linking.openURL(`tel:${clean}`);
}
} catch {}
}
interface Props {
text: string;
style?: StyleProp<TextStyle>;
}
const MessageText: React.FC<Props> = ({ text, style }) => {
const segments = React.useMemo(() => tokenize(text), [text]);
return (
<Text
style={style}
selectable
// dataDetectorType ist Android-only und macht Phone/URL/Email zusaetzlich
// ueber System-Detection klickbar — als Fallback falls unsere Regex-
// Tokens nicht passen.
dataDetectorType="all"
>
{segments.map((seg, i) => {
if (seg.kind === 'text') {
return <Text key={i} selectable>{seg.text}</Text>;
}
return (
<Text
key={i}
selectable
style={LINK_STYLE}
onPress={() => onPress(seg)}
// Long-Press soll an den Parent durch fuer Selection
onLongPress={undefined}
suppressHighlighting={false}
>
{seg.text}
</Text>
);
})}
</Text>
);
};
export default MessageText;

View File

@ -93,18 +93,24 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
}, [isRecording]);
// VAD Silence Callback — Auto-Stop
// 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 (!isRecording) return;
setIsRecording(false);
if (audioService.getRecordingState() !== 'recording') return;
const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 500) {
onRecordingComplete(result);
onCompleteRef.current(result);
}
});
return unsubSilence;
}, [isRecording, onRecordingComplete]);
}, []);
// Auto-Start fuer Wake Word (extern getriggert)
const startAutoRecording = useCallback(async () => {
@ -136,23 +142,35 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
};
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop.
const tapBusy = useRef(false);
const handleTap = async () => {
if (disabled) return;
if (isRecording) {
// Aufnahme manuell stoppen
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
} else {
// Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
isLongPress.current = false;
setIsRecording(true);
if (disabled || tapBusy.current) return;
tapBusy.current = true;
try {
// Fragen WIR den Service, nicht den React-State (Closure kann 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);
}
} else if (svcState === 'idle') {
// Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
isLongPress.current = false;
setIsRecording(true);
}
}
// 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.
} finally {
tapBusy.current = false;
}
};

View File

@ -25,11 +25,13 @@ import RNFS from 'react-native-fs';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
import phoneCallService from '../services/phoneCall';
import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import { RecordingResult } from '../services/audio';
import MessageText from '../components/MessageText';
import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@ -103,16 +105,28 @@ const ChatScreen: React.FC = () => {
const [showCameraUpload, setShowCameraUpload] = useState(false);
const [gpsEnabled, setGpsEnabled] = useState(false);
const [wakeWordActive, setWakeWordActive] = useState(false);
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
const [ttsMuted, setTtsMuted] = useState(false);
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
const localXttsVoiceRef = useRef<string>('');
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
const ttsSpeedRef = useRef<number>(TTS_SPEED_DEFAULT);
// Spiegelung der TTS-Settings in einer Ref — damit die onMessage-Closure
// (useEffect mit []-deps) IMMER die aktuellen Werte sieht. Ohne Ref
// bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
// oder AsyncStorage-Load nicht beruecksichtigt).
const ttsCanPlayRef = useRef<boolean>(true);
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
@ -132,6 +146,7 @@ const ChatScreen: React.FC = () => {
setTtsMuted(muted === 'true'); // default false
const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed();
};
loadTtsSettings();
// Poll alle 2s um Settings-Aenderung mitzubekommen (einfache Loesung ohne Context)
@ -139,6 +154,35 @@ const ChatScreen: React.FC = () => {
return () => clearInterval(interval);
}, []);
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {});
const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s);
setWakeWordActive(s !== 'off');
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
// 'armed' oder 'off' fallen, darf Spotify wieder.
if (s === 'conversing') audioService.acquireConversationFocus();
else audioService.releaseConversationFocus();
});
return () => unsub();
}, []);
// Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt
useEffect(() => {
phoneCallService.start().catch(err =>
console.warn('[Chat] phoneCall.start fehlgeschlagen', err));
return () => { phoneCallService.stop().catch(() => {}); };
}, []);
// ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
// darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
useEffect(() => {
ttsCanPlayRef.current = ttsDeviceEnabled && !ttsMuted;
}, [ttsDeviceEnabled, ttsMuted]);
const toggleMute = useCallback(() => {
setTtsMuted(prev => {
const next = !prev;
@ -240,15 +284,35 @@ const ChatScreen: React.FC = () => {
if (message.type === 'chat') {
const sender = (message.payload.sender as string) || '';
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben.
// WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst
// wuerde bei zwei kurz hintereinander gesendeten Audios beide Bubbles
// den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste).
if (sender === 'stt') {
const sttText = (message.payload.text as string) || '';
if (sttText) {
setMessages(prev => prev.map(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
? { ...m, text: `\uD83C\uDFA4 ${sttText}` }
: m
));
setMessages(prev => {
const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
const newText = `\uD83C\uDFA4 ${sttText}`;
if (idx < 0) {
// Defensiv: wenn keine Placeholder im State (z.B. weil sie nie
// hinzugefuegt wurde oder schon durch ein anderes Update verloren
// ging), die Sprachnachricht trotzdem als neue Bubble einfuegen.
// Sonst kommt ARIAs Antwort ohne sichtbare User-Nachricht.
return capMessages([...prev, {
id: nextId(),
sender: 'user',
text: newText,
timestamp: message.timestamp,
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}]);
}
const next = prev.slice();
next[idx] = { ...next[idx], text: newText };
return next;
});
}
return;
}
@ -291,7 +355,12 @@ const ChatScreen: React.FC = () => {
}
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
const canPlay = ttsDeviceEnabled && !ttsMuted;
// WICHTIG: via Ref statt direkt state lesen, sonst ist's stale (Closure-Bug).
const canPlay = ttsCanPlayRef.current;
if (message.type === 'audio_pcm' || (message.type === 'audio' && message.payload.base64)) {
console.log('[Chat] audio-msg canPlay=%s (enabled=%s muted=%s)',
canPlay, ttsDeviceEnabled, ttsMuted);
}
if (message.type === 'audio' && message.payload.base64) {
const b64 = message.payload.base64 as string;
const refId = (message.payload.messageId as string) || '';
@ -346,6 +415,24 @@ const ChatScreen: React.FC = () => {
ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit`, ToastAndroid.SHORT);
}
}
// Gamebox-Bridges (f5tts/whisper) melden Lade-Status — Banner oben
if (message.type === ('service_status' as any)) {
const p = message.payload as any;
const svc = (p?.service as string) || '';
if (!svc) return;
setServiceStatus(prev => ({
...prev,
[svc]: {
state: (p?.state as string) || 'unknown',
model: p?.model as string | undefined,
loadSeconds: p?.loadSeconds as number | undefined,
error: p?.error as string | undefined,
},
}));
// Bei neuer Loading-Phase Banner wieder aktivieren
if (p?.state === 'loading') setServiceBannerDismissed(false);
}
});
const unsubState = rvs.onStateChange((state) => {
@ -385,10 +472,11 @@ const ChatScreen: React.FC = () => {
useEffect(() => {
const unsubWake = wakeWordService.onWakeWord(async () => {
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
// Aufnahme mit Auto-Stop (VAD) starten
const started = await audioService.startRecording(true);
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (!started) {
// Mikrofon nicht verfuegbar, Wake Word wieder aktivieren
// Mikrofon nicht verfuegbar, naechsten Versuch
wakeWordService.resume();
}
});
@ -397,7 +485,7 @@ const ChatScreen: React.FC = () => {
const unsubSilence = audioService.onSilenceDetected(async () => {
const result = await audioService.stopRecording();
if (result && result.durationMs > 500) {
// Sprachnachricht senden (gleiche Logik wie handleVoiceRecording)
// User hat im Fenster gesprochen → Sprachnachricht senden
const location = await getCurrentLocation();
const userMsg: ChatMessage = {
id: nextId(),
@ -412,11 +500,17 @@ const ChatScreen: React.FC = () => {
durationMs: result.durationMs,
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }),
});
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
} else {
// Kein Speech im Window → Konversation beenden (Ohr geht aus oder
// bleibt armed wenn Wake Word verfuegbar)
wakeWordService.endConversation();
// UI-State synchron halten
if (!wakeWordService.isActive()) setWakeWordActive(false);
}
// Wake Word wieder aktivieren
if (wakeWordActive) wakeWordService.resume();
});
return () => {
@ -428,7 +522,12 @@ const ChatScreen: React.FC = () => {
// Wake Word Toggle Handler
const toggleWakeWord = useCallback(async () => {
if (wakeWordActive) {
wakeWordService.stop();
// Vor Porcupine-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 {}
await wakeWordService.stop();
setWakeWordActive(false);
} else {
const started = await wakeWordService.start();
@ -514,10 +613,13 @@ const ChatScreen: React.FC = () => {
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
rvs.send('chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
@ -544,6 +646,8 @@ const ChatScreen: React.FC = () => {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }),
});
}, [getCurrentLocation]);
@ -627,6 +731,7 @@ const ChatScreen: React.FC = () => {
rvs.send('chat', {
text: messageText,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }),
});
}
@ -701,9 +806,10 @@ const ChatScreen: React.FC = () => {
))}
{/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */}
{!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
{item.text}
</Text>
<MessageText
text={item.text}
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
/>
)}
{/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Bridge-TTS mit aktueller Engine */}
{!isUser && item.text.length > 0 && (
@ -718,6 +824,7 @@ const ChatScreen: React.FC = () => {
rvs.send('tts_request' as any, {
text: item.text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
messageId: item.messageId || '',
});
}
@ -753,6 +860,49 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity>
</View>
{/* Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) */}
{(() => {
const entries = Object.entries(serviceStatus);
if (entries.length === 0 || serviceBannerDismissed) return null;
const anyLoading = entries.some(([, v]) => v.state === 'loading');
const anyError = entries.some(([, v]) => v.state === 'error');
const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready');
const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A';
const border = anyError ? '#FF3B30' : anyLoading ? '#FFD60A' : '#34C759';
const labels: Record<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT' };
return (
<TouchableOpacity
activeOpacity={allReady ? 0.6 : 1.0}
onPress={() => { if (allReady) setServiceBannerDismissed(true); }}
style={[styles.serviceBanner, { backgroundColor: bg, borderColor: border }]}
>
{entries.map(([svc, info]) => {
let icon = '\u23F3', text = '';
if (info.state === 'loading') {
text = `${labels[svc] || svc}: laedt${info.model ? ' ' + info.model : ''}...`;
} else if (info.state === 'ready') {
icon = '\u2705';
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}`;
} else if (info.state === 'error') {
icon = '\u274C';
text = `${labels[svc] || svc}: Fehler ${info.error || ''}`;
} else {
text = `${labels[svc] || svc}: ${info.state}`;
}
return (
<Text key={svc} style={styles.serviceBannerLine}>
{icon} {text}
</Text>
);
})}
<Text style={styles.serviceBannerHint}>
{allReady ? 'Tippen zum Schliessen' : 'Bitte warten...'}
</Text>
</TouchableOpacity>
);
})()}
{/* Suchleiste */}
{searchVisible && (
<View style={styles.searchBar}>
@ -895,7 +1045,10 @@ const ChatScreen: React.FC = () => {
style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
onPress={toggleWakeWord}
>
<Text style={styles.wakeWordIcon}>{wakeWordActive ? '👂' : '🔇'}</Text>
<Text style={styles.wakeWordIcon}>
{wakeWordState === 'conversing' ? '🎙️' :
wakeWordState === 'armed' ? '👂' : '🔇'}
</Text>
</TouchableOpacity>
</>
)}
@ -967,6 +1120,25 @@ const styles = StyleSheet.create({
color: '#8888AA',
fontSize: 12,
},
serviceBanner: {
paddingVertical: 8,
paddingHorizontal: 12,
borderTopWidth: 0,
borderBottomWidth: 1,
borderLeftWidth: 0,
borderRightWidth: 0,
},
serviceBannerLine: {
color: '#FFFFFF',
fontSize: 12,
lineHeight: 18,
},
serviceBannerHint: {
color: '#AAAACC',
fontSize: 10,
marginTop: 2,
fontStyle: 'italic',
},
messageList: {
padding: 12,
paddingBottom: 8,

View File

@ -27,7 +27,25 @@ import {
TTS_PREROLL_MIN_SEC,
TTS_PREROLL_MAX_SEC,
TTS_PREROLL_STORAGE_KEY,
VAD_SILENCE_DEFAULT_SEC,
VAD_SILENCE_MIN_SEC,
VAD_SILENCE_MAX_SEC,
VAD_SILENCE_STORAGE_KEY,
CONV_WINDOW_DEFAULT_SEC,
CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY,
TTS_SPEED_DEFAULT,
TTS_SPEED_MIN,
TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import wakeWordService, {
WAKE_KEYWORDS,
KEYWORD_LABELS,
DEFAULT_KEYWORD,
WAKE_KEYWORD_STORAGE,
} from '../services/wakeword';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
import VoiceCloneModal from '../components/VoiceCloneModal';
@ -82,6 +100,11 @@ const SettingsScreen: React.FC = () => {
const [storageSize, setStorageSize] = useState('...');
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@ -117,6 +140,31 @@ const SettingsScreen: React.FC = () => {
}
}
});
AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= VAD_SILENCE_MIN_SEC && n <= VAD_SILENCE_MAX_SEC) {
setVadSilenceSec(n);
}
}
});
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
setConvWindowSec(n);
}
}
});
AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) setTtsSpeed(n);
}
});
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved);
});
@ -555,6 +603,133 @@ const SettingsScreen: React.FC = () => {
</View>
</View>
{/* === Spracheingabe (geraetelokal) === */}
<Text style={styles.sectionTitle}>Spracheingabe</Text>
<View style={styles.card}>
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
<Text style={styles.toggleHint}>
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
Nachdenken; niedriger = schnelleres Senden.
Default: {VAD_SILENCE_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(VAD_SILENCE_MIN_SEC, Math.round((vadSilenceSec - 0.5) * 10) / 10);
setVadSilenceSec(next);
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
}}
disabled={vadSilenceSec <= VAD_SILENCE_MIN_SEC}
>
<Text style={styles.prerollButtonText}>0.5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(VAD_SILENCE_MAX_SEC, Math.round((vadSilenceSec + 0.5) * 10) / 10);
setVadSilenceSec(next);
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
}}
disabled={vadSilenceSec >= VAD_SILENCE_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
<Text style={styles.toggleHint}>
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
Sprichst du nichts Mikrofon zu.
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
</View>
{/* === Wake-Word (komplett on-device, openWakeWord) === */}
<Text style={styles.sectionTitle}>Wake-Word</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Lokale Erkennung via openWakeWord (ONNX, on-device). Kein API-Key,
kein Cloud-Roundtrip Audio verlaesst das Geraet nicht. Wenn das Ohr
aktiv ist, hoerst du normal mit; sagst du das Wake-Word, startet eine
Konversation mit ARIA.
</Text>
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
<Text style={styles.toggleHint}>
Eigene Wake-Words via openWakeWord-Notebook trainierbar (gratis).
Custom-Upload ueber Diagnostic kommt in einer spaeteren Version.
</Text>
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
{WAKE_KEYWORDS.map(kw => (
<TouchableOpacity
key={kw}
style={[
styles.keywordChip,
wakeKeyword === kw && styles.keywordChipActive,
]}
onPress={() => setWakeKeyword(kw)}
>
<Text style={[
styles.keywordChipText,
wakeKeyword === kw && styles.keywordChipTextActive,
]}>
{KEYWORD_LABELS[kw]}
</Text>
</TouchableOpacity>
))}
</View>
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
<TouchableOpacity
style={[styles.connectButton, {flex: 1}]}
onPress={async () => {
setWakeStatus('Initialisiere...');
try {
const ok = await wakeWordService.configure(wakeKeyword);
setWakeStatus(ok ? `✅ "${KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS]}" bereit` : '❌ Init-Fehler — Logs pruefen');
} catch (err: any) {
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
}
setTimeout(() => setWakeStatus(''), 5000);
}}
>
<Text style={styles.connectButtonText}>Speichern + Aktivieren</Text>
</TouchableOpacity>
</View>
{!!wakeStatus && (
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
)}
</View>
{/* === Sprachausgabe (geraetelokal) === */}
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
<View style={styles.card}>
@ -610,6 +785,38 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Sprechgeschwindigkeit</Text>
<Text style={styles.toggleHint}>
Wie schnell ARIA spricht. 1.0 = Normal. Niedriger = langsamer, hoeher = schneller.
Wird an F5-TTS als speed-Param uebergeben und pro Geraet gespeichert.
Default: {TTS_SPEED_DEFAULT.toFixed(1)}x.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(TTS_SPEED_MIN, Math.round((ttsSpeed - 0.1) * 10) / 10);
setTtsSpeed(next);
AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next));
}}
disabled={ttsSpeed <= TTS_SPEED_MIN}
>
<Text style={styles.prerollButtonText}>0.1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{ttsSpeed.toFixed(1)} x</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(TTS_SPEED_MAX, Math.round((ttsSpeed + 0.1) * 10) / 10);
setTtsSpeed(next);
AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next));
}}
disabled={ttsSpeed >= TTS_SPEED_MAX}
>
<Text style={styles.prerollButtonText}>+0.1</Text>
</TouchableOpacity>
</View>
</View>
)}
@ -617,23 +824,13 @@ const SettingsScreen: React.FC = () => {
<View style={{marginTop: 20}}>
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
<Text style={styles.toggleHint}>
Eigene Wahl fuer dieses Geraet. Ohne Auswahl gilt der Diagnostic-Default.
Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz
ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme.
</Text>
{/* Default-Option */}
<TouchableOpacity
style={[styles.voiceRow, xttsVoice === '' && styles.voiceRowActive]}
onPress={() => selectVoice('')}
>
<Text style={[styles.voiceRowName, xttsVoice === '' && styles.voiceRowNameActive]}>
Standard (Diagnostic-Default)
</Text>
{xttsVoice === '' && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
</TouchableOpacity>
{availableVoices.length === 0 ? (
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
Keine eigenen Stimmen auf dem XTTS-Server.
Keine geklonten Stimmen vorhanden unten "Eigene Stimme aufnehmen".
</Text>
) : (
availableVoices.map(v => (
@ -1235,6 +1432,28 @@ const styles = StyleSheet.create({
minWidth: 80,
textAlign: 'center',
},
keywordChip: {
backgroundColor: '#1E1E2E',
borderWidth: 1,
borderColor: '#2A2A3E',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
},
keywordChipActive: {
backgroundColor: '#0096FF',
borderColor: '#0096FF',
},
keywordChipText: {
color: '#8888AA',
fontSize: 13,
fontWeight: '500',
},
keywordChipTextActive: {
color: '#FFFFFF',
fontWeight: '700',
},
});
export default SettingsScreen;

View File

@ -74,17 +74,76 @@ const AUDIO_ENCODING = 'audio/wav';
// VAD (Voice Activity Detection) — Stille-Erkennung
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
const VAD_SILENCE_DURATION_MS = 1800; // ms Stille bevor Auto-Stop
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
// Max-Dauer einer Aufnahme in Gespraechsmodus (Notbremse gegen Runaway-Loops)
const MAX_RECORDING_MS = 30000;
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
// die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings.
export const VAD_SILENCE_DEFAULT_SEC = 2.8;
export const VAD_SILENCE_MIN_SEC = 1.0;
export const VAD_SILENCE_MAX_SEC = 8.0;
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
export const TTS_SPEED_DEFAULT = 1.0;
export const TTS_SPEED_MIN = 0.1;
export const TTS_SPEED_MAX = 5.0;
export const TTS_SPEED_STORAGE_KEY = 'aria_tts_speed';
export async function loadTtsSpeed(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) return n;
}
} catch {}
return TTS_SPEED_DEFAULT;
}
export async function loadConvWindowMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
}
async function loadVadSilenceMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= VAD_SILENCE_MIN_SEC && n <= VAD_SILENCE_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(VAD_SILENCE_DEFAULT_SEC * 1000);
}
// Max-Dauer einer Aufnahme (Notbremse gegen Runaway-Loops). Auf 2 Minuten
// hochgezogen damit auch laengere Erklaerungen durchgehen.
const MAX_RECORDING_MS = 120000;
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
export const TTS_PREROLL_DEFAULT_SEC = 3.5;
export const TTS_PREROLL_MIN_SEC = 1.0;
export const TTS_PREROLL_MIN_SEC = 0; // 0 = sofort abspielen (F5-TTS ist schnell genug)
export const TTS_PREROLL_MAX_SEC = 6.0;
export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec';
@ -132,17 +191,85 @@ class AudioService {
private pcmBytesCollected: number = 0;
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
// AudioFocus wird verzoegert freigegeben — wenn ARIA eine zweite Antwort
// direkt hinterherschickt (oder ein neuer Stream startet), bleibt Spotify
// pausiert. Ohne diese Verzoegerung springt Spotify im Mikro-Sekunden-Gap
// zwischen zwei Streams kurz wieder an.
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
// Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER
// wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir
// den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt,
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false;
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
private vadTimer: ReturnType<typeof setInterval> | null = null;
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
private silenceFired: boolean = false;
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.recorder = new AudioRecorderPlayer();
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
}
/** AudioFocus mit kleiner Verzoegerung freigeben Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an.
* Im Conversation-Mode (Wake-Word conversing) wird das Release komplett
* unterdrueckt der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
if (this._conversationFocusActive) return;
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
private _cancelDeferredFocusRelease(): void {
if (this.focusReleaseTimer) {
clearTimeout(this.focusReleaseTimer);
this.focusReleaseTimer = null;
}
}
/** Conversation-Mode beginnt AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
if (this._conversationFocusActive) return;
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {});
}
/** Conversation-Mode endet Focus darf wieder freigegeben werden
* (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */
releaseConversationFocus(): void {
if (!this._conversationFocusActive) return;
this._conversationFocusActive = false;
console.log('[Audio] Conversation-Focus inaktiv');
this._releaseFocusDeferred();
}
/** TTS-Wiedergabe haart stoppen z.B. wenn ein Anruf reinkommt.
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
this.stopPlayback();
}
// --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> {
@ -169,8 +296,16 @@ class AudioService {
// --- Aufnahme ---
/** Mikrofon-Aufnahme starten */
async startRecording(autoStop: boolean = false): Promise<boolean> {
/** Mikrofon-Aufnahme starten.
*
* @param autoStop VAD aktivieren Auto-Stop bei Stille
* @param noSpeechTimeoutMs Wenn der User innerhalb dieser Zeit nichts sagt,
* wird Stille gemeldet (Recording wird verworfen).
* Fuer Conversation-Window: nach ARIA's Antwort
* hast du nur N Sekunden um anzufangen, sonst
* Gespraech zu Ende.
*/
async startRecording(autoStop: boolean = false, noSpeechTimeoutMs: number = 0): Promise<boolean> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] Aufnahme laeuft bereits');
return false;
@ -235,25 +370,54 @@ class AudioService {
this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
// VAD aktivieren
// VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar).
// WICHTIG: jeder Trigger (VAD-Stille / Max-Dauer / No-Speech-Window)
// disable SOFORT den VAD-Flag und clear den Timer, BEVOR die Listener
// gefeuert werden. Sonst feuert das setInterval weiter alle 200ms und
// ruft stopRecording parallel auf → audio-recorder-player crasht.
this.vadEnabled = autoStop;
this.silenceFired = false;
const fireSilenceOnce = (reason: string) => {
if (this.silenceFired) return;
this.silenceFired = true;
this.vadEnabled = false;
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
console.log('[Audio] Silence-Fire: %s', reason);
this.silenceListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] silence listener err:', e); }
});
};
if (autoStop) {
const vadSilenceMs = await loadVadSilenceMs();
console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms',
vadSilenceMs, MAX_RECORDING_MS);
this.vadTimer = setInterval(() => {
const silenceDuration = Date.now() - this.lastSpeechTime;
if (silenceDuration >= VAD_SILENCE_DURATION_MS) {
console.log(`[Audio] VAD: ${silenceDuration}ms Stille — Auto-Stop`);
this.silenceListeners.forEach(cb => cb());
if (silenceDuration >= vadSilenceMs) {
fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`);
}
}, 200);
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
this.maxDurationTimer = setTimeout(() => {
console.warn(`[Audio] Max-Dauer ${MAX_RECORDING_MS}ms erreicht — Zwangs-Stop`);
this.silenceListeners.forEach(cb => cb());
fireSilenceOnce(`Max-Dauer ${MAX_RECORDING_MS}ms`);
}, MAX_RECORDING_MS);
}
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
// anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie).
if (noSpeechTimeoutMs > 0) {
this.noSpeechTimer = setTimeout(() => {
if (!this.speechDetected && this.recordingState === 'recording') {
fireSilenceOnce(`Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache`);
}
}, noSpeechTimeoutMs);
}
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
return true;
} catch (err) {
@ -280,13 +444,18 @@ class AudioService {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
if (this.noSpeechTimer) {
clearTimeout(this.noSpeechTimer);
this.noSpeechTimer = null;
}
try {
await this.recorder.stopRecorder();
this.recorder.removeRecordBackListener();
// Audio-Focus freigeben — andere Apps duerfen wieder
AudioFocus?.release().catch(() => {});
// Audio-Focus verzoegert freigeben — gleich kommt die TTS-Antwort,
// im Gap soll Spotify nicht hochkommen.
this._releaseFocusDeferred();
const durationMs = Date.now() - this.recordingStartTime;
const hadSpeech = this.speechDetected;
@ -358,7 +527,13 @@ class AudioService {
/** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen.
* silent=true nur cachen, nicht abspielen (z.B. wenn TTS geraetelokal gemutet).
* Gibt bei final=true den Cache-Pfad zurueck (file://) oder '' wenn nicht gecached. */
* Gibt bei final=true den Cache-Pfad zurueck (file://) oder '' wenn nicht gecached.
*
* Wrapper serialisiert aufeinanderfolgende Chunk-Calls via Promise-Queue
* sonst gabs bei kurzen Streams einen Race: final-Chunk konnte `end()` rufen
* BEVOR der vorherige `start()` im Native-Modul fertig war. Der Writer-
* Thread sah dann endRequested=true ohne jemals Chunks zu verarbeiten. */
private _pcmChunkQueue: Promise<any> = Promise.resolve();
async handlePcmChunk(payload: {
base64: string;
sampleRate?: number;
@ -367,12 +542,37 @@ class AudioService {
chunk?: number;
final?: boolean;
silent?: boolean;
}): Promise<string> {
const p = this._pcmChunkQueue.then(() => this._handlePcmChunkImpl(payload)).catch(err => {
console.warn('[Audio] handlePcmChunk queued err:', err);
return '';
});
// Chain only on the side effect — callers still get the per-call result
this._pcmChunkQueue = p;
return p;
}
private async _handlePcmChunkImpl(payload: {
base64: string;
sampleRate?: number;
channels?: number;
messageId?: string;
chunk?: number;
final?: boolean;
silent?: boolean;
}): Promise<string> {
const silent = !!payload.silent;
if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return '';
}
// Debug-Log bei Chunk 0 eines neuen Streams — damit man im adb logcat
// sieht warum der Auto-Playback greift oder nicht.
if ((payload.chunk ?? 0) === 0 && !this.pcmStreamActive) {
console.log('[Audio] PCM-Stream start: silent=%s messageId=%s sr=%s ch=%s',
silent, payload.messageId || '(none)',
payload.sampleRate, payload.channels);
}
const messageId = payload.messageId || '';
const sampleRate = payload.sampleRate || 24000;
@ -402,6 +602,7 @@ class AudioService {
this.pcmStreamActive = false;
return '';
}
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
}
}
@ -419,8 +620,13 @@ class AudioService {
if (isFinal) {
if (!silent) {
// end() resolved jetzt erst wenn der native Writer-Thread fertig
// ist (alle Samples ausgespielt) — danach AudioFocus verzoegert
// freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei
// ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen.
try { await PcmStreamPlayer!.end(); } catch {}
AudioFocus?.release().catch(() => {});
this._releaseFocusDeferred();
}
this.pcmStreamActive = false;
@ -524,8 +730,9 @@ class AudioService {
private async _playNext(): Promise<void> {
if (this.audioQueue.length === 0) {
this.isPlaying = false;
// Audio-Focus abgeben → andere Apps volle Lautstaerke
AudioFocus?.release().catch(() => {});
// Audio-Focus verzoegert abgeben → wenn gleich noch eine Antwort kommt,
// bleibt Spotify pausiert.
this._releaseFocusDeferred();
// Alle Audio-Teile abgespielt → Listener benachrichtigen
this.playbackFinishedListeners.forEach(cb => cb());
return;
@ -533,6 +740,7 @@ class AudioService {
// Beim ersten Playback-Start: andere Apps ducken
if (!this.isPlaying) {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
}
this.isPlaying = true;
@ -618,7 +826,8 @@ class AudioService {
this.pcmBytesCollected = 0;
this.pcmMessageId = '';
}
// Audio-Focus freigeben
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
}

View File

@ -0,0 +1,108 @@
/**
* PhoneCall-Service pausiert die TTS-Wiedergabe wenn das Telefon klingelt
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
*
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
* (Audio kommt nicht zurueck), der User muesste die Antwort manuell
* nochmal anfordern (Play-Button auf der Nachricht).
*
* Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
*/
import {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
ToastAndroid,
} from 'react-native';
import audioService from './audio';
interface PhoneCallNative {
start(): Promise<boolean>;
stop(): Promise<boolean>;
}
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService {
private started: boolean = false;
private subscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle';
async start(): Promise<boolean> {
if (this.started || !PhoneCall) return false;
if (Platform.OS !== 'android') return false;
// Runtime-Permission holen (nur einmal noetig)
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
{
title: 'ARIA Cockpit — Anruf-Erkennung',
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
return false;
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
}
try {
const ok = await PhoneCall.start();
if (!ok) {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
return false;
}
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => {
this._onStateChanged(e.state);
});
this.started = true;
console.log('[PhoneCall] Listener aktiv');
return true;
} catch (err: any) {
console.warn('[PhoneCall] start gescheitert:', err?.message || err);
return false;
}
}
async stop(): Promise<void> {
if (!this.started || !PhoneCall) return;
try {
this.subscription?.remove();
this.subscription = null;
await PhoneCall.stop();
} catch {}
this.started = false;
this.lastState = 'idle';
}
private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return;
console.log('[PhoneCall] State: %s → %s', this.lastState, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`);
ToastAndroid.show(
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
ToastAndroid.SHORT,
);
}
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern
}
}
const phoneCallService = new PhoneCallService();
export default phoneCallService;

View File

@ -29,6 +29,11 @@ class UpdateService {
private downloading = false;
constructor() {
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
// laeuft, sind frueher heruntergeladene APKs entweder schon installiert
// oder unvollstaendig gewesen. Spart sonst pro Update 20-30MB auf dem Handy.
this.cleanupOldApks().catch(() => {});
// Auf update_available Nachrichten lauschen
rvs.onMessage((msg: RVSMessage) => {
if (msg.type === 'update_available' as any) {
@ -45,6 +50,30 @@ class UpdateService {
});
}
/** Raeumt alte heruntergeladene APK-Dateien aus dem Cache auf. */
private async cleanupOldApks(): Promise<void> {
try {
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
const apks = files.filter(f => /\.apk$/i.test(f.name));
let freed = 0;
for (const f of apks) {
try {
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path);
freed += size;
console.log(`[Update] Alte APK geloescht: ${f.name} (${(size / 1024 / 1024).toFixed(1)}MB)`);
} catch (err: any) {
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.name} (${err?.message || err})`);
}
}
if (apks.length > 0) {
console.log(`[Update] Cleanup fertig: ${apks.length} APKs entfernt, ${(freed / 1024 / 1024).toFixed(1)}MB freigegeben`);
}
} catch (err: any) {
console.warn(`[Update] Cleanup-Fehler: ${err?.message || err}`);
}
}
/** Bei App-Start Update pruefen */
checkForUpdate(): void {
if (this.checking) return;
@ -111,6 +140,10 @@ class UpdateService {
});
});
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
// Updates in einer Session gezogen werden
await this.cleanupOldApks();
// Base64 als APK-Datei speichern
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
await RNFS.writeFile(destPath, apkData.base64, 'base64');

View File

@ -1,56 +1,262 @@
/**
* Gespraechsmodus "Ohr-Button"
* Gespraechsmodus / Wake Word Service
*
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
* Wie ein Walkie-Talkie / natuerliches Gespraech:
* ARIA spricht Aufnahme startet User spricht VAD stoppt ARIA antwortet ...
* Wake-Word-Engine: openWakeWord (https://github.com/dscripka/openWakeWord),
* komplett on-device via ONNX Runtime in Native-Kotlin (siehe
* OpenWakeWordModule.kt + assets/openwakeword/). Kein API-Key, kein Cloud-
* Roundtrip, kein Cent Lizenzgebuehren.
*
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
* Drei Zustaende:
* off Ohr aus, nichts laeuft
* armed Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word.
* Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus.
* conversing Wake-Word getriggert (oder Ohr-Tap manuell):
* aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei),
* AudioRecorder uebernimmt fuer die Aufnahme.
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
* (Conversation-Window). Stille im Fenster zurueck zu armed.
*
* Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht
* 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus).
*/
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
export type WakeWordState = 'off' | 'listening' | 'detected';
export type WakeWordState = 'off' | 'armed' | 'conversing';
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
/** Verfuegbare Wake-Words entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
* werden Diagnostic-Upload ist Phase 2. */
export const WAKE_KEYWORDS = [
'hey_jarvis',
'computer',
'alexa',
'hey_mycroft',
'hey_rhasspy',
] as const;
export type WakeKeyword = typeof WAKE_KEYWORDS[number];
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';
/** Hilfs-Mapping fuer die Anzeige im UI. */
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
hey_jarvis: 'Hey Jarvis',
computer: 'Computer',
alexa: 'Alexa',
hey_mycroft: 'Hey Mycroft',
hey_rhasspy: 'Hey Rhasspy',
};
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
const DEFAULT_THRESHOLD = 0.5;
const DEFAULT_PATIENCE = 2;
const DEFAULT_DEBOUNCE_MS = 1500;
interface OpenWakeWordModule {
init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise<boolean>;
start(): Promise<boolean>;
stop(): Promise<boolean>;
dispose(): Promise<boolean>;
isAvailable(): Promise<boolean>;
}
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
class WakeWordService {
private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = [];
/** Gespraechsmodus starten */
private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false;
private initInProgress: Promise<boolean> | null = null;
private eventSub: { remove: () => void } | null = null;
/** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */
async loadFromStorage(): Promise<void> {
try {
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword;
this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD;
await this.initNative();
} catch (err) {
console.warn('[WakeWord] loadFromStorage', err);
}
}
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
async configure(keyword: string): Promise<boolean> {
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
? (keyword as WakeKeyword)
: DEFAULT_KEYWORD;
this.keyword = next;
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);
// Laufende Instanz stoppen + neu initialisieren
await this.disposeNative();
const ok = await this.initNative();
if (!ok) {
ToastAndroid.show(
`Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`,
ToastAndroid.LONG,
);
}
return ok;
}
private async initNative(): Promise<boolean> {
if (!OpenWakeWord) {
console.warn('[WakeWord] OpenWakeWord Native-Modul nicht verfuegbar — Direkt-Aufnahme-Fallback aktiv');
this.nativeReady = false;
return false;
}
if (this.initInProgress) return this.initInProgress;
this.initInProgress = (async () => {
try {
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal
if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
this.eventSub = emitter.addListener('WakeWordDetected', () => {
console.log('[WakeWord] Native Detection-Event empfangen');
this.onWakeDetected().catch(err =>
console.warn('[WakeWord] onWakeDetected crashed:', err));
});
}
this.nativeReady = true;
console.log('[WakeWord] Init OK (model=%s)', this.keyword);
return true;
} catch (err: any) {
console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err);
this.nativeReady = false;
return false;
} finally {
this.initInProgress = null;
}
})();
return this.initInProgress;
}
private async disposeNative(): Promise<void> {
if (!OpenWakeWord) return;
try { await OpenWakeWord.dispose(); } catch {}
this.nativeReady = false;
}
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
async start(): Promise<boolean> {
if (this.state === 'listening') return true;
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
this.setState('listening');
// Sofort erste Aufnahme starten
if (this.state !== 'off') return true;
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.start();
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return true;
} catch (err: any) {
console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:',
err?.message || err);
ToastAndroid.show(
`Wake-Word-Start failed: ${err?.message || err}`,
ToastAndroid.LONG,
);
}
} else {
console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback');
ToastAndroid.show(
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
ToastAndroid.LONG,
);
}
// Fallback: direkt in Konversation
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
this.setState('conversing');
setTimeout(() => {
if (this.state === 'listening') {
if (this.state === 'conversing') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 500);
return true;
}
/** Gespraechsmodus stoppen */
stop(): void {
console.log('[WakeWord] Gespraechsmodus deaktiviert');
/** Komplett ausschalten (Ohr abschalten) */
async stop(): Promise<void> {
console.log('[WakeWord] Ohr deaktiviert');
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.setState('off');
}
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> {
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT);
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.setState('conversing');
setTimeout(() => {
if (this.state === 'conversing') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 200);
}
/** Konversation beenden User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'.
*/
async endConversation(): Promise<void> {
if (this.state !== 'conversing') return;
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
}
}
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
this.setState('off');
}
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
async resume(): Promise<void> {
if (this.state !== 'listening') return;
if (this.state !== 'conversing') return;
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
await new Promise(resolve => setTimeout(resolve, 800));
if (this.state === 'listening') {
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
if (this.state === 'conversing') {
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
this.wakeCallbacks.forEach(cb => cb());
}
}
/** True solange das Ohr aktiv ist (armed ODER conversing). */
isActive(): boolean {
return this.state === 'listening';
return this.state !== 'off';
}
isConversing(): boolean {
return this.state === 'conversing';
}
hasWakeWord(): boolean {
return this.nativeReady;
}
getKeyword(): WakeKeyword {
return this.keyword;
}
// --- Callbacks ---

View File

@ -325,8 +325,16 @@ class STTEngine:
Erkannter Text oder leerer String.
"""
if self.model is None:
logger.error("Whisper-Modell nicht initialisiert")
return ""
# Lazy-Load: normalerweise laeuft STT remote auf der Gamebox.
# Erst wenn das Fallback hier zuschlaegt, laden wir lokal.
logger.info("Lokales Whisper-Fallback — Modell wird nachgeladen...")
try:
self.initialize()
except Exception:
logger.exception("Lokales Whisper konnte nicht geladen werden")
return ""
if self.model is None:
return ""
try:
# Audio als float32 normalisieren
@ -488,6 +496,7 @@ class ARIABridge:
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
self.tts_enabled = True
self.xtts_voice = ""
self._f5tts_config: dict = {}
vc: dict = {}
# Gespeicherte Voice-Config laden
try:
@ -497,7 +506,16 @@ class ARIABridge:
vc = json.load(f)
self.tts_enabled = vc.get("ttsEnabled", True)
self.xtts_voice = vc.get("xttsVoice", "")
logger.info("Voice-Config geladen: tts=%s voice=%s", self.tts_enabled, self.xtts_voice or "default")
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
"f5ttsCfgStrength", "f5ttsNfeStep"):
if k in vc:
self._f5tts_config[k] = vc[k]
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s",
self.tts_enabled, self.xtts_voice or "default",
self._f5tts_config or "defaults")
except Exception as e:
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
@ -523,6 +541,25 @@ class ARIABridge:
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
self._next_voice_override: Optional[str] = None
# Gleiche Logik fuer die Wiedergabegeschwindigkeit (F5-TTS speed-Param,
# App-Setting aria_tts_speed, 1.0 = normal).
self._next_speed_override: Optional[float] = None
# STT-Requests die aktuell auf Antwort von der whisper-bridge (Gamebox) warten.
# requestId → Future mit dem Text (oder None bei Fehler).
self._pending_stt: dict[str, asyncio.Future] = {}
# whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt.
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False
# Pending Files: wenn die App ein Bild + Text gleichzeitig schickt, kommen
# zwei separate RVS-Events ('file' und 'chat') — wir buffern die Files
# kurz und mergen sie mit dem nachfolgenden Chat-Text zu einer einzigen
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
# Anweisung" beim file, einmal auf den Chat-Text).
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
self._pending_files: list[tuple[str, str, str, int, int, int]] = []
self._pending_files_flush_task: Optional[asyncio.Task] = None
self._PENDING_FILES_WINDOW_SEC: float = 0.8
def initialize(self) -> None:
"""Initialisiert alle Komponenten.
@ -535,8 +572,9 @@ class ARIABridge:
logger.info("ARIA Voice Bridge startet...")
logger.info("=" * 50)
# STT IMMER laden — verarbeitet Audio von der App (braucht kein Sounddevice)
self.stt_engine.initialize()
# STT wird standardmaessig von der whisper-bridge (Gamebox) erledigt.
# Lokales Whisper ist nur Fallback und wird lazy geladen wenn remote nicht
# antwortet. Das spart RAM auf der VM und Startup-Zeit.
# Audio-Hardware pruefen (fuer lokales Mikro/Lautsprecher)
self.audio_available = False
@ -878,12 +916,13 @@ class ARIABridge:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
return
# Voice bestimmen: App-Override fuer diesen Request > globale Default-Voice
# Voice bestimmen: App-Override (gesetzt durch letzten chat-Event) > globale
# Default-Voice. Der Override wird NICHT pro Antwort verbraucht — sonst nutzt
# eine Multi-Turn-Antwort von ARIA (Tool-Use + finale Antwort) ab dem zweiten
# TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
# Override verbrauchen (gilt nur fuer genau diese naechste Antwort)
if self._next_voice_override:
logger.info("[core] Nutze Voice-Override: %s", self._next_voice_override)
self._next_voice_override = None
xtts_speed = self._next_speed_override or 1.0
tts_text = tts_text_preview or text
if not tts_text:
@ -900,13 +939,15 @@ class ARIABridge:
"payload": {
"text": tts_text,
"voice": xtts_voice,
"speed": xtts_speed,
"language": "de",
"requestId": xtts_request_id,
"messageId": message_id,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[core] XTTS-Request gesendet (%s): '%s'", xtts_voice or "default", tts_text[:60])
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx): '%s'",
xtts_voice or "default", xtts_speed, tts_text[:60])
except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
@ -951,6 +992,29 @@ class ARIABridge:
except Exception as e:
logger.debug("[mode] Broadcast fehlgeschlagen: %s", e)
async def _broadcast_persisted_config(self) -> None:
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
Damit bekommen frisch verbundene Bridges (insbesondere die f5tts-bridge
auf der Gamebox nach Container-Restart) die zuletzt in Diagnostic
gewaehlten Settings ohne dass der User in Diagnostic was klicken muss.
"""
try:
payload = {
"ttsEnabled": getattr(self, "tts_enabled", True),
"xttsVoice": getattr(self, "xtts_voice", ""),
"whisperModel": self.stt_engine.model_size,
}
payload.update(getattr(self, "_f5tts_config", {}) or {})
await self._send_to_rvs({
"type": "config",
"payload": payload,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[rvs] Persistierte Config broadcastet: %s", payload)
except Exception as e:
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e)
def _fetch_active_session(self) -> None:
"""Holt die aktive Session vom Diagnostic-Endpoint."""
try:
@ -964,6 +1028,51 @@ class ARIABridge:
except Exception as e:
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
def _build_pending_files_message(self, user_text: str) -> str:
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
User-Text. user_text leer 'warte auf Anweisung'-Variante."""
parts: list[str] = []
for fp, name, ftype, kb, w, h in self._pending_files:
dim = f" {w}x{h}px" if (w and h) else ""
kind = "Bild" if ftype.startswith("image/") else "Datei"
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
files_summary = "\n".join(parts)
n = len(self._pending_files)
anhang = "Anhang" if n == 1 else "Anhaenge"
if user_text:
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Er sagt dazu: \"{user_text}\"")
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
f"Warte auf seine Anweisung was du damit tun sollst.")
async def _flush_pending_files_after(self, delay: float) -> None:
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
aria-core senden ('warte auf Anweisung'-Variante)."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self._pending_files:
return
text = self._build_pending_files_message("")
self._pending_files = []
self._pending_files_flush_task = None
await self.send_to_core(text, source="app-file")
async def _flush_pending_files_with_text(self, user_text: str) -> bool:
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
Files + Text zu einer einzigen aria-core-Nachricht mergen.
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden)."""
if not self._pending_files:
return False
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text)
self._pending_files = []
await self.send_to_core(text, source="app-file+chat")
return True
async def send_to_core(self, text: str, source: str = "bridge") -> None:
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
if self.ws_core is None:
@ -1020,6 +1129,12 @@ class ARIABridge:
# ihren UI-State sofort syncen koennen
await self._broadcast_current_mode()
# Persistierte Voice-Config broadcasten — die f5tts-bridge auf
# der Gamebox bekommt damit nach Restart die zuletzt in
# Diagnostic gewaehlten Settings wieder (sonst stuende sie auf
# ihren Hard-Defaults).
asyncio.create_task(self._broadcast_persisted_config())
# Heartbeat senden (RVS erwartet Ping alle 30s)
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
@ -1103,14 +1218,32 @@ class ARIABridge:
if sender in ("aria", "stt"):
return
text = payload.get("text", "")
# Voice-Override fuer die naechste ARIA-Antwort merken
voice_override = payload.get("voice", "")
if voice_override:
self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override fuer naechste Antwort: %s", voice_override)
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
# Field nicht gesendet = vorherigen Override unveraendert lassen (z.B. wenn
# cancel_request oder anderer Service die App umgeht).
if "voice" in payload:
voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override or None
logger.info("[rvs] Voice fuer Antworten: %s",
self._next_voice_override or "(Default)")
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
if "speed" in payload:
try:
speed = float(payload.get("speed", 0) or 0)
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError):
self._next_speed_override = None
if text:
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
# Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
# zwei separater send_to_core-Calls.
merged = await self._flush_pending_files_with_text(text)
if merged:
logger.info("[rvs] App-Chat (mit Anhaengen): '%s'", text[:80])
else:
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
return
if msg_type == "cancel_request":
@ -1160,8 +1293,14 @@ class ARIABridge:
if not text:
return
tts_text = clean_text_for_tts(text) or text
# Voice aus App-Payload gewinnt, sonst global
# Voice + Speed aus App-Payload gewinnen, sonst global/default
xtts_voice = payload.get("voice", "") or getattr(self, 'xtts_voice', '')
try:
xtts_speed = float(payload.get("speed", 0) or 0)
if not (0.1 <= xtts_speed <= 5.0):
xtts_speed = 1.0
except (TypeError, ValueError):
xtts_speed = 1.0
try:
xtts_request_id = str(uuid.uuid4())
if message_id:
@ -1171,6 +1310,7 @@ class ARIABridge:
"payload": {
"text": tts_text,
"voice": xtts_voice,
"speed": xtts_speed,
"language": "de",
"requestId": xtts_request_id,
"messageId": message_id,
@ -1183,7 +1323,10 @@ class ARIABridge:
return
elif msg_type == "config":
# Konfiguration von App/Diagnostic empfangen + persistent speichern
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
# Felder die nicht direkt zur aria-bridge gehoeren (f5tts*) werden
# nur persistiert; die f5tts-bridge auf der Gamebox empfaengt den
# gleichen RVS-Broadcast und reagiert selber.
changed = False
if "ttsEnabled" in payload:
self.tts_enabled = bool(payload["ttsEnabled"])
@ -1195,11 +1338,21 @@ class ARIABridge:
changed = True
if "whisperModel" in payload:
new_model = payload["whisperModel"]
if new_model and new_model != self.stt_engine.model_size:
logger.info("[rvs] Whisper-Modell Wechsel: %s -> %s (laedt...)", self.stt_engine.model_size, new_model)
loop = asyncio.get_event_loop()
if await loop.run_in_executor(None, self.stt_engine.reload, new_model):
changed = True
allowed = {"tiny", "base", "small", "medium", "large-v3"}
if new_model in allowed and new_model != self.stt_engine.model_size:
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt Gamebox)",
new_model)
self.stt_engine.model_size = new_model
self.stt_engine.model = None
changed = True
# F5-TTS-Felder: einfach persistieren, f5tts-bridge applied selber.
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
"f5ttsCfgStrength", "f5ttsNfeStep"):
if k in payload:
if not hasattr(self, "_f5tts_config"):
self._f5tts_config = {}
self._f5tts_config[k] = payload[k]
changed = True
# Persistent speichern in Shared Volume
if changed:
try:
@ -1209,6 +1362,7 @@ class ARIABridge:
"xttsVoice": getattr(self, "xtts_voice", ""),
"whisperModel": self.stt_engine.model_size,
}
config_data.update(getattr(self, "_f5tts_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)
@ -1248,70 +1402,54 @@ class ARIABridge:
await self.ws_core.send(raw_message)
elif msg_type == "file":
# Datei von der App → als Text-Nachricht an aria-core
# Datei von der App: speichern + zu Pending-Queue hinzufuegen.
# Wird mit dem nachfolgenden chat-Event (innerhalb PENDING_FILES_WINDOW)
# zu einer einzigen aria-core-Anfrage gemerged. Sonst antwortet ARIA
# zweimal: einmal "warte auf Anweisung" beim file, einmal auf den Chat.
file_name = payload.get("name", "unbekannt")
file_type = payload.get("type", "")
file_b64 = payload.get("base64", "")
file_size = payload.get("size", 0)
width = payload.get("width", 0)
height = payload.get("height", 0)
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
file_name, file_type, len(file_b64) // 1365 if file_b64 else 0)
# Shared Volume: /shared/ ist in Bridge UND aria-core gemountet
SHARED_DIR = "/shared/uploads"
os.makedirs(SHARED_DIR, exist_ok=True)
if file_b64 and file_type.startswith("image/"):
# Bild in Shared Volume speichern
if not file_b64:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
return
if file_type.startswith("image/"):
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden (wichtigster Schritt)
text = (f"Stefan hat dir ein Bild geschickt: {file_name}"
f"{f' ({width}x{height}px)' if width else ''}"
f", {size_kb}KB."
f" Das Bild liegt unter: {file_path}"
f" Warte auf Stefans Anweisung was du damit tun sollst.")
await self.send_to_core(text, source="app-file")
# Dann App informieren (optional, darf nicht crashen)
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif file_b64:
# Andere Datei in Shared Volume speichern
else:
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden
text = (f"Stefan hat dir eine Datei geschickt: {file_name}"
f" ({file_type}, {size_kb}KB)."
f" Die Datei liegt unter: {file_path}"
f" Warte auf Stefans Anweisung was du damit tun sollst.")
await self.send_to_core(text, source="app-file")
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
else:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
self._pending_files_flush_task.cancel()
self._pending_files_flush_task = asyncio.create_task(
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
)
try:
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
@ -1350,31 +1488,166 @@ class ARIABridge:
if not audio_b64:
logger.warning("[rvs] Audio ohne Daten empfangen")
return
# Voice-Override fuer die kommende ARIA-Antwort (App-lokal gewaehlt)
voice_override = payload.get("voice", "")
if voice_override:
self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override (via Audio): %s", voice_override)
# Voice-Override fuer Folgenachrichten — gleiche Semantik wie beim chat-Event.
if "voice" in payload:
voice_override = payload.get("voice", "") or ""
self._next_voice_override = voice_override or None
logger.info("[rvs] Voice fuer Antworten (via Audio): %s",
self._next_voice_override or "(Default)")
if "speed" in payload:
try:
speed = float(payload.get("speed", 0) or 0)
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
except (TypeError, ValueError):
self._next_speed_override = None
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB",
mime_type, duration_ms, len(audio_b64) // 1365)
asyncio.create_task(self._process_app_audio(audio_b64, mime_type))
elif msg_type == "stt_response":
# Antwort der whisper-bridge auf unseren stt_request
request_id = payload.get("requestId", "")
future = self._pending_stt.get(request_id)
if future is None or future.done():
return
error = payload.get("error", "")
if error:
logger.warning("[rvs] stt_response Fehler: %s", error)
future.set_result(None)
else:
text = payload.get("text", "")
stt_ms = payload.get("sttMs", 0)
model = payload.get("model", "?")
logger.info("[rvs] Remote-STT OK (%s, %dms): '%s'", model, stt_ms, (text or "")[:80])
future.set_result(text)
return
elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
svc = payload.get("service", "")
state = payload.get("state", "")
if svc == "whisper":
was_ready = self._remote_stt_ready
self._remote_stt_ready = (state == "ready")
if self._remote_stt_ready != was_ready:
logger.info("[rvs] whisper-bridge -> %s", state)
return
elif msg_type == "config_request":
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
# Config — passiert wenn sie sich connected, weil sie sonst die
# Diagnostic-Settings nicht kennt. Wir broadcasten die persistierte
# Config (auch beim normalen Connect von aria-bridge selber, aber
# da war eventuell die andere Bridge noch nicht connected).
requester = payload.get("service", "?")
logger.info("[rvs] config_request von %s — broadcaste Voice-Config", requester)
asyncio.create_task(self._broadcast_persisted_config())
return
else:
logger.debug("[rvs] Unbekannter Typ: %s", msg_type)
# STT-Orchestrierung: zuerst Remote (Gamebox), Fallback lokal.
# Zwei Timeouts:
# ready=True → 45s reicht selbst fuer lange Audios
# ready=False → 300s, weil das Modell evtl. noch heruntergeladen wird
# (large-v3 ~3GB, kann auf der Gamebox 1-2 Min dauern).
_STT_REMOTE_TIMEOUT_READY_S = 45.0
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0
async def _process_app_audio(self, audio_b64: str, mime_type: str) -> None:
"""Decodiert App-Audio (Base64 AAC/MP4), konvertiert zu 16kHz PCM, STT, sendet an core."""
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal."""
# Erst Remote versuchen
text = await self._stt_remote(audio_b64, mime_type)
if text is None:
# Remote hat nicht geantwortet → lokales Whisper
logger.warning("[rvs] Remote-STT nicht verfuegbar — Fallback auf lokales Whisper")
text = await self._stt_local(audio_b64, mime_type)
if text is None:
return
if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(text, source="app-voice")
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop)
try:
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": text,
"sender": "stt",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
async def _stt_remote(self, audio_b64: str, mime_type: str) -> Optional[str]:
"""Schickt Audio an die whisper-bridge und wartet auf stt_response.
Rueckgabe:
str erkannter Text (kann leer sein)
None Remote-STT nicht erreichbar oder Fehler/Timeout ( Fallback)
"""
if self.ws_rvs is None:
return None
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_stt[request_id] = future
try:
model = getattr(self.stt_engine, "model_size", "small")
logger.info("[rvs] stt_request → whisper-bridge (id=%s, model=%s, %dKB)",
request_id[:8], model, len(audio_b64) // 1365)
ok = await self._send_to_rvs({
"type": "stt_request",
"payload": {
"requestId": request_id,
"audio": audio_b64,
"mimeType": mime_type,
"model": model,
"language": getattr(self.stt_engine, "language", "de"),
},
"timestamp": int(loop.time() * 1000),
})
if not ok:
logger.warning("[rvs] stt_request konnte nicht gesendet werden — skip Remote")
return None
timeout_s = (self._STT_REMOTE_TIMEOUT_READY_S
if self._remote_stt_ready
else self._STT_REMOTE_TIMEOUT_LOADING_S)
logger.info("[rvs] STT-Timeout %ds (whisper-bridge %s)",
int(timeout_s), "ready" if self._remote_stt_ready else "loading")
return await asyncio.wait_for(future, timeout=timeout_s)
except asyncio.TimeoutError:
logger.warning("[rvs] Remote-STT Timeout (%.0fs)", self._STT_REMOTE_TIMEOUT_S)
return None
except Exception as e:
logger.warning("[rvs] Remote-STT Fehler: %s", e)
return None
finally:
self._pending_stt.pop(request_id, None)
async def _stt_local(self, audio_b64: str, mime_type: str) -> Optional[str]:
"""Lokales Whisper-Fallback: FFmpeg → float32 → stt_engine.transcribe."""
loop = asyncio.get_event_loop()
tmp_in = None
tmp_out = None
try:
# Base64 → temp-Datei
ext = ".mp4" if "mp4" in mime_type else ".wav" if "wav" in mime_type else ".ogg"
tmp_in = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
tmp_in.write(base64.b64decode(audio_b64))
tmp_in.close()
# FFmpeg: beliebiges Format → 16kHz mono PCM (raw float32)
tmp_out = tempfile.NamedTemporaryFile(suffix=".raw", delete=False)
tmp_out.close()
@ -1389,55 +1662,34 @@ class ARIABridge:
)
if result.returncode != 0:
logger.error("[rvs] FFmpeg Fehler: %s", result.stderr.decode()[:200])
return
return None
# PCM lesen → numpy float32
audio_data = np.fromfile(tmp_out.name, dtype=np.float32)
if len(audio_data) == 0:
logger.warning("[rvs] Leere Audio-Daten nach Konvertierung")
return
return None
duration_s = len(audio_data) / 16000.0
logger.info("[rvs] Audio konvertiert: %.1fs, %d samples", duration_s, len(audio_data))
# STT
text = await loop.run_in_executor(None, self.stt_engine.transcribe, audio_data)
if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(text, source="app-voice")
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop)
try:
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": text,
"sender": "stt",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
logger.info("[rvs] Lokal-STT: %.1fs Audio, model=%s", duration_s, self.stt_engine.model_size)
return await loop.run_in_executor(None, self.stt_engine.transcribe, audio_data)
except Exception:
logger.exception("[rvs] Audio-Verarbeitung fehlgeschlagen")
logger.exception("[rvs] Lokales STT fehlgeschlagen")
return None
finally:
# Temp-Dateien aufraeumen
for f in [tmp_in, tmp_out]:
for f in (tmp_in, tmp_out):
if f:
try:
os.unlink(f.name)
except OSError:
pass
async def _send_to_rvs(self, message: dict) -> None:
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check."""
async def _send_to_rvs(self, message: dict) -> bool:
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check.
Rueckgabe: True wenn erfolgreich gesendet, False wenn Verbindung tot.
"""
if self.ws_rvs is None:
return
return False
# Ping-Check: Verbindung wirklich aktiv?
try:
@ -1451,12 +1703,14 @@ class ARIABridge:
pass
self.ws_rvs = None
# Reconnect wird vom connect_to_rvs Loop uebernommen
return
return False
try:
await self.ws_rvs.send(json.dumps(message))
return True
except Exception:
logger.warning("[rvs] Sendefehler — RVS nicht erreichbar")
return False
# ── Log-Streaming an die App ─────────────────────────────

16
cleanup-windows.bat Normal file
View File

@ -0,0 +1,16 @@
@echo off
REM ================================================================
REM ARIA - Cleanup-Wrapper fuer Windows
REM ================================================================
REM Ruft cleanup-windows.ps1 mit ExecutionPolicy Bypass auf.
REM Funktioniert auch wenn Windows .ps1 direkt nicht startet.
REM
REM Nutzung:
REM cleanup-windows.bat stefan
REM cleanup-windows.bat stefan -SkipPrune
REM
REM Doppelklick funktioniert NICHT (braucht Username als Param).
REM Per Konsole aufrufen.
REM ================================================================
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0cleanup-windows.ps1" %*

184
cleanup-windows.ps1 Normal file
View File

@ -0,0 +1,184 @@
# ================================================================
# ARIA - Windows / WSL2 / Docker Desktop VHDX Cleanup
# ================================================================
#
# Findet alle WSL2 + Docker Desktop ext4.vhdx Files unter
# C:\Users\<USER>\AppData\Local\... und kompaktiert sie via diskpart.
# Damit bekommst du Speicherplatz zurueck den du IN den Distros/
# Containern geloescht hast (z.B. nach `docker system prune`),
# der aber von der VHDX bisher nicht freigegeben wurde.
#
# Nutzung (PowerShell als ADMIN, oder via cleanup-windows.bat):
# .\cleanup-windows.ps1 stefan
# .\cleanup-windows.ps1 -User stefan
# .\cleanup-windows.ps1 -User stefan -SkipPrune # nur compacten
# .\cleanup-windows.ps1 -User stefan -PruneOnly # nur prune
#
# Was passiert:
# 1. Erst (optional): docker system prune + builder prune in WSL2
# 2. wsl --shutdown
# 3. Alle gefundenen .vhdx Files mit diskpart compact vdisk shrinken
#
# Hinweis: diskpart braucht KEINE Hyper-V Tools (anders als Optimize-VHD).
#
# ASCII-only damit Windows-PowerShell 5.1 das File ohne BOM korrekt
# parsen kann (UTF-8-Sonderzeichen wuerden sonst als Windows-1252
# fehlinterpretiert).
# ================================================================
[CmdletBinding()]
param(
[Parameter(Mandatory=$true, Position=0,
HelpMessage="Dein Windows-Benutzername (z.B. stefan)")]
[string]$User,
[Parameter(HelpMessage="Docker prune ueberspringen - nur compacten")]
[switch]$SkipPrune,
[Parameter(HelpMessage="Docker prune NUR machen, dann beenden")]
[switch]$PruneOnly
)
# Defensive: Process-Scope ExecutionPolicy auf Bypass - verhindert dass
# Untersaetze (z.B. Module) blockiert werden. Harmless wenn Parent schon
# Bypass aufgerufen hat.
try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force | Out-Null } catch {}
# Admin-Check + Self-Elevation
# Wenn nicht als Admin gestartet -> einmal neu starten als Admin, mit
# ExecutionPolicy Bypass + den Original-Argumenten. User muss nur einmal
# UAC-Prompt bestaetigen.
$isAdmin = ([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host "-> Starte neu als Administrator (mit ExecutionPolicy Bypass)..." -ForegroundColor Yellow
$myPath = $MyInvocation.MyCommand.Path
$forwardArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$myPath`"")
if ($User) { $forwardArgs += @("-User", $User) }
if ($SkipPrune) { $forwardArgs += "-SkipPrune" }
if ($PruneOnly) { $forwardArgs += "-PruneOnly" }
try {
Start-Process powershell.exe -Verb RunAs -ArgumentList $forwardArgs
} catch {
Write-Host "[FAIL] UAC-Elevation abgebrochen oder fehlgeschlagen." -ForegroundColor Red
Write-Host " Rechtsklick auf PowerShell -> 'Als Administrator ausfuehren'" -ForegroundColor Yellow
exit 1
}
exit 0
}
$basePath = "C:\Users\$User\AppData\Local"
if (-not (Test-Path $basePath)) {
Write-Host "[FAIL] Pfad existiert nicht: $basePath" -ForegroundColor Red
Write-Host " Pruefe den Benutzernamen." -ForegroundColor Yellow
exit 1
}
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host " ARIA Cleanup fuer User: $User" -ForegroundColor Cyan
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host ""
# -- 1. Docker prune (in WSL2) -----------------------------------
if (-not $SkipPrune) {
Write-Host "[1/3] Docker Cleanup in WSL2..." -ForegroundColor Yellow
Write-Host " docker system prune -a --volumes -f" -ForegroundColor Gray
Write-Host " docker builder prune -a -f" -ForegroundColor Gray
Write-Host ""
try {
wsl -e bash -c "docker system prune -a --volumes -f && docker builder prune -a -f"
Write-Host " [OK] fertig" -ForegroundColor Green
} catch {
Write-Host " [WARN] Docker prune fehlgeschlagen (vielleicht laeuft Docker Desktop nicht?)" -ForegroundColor Yellow
Write-Host " $_" -ForegroundColor Gray
}
Write-Host ""
if ($PruneOnly) {
Write-Host "PruneOnly gesetzt - fertig." -ForegroundColor Cyan
exit 0
}
}
# -- 2. WSL2 shutdown --------------------------------------------
Write-Host "[2/3] WSL2 herunterfahren..." -ForegroundColor Yellow
wsl --shutdown
Start-Sleep -Seconds 3
Write-Host " [OK] fertig" -ForegroundColor Green
Write-Host ""
# -- 3. VHDX-Files finden + compacten ----------------------------
Write-Host "[3/3] VHDX-Files suchen + compacten..." -ForegroundColor Yellow
Write-Host ""
$vhdxFiles = @()
$vhdxFiles += Get-ChildItem -Path "$basePath\Docker" -Recurse -Filter "*.vhdx" -ErrorAction SilentlyContinue
$vhdxFiles += Get-ChildItem -Path "$basePath\Packages" -Recurse -Filter "ext4.vhdx" -ErrorAction SilentlyContinue
$vhdxFiles = $vhdxFiles | Sort-Object FullName -Unique
if ($vhdxFiles.Count -eq 0) {
Write-Host " Keine .vhdx Files gefunden." -ForegroundColor Yellow
exit 0
}
Write-Host "Gefundene Files (vorher):" -ForegroundColor Cyan
foreach ($f in $vhdxFiles) {
$sizeGB = [math]::Round($f.Length / 1GB, 2)
Write-Host (" {0,8} GB {1}" -f $sizeGB, $f.FullName) -ForegroundColor Gray
}
Write-Host ""
$totalBefore = ($vhdxFiles | Measure-Object Length -Sum).Sum
foreach ($f in $vhdxFiles) {
Write-Host "-> Compact: $($f.FullName)" -ForegroundColor White
$sizeBefore = [math]::Round($f.Length / 1GB, 2)
# Temporaeres diskpart-Script schreiben
$tmp = [System.IO.Path]::GetTempFileName()
@"
select vdisk file="$($f.FullName)"
attach vdisk readonly
compact vdisk
detach vdisk
exit
"@ | Out-File -Encoding ASCII -FilePath $tmp
try {
$output = & diskpart /s $tmp 2>&1
# Datei neu lesen - Length ist gecacht
$newFile = Get-Item $f.FullName
$sizeAfter = [math]::Round($newFile.Length / 1GB, 2)
$saved = [math]::Round($sizeBefore - $sizeAfter, 2)
if ($saved -gt 0) {
Write-Host (" [OK] {0} GB -> {1} GB (gespart: {2} GB)" -f $sizeBefore, $sizeAfter, $saved) -ForegroundColor Green
} else {
Write-Host (" -- {0} GB -> {1} GB (nichts zu holen - File war schon optimal)" -f $sizeBefore, $sizeAfter) -ForegroundColor DarkGray
}
} catch {
Write-Host " [FAIL] Fehler: $_" -ForegroundColor Red
Write-Host " diskpart-Output:" -ForegroundColor DarkGray
$output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
} finally {
Remove-Item $tmp -ErrorAction SilentlyContinue
}
Write-Host ""
}
# -- Zusammenfassung ---------------------------------------------
$vhdxFilesAfter = @()
$vhdxFilesAfter += Get-ChildItem -Path "$basePath\Docker" -Recurse -Filter "*.vhdx" -ErrorAction SilentlyContinue
$vhdxFilesAfter += Get-ChildItem -Path "$basePath\Packages" -Recurse -Filter "ext4.vhdx" -ErrorAction SilentlyContinue
$vhdxFilesAfter = $vhdxFilesAfter | Sort-Object FullName -Unique
$totalAfter = ($vhdxFilesAfter | Measure-Object Length -Sum).Sum
$savedTotal = [math]::Round(($totalBefore - $totalAfter) / 1GB, 2)
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host (" Gesamt: {0} GB -> {1} GB (gespart: {2} GB)" -f `
[math]::Round($totalBefore / 1GB, 2),
[math]::Round($totalAfter / 1GB, 2),
$savedTotal) -ForegroundColor Cyan
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Fertig. Docker Desktop / WSL2 starten ja von alleine wieder beim naechsten Aufruf." -ForegroundColor Green

View File

@ -127,6 +127,43 @@
</style>
</head>
<body>
<!-- Service-Status Banner unten rechts (Gamebox: F5-TTS / Whisper Lade-Status) -->
<div id="service-status-banner" style="display:none;position:fixed;bottom:16px;right:16px;z-index:999;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:8px;padding:10px 14px;font-size:12px;color:#fff;min-width:240px;max-width:360px;box-shadow:0 4px 14px rgba(0,0,0,0.5);">
<div style="display:flex;align-items:flex-start;gap:8px;">
<span id="service-status-icon" style="font-size:18px;line-height:1;">&#x23F3;</span>
<div id="service-status-list" style="flex:1;display:flex;flex-direction:column;gap:6px;"></div>
<button id="service-status-close" onclick="document.getElementById('service-status-banner').style.display='none'" style="background:none;border:none;color:#666680;font-size:16px;cursor:pointer;padding:0;line-height:1;display:none;">&times;</button>
</div>
</div>
<!-- Voice-Preview Modal -->
<div id="voice-preview-modal" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.7);align-items:center;justify-content:center;">
<div style="background:#1A1A2E;border:1px solid #2A2A3E;border-radius:10px;padding:20px;max-width:560px;width:90%;display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<h3 style="margin:0;color:#fff;">Stimmen-Preview: <span id="voice-preview-name"></span></h3>
<button onclick="closeVoicePreview()" style="background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">&times;</button>
</div>
<textarea id="voice-preview-text" rows="4"
style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px;color:#fff;font-size:13px;resize:vertical;"></textarea>
<div style="display:flex;align-items:center;gap:10px;font-size:12px;color:#8888AA;">
<span style="min-width:120px;">Geschwindigkeit:</span>
<button onclick="adjustPreviewSpeed(-0.1)" class="btn secondary" style="padding:4px 10px;font-size:12px;">0.1</button>
<span id="voice-preview-speed-value" style="min-width:52px;text-align:center;color:#fff;font-weight:600;">1.0 x</span>
<button onclick="adjustPreviewSpeed(0.1)" class="btn secondary" style="padding:4px 10px;font-size:12px;">+0.1</button>
<span style="color:#555570;font-size:11px;">(nur fuer dieses Modal, wird nicht gespeichert)</span>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button id="voice-preview-play" onclick="playVoicePreview()" class="btn primary" style="padding:8px 16px;">
▶ Abspielen
</button>
<span id="voice-preview-status" style="color:#8888AA;font-size:11px;flex:1;"></span>
</div>
<audio id="voice-preview-audio" controls style="width:100%;display:none;"></audio>
</div>
</div>
<!-- Disk-Space Warnung (dynamisch gesetzt) -->
<div id="disk-banner" style="display:none;position:sticky;top:0;z-index:500;padding:10px 14px;border-radius:0;margin:-16px -16px 12px -16px;font-size:13px;">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
@ -437,11 +474,11 @@
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
</div>
<!-- XTTS Stimme -->
<!-- F5-TTS Stimme (zwingend eine Voice waehlen — F5-TTS braucht eine Referenz) -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
<label style="color:#8888AA;font-size:12px;">XTTS Stimme:</label>
<label style="color:#8888AA;font-size:12px;">F5-TTS Stimme:</label>
<select id="diag-xtts-voice" onchange="sendVoiceConfig()" style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<option value="">Standard (XTTS Default)</option>
<option value="" disabled>(keine Stimme gewaehlt)</option>
</select>
<button class="btn secondary" onclick="loadXTTSVoices()" style="padding:4px 10px;font-size:11px;">Laden</button>
</div>
@ -450,6 +487,60 @@
<!-- Gecloned Stimmen — Liste mit Loeschen -->
<div id="xtts-voice-list" style="margin-bottom:12px;"></div>
<!-- F5-TTS Modell-Tuning -->
<details style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;margin-bottom:12px;">
<summary style="color:#8888AA;font-size:12px;cursor:pointer;">F5-TTS Modell-Tuning (advanced)</summary>
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
<div style="color:#8888AA;font-size:11px;">
Werden via RVS an die f5tts-bridge auf der Gamebox geschickt.
Modell-/Checkpoint-Wechsel triggert einen Reload (~30s).
Hardcoded Defaults: F5TTS_v1_Base, cfg_strength=2.5, nfe_step=32.
</div>
<label style="color:#8888AA;font-size:12px;">
Modell-Architektur (F5TTS_v1_Base = Default multilingual, F5TTS_Base = fuer die meisten Fine-Tunes):
</label>
<input type="text" id="diag-f5tts-model"
placeholder="F5TTS_v1_Base"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<label style="color:#8888AA;font-size:12px;">
Custom Checkpoint — HF-Pfad (hf://user/repo/file) oder lokaler Container-Pfad. Leer = Default.
</label>
<input type="text" id="diag-f5tts-ckpt"
placeholder="z.B. hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<label style="color:#8888AA;font-size:12px;">
Custom Vocab — muss zum Checkpoint passen. Leer = Default.
</label>
<input type="text" id="diag-f5tts-vocab"
placeholder="z.B. hf://aihpi/F5-TTS-German/vocab.txt"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<div style="display:flex;gap:12px;">
<div style="flex:1;">
<label style="color:#8888AA;font-size:12px;">cfg_strength (1.0 - 5.0):</label>
<input type="number" id="diag-f5tts-cfg" step="0.1" min="1" max="5"
placeholder="2.5"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;width:100%;box-sizing:border-box;">
<div style="color:#666680;font-size:10px;">Hoeher = klebt staerker an Referenz</div>
</div>
<div style="flex:1;">
<label style="color:#8888AA;font-size:12px;">nfe_step (8 - 64):</label>
<input type="number" id="diag-f5tts-nfe" step="1" min="8" max="64"
placeholder="32"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;width:100%;box-sizing:border-box;">
<div style="color:#666680;font-size:10px;">Hoeher = bessere Qualitaet, langsamer</div>
</div>
</div>
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;align-self:flex-start;margin-top:6px;">
Anwenden
</button>
</div>
</details>
<!-- Voice Cloning -->
<div style="background:#1E1E2E;border-radius:8px;padding:12px;margin-top:8px;">
<div style="color:#0096FF;font-size:13px;font-weight:600;margin-bottom:8px;">Stimme klonen</div>
@ -841,6 +932,16 @@
const wSel = document.getElementById('diag-whisper-model');
if (wSel) wSel.value = msg.whisperModel;
}
// F5-TTS Tuning-Felder wiederherstellen (falls gesetzt)
const setIfPresent = (id, val) => {
const el = document.getElementById(id);
if (el && val !== undefined && val !== null && val !== '') el.value = val;
};
setIfPresent('diag-f5tts-model', msg.f5ttsModel);
setIfPresent('diag-f5tts-ckpt', msg.f5ttsCkptFile);
setIfPresent('diag-f5tts-vocab', msg.f5ttsVocabFile);
setIfPresent('diag-f5tts-cfg', msg.f5ttsCfgStrength);
setIfPresent('diag-f5tts-nfe', msg.f5ttsNfeStep);
return;
}
@ -852,6 +953,29 @@
return;
}
if (msg.type === 'service_status') {
updateServiceStatus(msg.payload || {});
return;
}
if (msg.type === 'voice_preview_audio') {
const statusEl = document.getElementById('voice-preview-status');
const audio = document.getElementById('voice-preview-audio');
const playBtn = document.getElementById('voice-preview-play');
if (playBtn) playBtn.disabled = false;
if (msg.error) {
if (statusEl) statusEl.textContent = '❌ Fehler: ' + msg.error;
return;
}
if (msg.base64 && audio) {
audio.src = 'data:audio/wav;base64,' + msg.base64;
audio.style.display = 'block';
audio.play().catch(() => {});
if (statusEl) statusEl.textContent = '✅ fertig';
}
return;
}
if (msg.type === 'voice_ready') {
const v = msg.payload?.voice || '';
const err = msg.payload?.error;
@ -1390,6 +1514,68 @@
'Glob': '\uD83D\uDCC1 Dateien suchen',
'Agent': '\uD83E\uDD16 Sub-Agent',
};
// ── Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) ──
// Aggregiert die Status-Infos der Bridges. Wenn irgendwas am Laden
// ist, zeigt das Banner unten rechts. Sobald alles auf 'ready' ist,
// bleibt's einen Moment und wird dann vom User weggeklickt (oder
// nach 8s automatisch).
const _serviceState = {}; // { f5tts: {state, model, ...}, whisper: {...} }
let _serviceFadeTimer = null;
function updateServiceStatus(p) {
const svc = p.service || '?';
_serviceState[svc] = p;
const banner = document.getElementById('service-status-banner');
const list = document.getElementById('service-status-list');
const icon = document.getElementById('service-status-icon');
const closeBtn = document.getElementById('service-status-close');
// Liste neu aufbauen
list.innerHTML = '';
let anyLoading = false, anyError = false;
const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT' };
for (const [s, info] of Object.entries(_serviceState)) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;';
let dot = '⚫', color = '#666680', text = '';
if (info.state === 'loading') {
dot = '⏳'; color = '#FFD60A'; anyLoading = true;
text = `${labels[s] || s}: laedt${info.model ? ' ' + info.model : ''}...`;
} else if (info.state === 'ready') {
dot = '✅'; color = '#34C759';
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
text = `${labels[s] || s}: bereit${info.model ? ' ' + info.model : ''}${sec}`;
} else if (info.state === 'error') {
dot = '❌'; color = '#FF3B30'; anyError = true;
text = `${labels[s] || s}: Fehler ${info.error || ''}`;
} else {
text = `${labels[s] || s}: ${info.state}`;
}
row.innerHTML = `<span style="color:${color}">${dot}</span><span>${text}</span>`;
list.appendChild(row);
}
// Icon spiegelt Gesamt-Status
if (anyError) icon.innerHTML = '&#x274C;';
else if (anyLoading) icon.innerHTML = '&#x23F3;';
else icon.innerHTML = '&#x2705;';
banner.style.display = 'block';
// Wenn alles ready (kein Loading, kein Error): X-Button anzeigen
// + nach 8s automatisch wegfaden
if (!anyLoading && !anyError) {
closeBtn.style.display = 'block';
clearTimeout(_serviceFadeTimer);
_serviceFadeTimer = setTimeout(() => {
banner.style.display = 'none';
}, 8000);
} else {
closeBtn.style.display = 'none';
clearTimeout(_serviceFadeTimer);
}
}
function updateThinkingIndicator(msg) {
const indicators = [
document.getElementById('thinking-indicator'),
@ -1439,16 +1625,75 @@
html += '<div style="display:flex;flex-direction:column;gap:4px;">';
for (const v of voices) {
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[c]));
const jsName = esc(v.name).replace(/'/g, "\\'");
html += `<div style="display:flex;align-items:center;gap:8px;background:#1E1E2E;border-radius:4px;padding:4px 8px;font-size:12px;">`
+ `<span style="flex:1;color:#E0E0F0;">${esc(v.name)}</span>`
+ `<span style="color:#555570;font-size:10px;">${(v.size/1024).toFixed(0)}KB</span>`
+ `<button class="btn secondary" onclick="deleteXttsVoice('${esc(v.name).replace(/'/g, "\\'")}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;" title="Stimme loeschen">X</button>`
+ `<button class="btn secondary" onclick="openVoicePreview('${jsName}')" style="padding:2px 8px;font-size:12px;" title="Stimme anhoeren"></button>`
+ `<button class="btn secondary" onclick="deleteXttsVoice('${jsName}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;" title="Stimme loeschen">X</button>`
+ `</div>`;
}
html += '</div>';
box.innerHTML = html;
}
// ── Voice Preview Modal ─────────────────────────
const VOICE_PREVIEW_DEFAULT = 'Hallo, ich bin ARIA. Das hier ist ein kleiner Test damit du meine Stimme beurteilen kannst.';
const PREVIEW_SPEED_DEFAULT = 1.0;
const PREVIEW_SPEED_MIN = 0.1;
const PREVIEW_SPEED_MAX = 5.0;
let currentPreviewVoice = '';
let currentPreviewSpeed = PREVIEW_SPEED_DEFAULT;
function _refreshPreviewSpeedLabel() {
const el = document.getElementById('voice-preview-speed-value');
if (el) el.textContent = currentPreviewSpeed.toFixed(1) + ' x';
}
function adjustPreviewSpeed(delta) {
const next = Math.round((currentPreviewSpeed + delta) * 10) / 10;
if (next < PREVIEW_SPEED_MIN || next > PREVIEW_SPEED_MAX) return;
currentPreviewSpeed = next;
_refreshPreviewSpeedLabel();
}
function openVoicePreview(name) {
currentPreviewVoice = name;
// Speed bei jedem Oeffnen zuruecksetzen — bewusst kein persist
currentPreviewSpeed = PREVIEW_SPEED_DEFAULT;
_refreshPreviewSpeedLabel();
document.getElementById('voice-preview-name').textContent = name;
// Text bei jedem Oeffnen zuruecksetzen
document.getElementById('voice-preview-text').value = VOICE_PREVIEW_DEFAULT;
document.getElementById('voice-preview-status').textContent = '';
const audio = document.getElementById('voice-preview-audio');
audio.style.display = 'none';
audio.src = '';
document.getElementById('voice-preview-modal').style.display = 'flex';
}
function closeVoicePreview() {
document.getElementById('voice-preview-modal').style.display = 'none';
const audio = document.getElementById('voice-preview-audio');
try { audio.pause(); } catch {}
}
function playVoicePreview() {
const text = (document.getElementById('voice-preview-text').value || '').trim();
if (!text) {
document.getElementById('voice-preview-status').textContent = 'Text leer';
return;
}
document.getElementById('voice-preview-status').textContent = '⏳ Rendere...';
document.getElementById('voice-preview-play').disabled = true;
send({
action: 'preview_voice',
voice: currentPreviewVoice,
text,
speed: currentPreviewSpeed,
});
}
function deleteXttsVoice(name) {
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
send({ action: 'xtts_delete_voice', name });
@ -1570,7 +1815,19 @@
const ttsEnabled = document.getElementById('diag-tts-enabled').checked;
const xttsVoice = document.getElementById('diag-xtts-voice').value;
const whisperModel = document.getElementById('diag-whisper-model').value;
send({ action: 'send_voice_config', ttsEnabled, xttsVoice, whisperModel });
const f5ttsModel = document.getElementById('diag-f5tts-model')?.value || '';
const f5ttsCkptFile = document.getElementById('diag-f5tts-ckpt')?.value || '';
const f5ttsVocabFile = document.getElementById('diag-f5tts-vocab')?.value || '';
const f5ttsCfgRaw = document.getElementById('diag-f5tts-cfg')?.value || '';
const f5ttsNfeRaw = document.getElementById('diag-f5tts-nfe')?.value || '';
const f5ttsCfgStrength = f5ttsCfgRaw ? parseFloat(f5ttsCfgRaw) : undefined;
const f5ttsNfeStep = f5ttsNfeRaw ? parseInt(f5ttsNfeRaw, 10) : undefined;
send({
action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep,
});
const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) {
statusEl.textContent = `⏳ Stimme "${xttsVoice}" wird geladen...`;

View File

@ -637,6 +637,25 @@ function connectRVS(forcePlain) {
log("info", "rvs", `Voice "${v || "default"}" geladen${ms ? ` in ${(ms/1000).toFixed(1)}s` : ""}`);
}
broadcast({ type: "voice_ready", payload: msg.payload });
} else if (msg.type === "service_status") {
// Gamebox-Bridges (f5tts/whisper) melden ihren Lade-Status —
// an Browser durchreichen fuer das Banner unten rechts
const svc = msg.payload?.service || "?";
const state = msg.payload?.state || "?";
const model = msg.payload?.model || "";
const sec = msg.payload?.loadSeconds;
const err = msg.payload?.error;
if (err) {
log("warn", "rvs", `service_status ${svc}: ${err}`);
} else if (state === "ready" && sec) {
log("info", "rvs", `service_status ${svc} ready (${model}, ${sec.toFixed(1)}s)`);
} else {
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
}
broadcast({ type: "service_status", payload: msg.payload });
} else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) {
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
_handlePreviewChunk(msg.payload);
} else {
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
}
@ -1423,6 +1442,20 @@ wss.on("connection", (ws) => {
xttsVoice: msg.xttsVoice || "",
};
if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel;
// F5-TTS Tuning-Felder — immer mit dem vom User gesendeten Wert setzen,
// auch leeren String. Leer = "reset auf Hard-Default". Sonst merkt die
// Bridge nicht dass der User den Wert loeschen wollte (absent key war
// vorher 'keep current' semantik → BigVGAN blieb drin obwohl User
// leer eingetragen hatte).
if (msg.f5ttsModel !== undefined) voiceConfig.f5ttsModel = msg.f5ttsModel || "";
if (msg.f5ttsCkptFile !== undefined) voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile || "";
if (msg.f5ttsVocabFile !== undefined) voiceConfig.f5ttsVocabFile = msg.f5ttsVocabFile || "";
if (msg.f5ttsCfgStrength !== undefined && !isNaN(msg.f5ttsCfgStrength)) {
voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength;
}
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
}
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
@ -1435,6 +1468,8 @@ wss.on("connection", (ws) => {
handleSaveTriggers(ws, msg.triggers || []);
} else if (msg.action === "test_tts") {
handleTestTTS(ws, msg.text || "Test");
} else if (msg.action === "preview_voice") {
handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo.", msg.speed);
} else if (msg.action === "check_tts") {
handleCheckTTS(ws);
} else if (msg.action === "check_desktop") {
@ -1607,6 +1642,98 @@ async function handleSaveTriggers(clientWs, triggers) {
}
// ── TTS Diagnose (XTTS) ───────────────────────────────
// ── Voice Preview ────────────────────────────────────────
// Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV
// und schickt sie base64-kodiert an den Browser-Client.
//
// Map requestId → { clientWs, chunks: [Buffer], sampleRate, channels }
const _previewPending = new Map();
function _buildWavFromPcm(pcmBuf, sampleRate, channels) {
const bitsPerSample = 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const dataSize = pcmBuf.length;
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + dataSize, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16); // subchunk1 size
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write("data", 36);
header.writeUInt32LE(dataSize, 40);
return Buffer.concat([header, pcmBuf]);
}
function _handlePreviewChunk(payload) {
const reqId = payload?.requestId || "";
const entry = _previewPending.get(reqId);
if (!entry) return;
if (payload.base64) {
try { entry.chunks.push(Buffer.from(payload.base64, "base64")); } catch {}
}
if (!entry.sampleRate && payload.sampleRate) entry.sampleRate = payload.sampleRate;
if (!entry.channels && payload.channels) entry.channels = payload.channels;
if (payload.final) {
_previewPending.delete(reqId);
try {
const pcm = Buffer.concat(entry.chunks);
const wav = _buildWavFromPcm(pcm, entry.sampleRate || 24000, entry.channels || 1);
const b64 = wav.toString("base64");
if (entry.clientWs && entry.clientWs.readyState === 1) {
entry.clientWs.send(JSON.stringify({
type: "voice_preview_audio",
base64: b64,
size: wav.length,
}));
}
} catch (err) {
if (entry.clientWs && entry.clientWs.readyState === 1) {
entry.clientWs.send(JSON.stringify({
type: "voice_preview_audio",
error: err.message,
}));
}
}
}
}
async function handleVoicePreview(clientWs, voice, text, speed) {
try {
// Speed clampen — Browser-Slider ist 0.1-5.0
let spd = parseFloat(speed);
if (!isFinite(spd) || spd < 0.1 || spd > 5.0) spd = 1.0;
const requestId = crypto.randomUUID();
_previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 });
// Timeout safety net
setTimeout(() => {
if (_previewPending.has(requestId)) {
_previewPending.delete(requestId);
if (clientWs && clientWs.readyState === 1) {
clientWs.send(JSON.stringify({
type: "voice_preview_audio",
error: "Timeout (60s) — keine Antwort vom f5tts-bridge",
}));
}
}
}, 60000);
log("info", "server", `Voice-Preview: voice="${voice}" speed=${spd.toFixed(1)}x text="${text.slice(0, 60)}"`);
sendToRVS_raw({
type: "xtts_request",
payload: { text, language: "de", requestId, voice, speed: spd },
timestamp: Date.now(),
});
} catch (err) {
clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message }));
}
}
async function handleTestTTS(clientWs, text) {
try {
log("info", "server", `TTS-Test via XTTS: "${text}"`);

View File

@ -5,7 +5,7 @@
- [x] Bildupload funktioniert (Shared Volume /shared/uploads/)
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
- [x] Cache leeren + Auto-Download von Anhaengen
- [x] ARIA liest Nachrichten vor (TTS via Piper)
- [x] ARIA liest Nachrichten vor (TTS via Piper, später ersetzt)
- [x] Autoscroll zur letzten Nachricht (inverted FlatList)
- [x] Bilder im Chat groesser + Vollbild-Vorschau
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
@ -16,11 +16,11 @@
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
- [x] Grosse Nachrichten satzweise aufteilen fuer TTS
- [x] RVS Nachrichten vom Smartphone gehen durch
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme)
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme — durch XTTS/F5-TTS ersetzt)
- [x] Highlight-Trigger konfigurierbar in Diagnostic
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning)
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning) — durch F5-TTS ersetzt
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
- [x] TTS Engine waehlbar (Piper/XTTS) in Diagnostic + App
- [x] TTS Engine waehlbar (Piper/XTTS) — Piper raus, XTTS raus, jetzt nur F5-TTS
- [x] Auto-Update System (APK via RVS WebSocket)
- [x] Auto-Update: APK-Installation via FileProvider
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
@ -31,49 +31,78 @@
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt (verhindert dass Umgebungsgeraeusche an Whisper gehen)
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten (sessionFromFile-Flag, atomic write)
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen (pipelineEnd broadcastet immer idle, auch bei Timeout/Fehler/Disconnect)
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload in Bridge, Default auf medium
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
- [x] Streaming TTS (Weg A): XTTS → PCM-Stream → aria-bridge → App AudioTrack MODE_STREAM, keine WAV-Gaps mehr
- [x] Piper komplett entfernt: nur noch XTTS v2 als TTS-Engine (remote, GPU auf Gaming-PC). Wenn XTTS offline ist, ist ARIA stumm — bewusst akzeptiert.
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms) — keine Umgebungsgeraeusche mehr
- [x] Gespraechsmodus: Max-Dauer 30s pro Aufnahme, Cache-Cleanup alter Files, Messages-Array gekappt (500)
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) werden angezeigt + exportierbar — OpenClaw resettet Sessions bei erster Nutzung nach Container-Restart, Inhalt ist aber in .reset.<timestamp> Dateien gesichert
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer beliebige Session-JSONL zu Markdown
- [x] NO_REPLY-Filter in Bridge + Diagnostic — still verworfen (kein Chat, kein TTS)
- [x] Audio-Ducking + Exklusiv-Focus (Kotlin AudioFocusModule): andere Apps leiser bei TTS, pausiert bei Aufnahme
- [x] TTS-Cleanup serverseitig: Code-Bloecke raus, Einheiten ausgeschrieben (22GB → Gigabyte), Abkuerzungen buchstabiert (CPU), URLs zu "ein Link". `<voice></voice>` Tag wird bevorzugt wenn ARIA ihn liefert.
- [x] QR-Code Onboarding: Diagnostic generiert QR, App scannt (bestehender QRScanner funktioniert out of the box)
- [x] TTS-Audio-Cache im Filesystem: Piper-Audio wird mit messageId verknuepft, als WAV in DocumentDirectory/tts_cache gespeichert, Play-Button spielt aus Cache statt regenerieren
- [x] Config via Diagnostic: RVS-Credentials + Aria-Auth-Token via /api/runtime-config, persistiert in /shared/config/runtime.json, Bridge liest beim Start (Overrides der ENV)
- [x] Streaming TTS: PCM-Stream → AudioTrack MODE_STREAM, keine WAV-Gaps
- [x] Piper komplett entfernt
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms)
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) angezeigt + exportierbar
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer Session-JSONL zu Markdown
- [x] NO_REPLY-Filter in Bridge + Diagnostic
- [x] Audio-Ducking + Exklusiv-Focus (Kotlin AudioFocusModule)
- [x] TTS-Cleanup serverseitig: Code-Bloecke raus, Einheiten ausgeschrieben, Abkuerzungen buchstabiert, URLs zu "ein Link"
- [x] QR-Code Onboarding: Diagnostic generiert QR, App scannt
- [x] TTS-Audio-Cache im Filesystem: WAV pro messageId, Play-Button spielt aus Cache
- [x] Config via Diagnostic: RVS-Credentials + Auth-Token persistiert in /shared/config/runtime.json
- [x] Disk-Voll Banner in Diagnostic: rotes Overlay + copy-baren Cleanup-Befehlen (safe + aggressiv)
- [x] cleanup.sh: kombinierter Docker-Aufraeum-Befehl (safe / --full)
- [x] Streaming TTS Pre-Roll: AudioTrack play() startet erst wenn 2.5s gepuffert sind
- [x] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
- [x] Leading-Silence (200ms) am Stream-Anfang — AudioTrack faehrt sauber an
- [x] Pre-Roll-Buffer einstellbar in App-Settings (1.0-6.0s, Default 3.5s)
- [x] Fade-In auf erstem PCM-Chunk (120ms) — versteckt XTTS/F5-TTS Warmup-Glitches
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
- [x] Voice-Auswahl funktioniert wieder: speaker_wav als Basename statt Pfad fuer daswer123 local-Mode
- [x] Diagnostic-Voice-Wechsel resettet alle App-lokalen Voice-Overrides via type "config"
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
- [x] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s)
- [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
- [x] F5-TTS: Referenz-WAV-Preprocessing — Loudness-Normalisierung -16 LUFS + Silence-Trim + 10s Clip fuer konsistente Cloning-Quali
- [x] F5-TTS: deutsches Fine-Tune (aihpi/F5-TTS-German, Vocos-Variante) via hf:// Pfad in Diagnostic konfigurierbar
- [x] Whisper transkribiert Voice-Uploads nicht mehr mit hardcoded "small" — aktuelles Modell wird behalten, kein unnoetiger Modell-Swap
- [x] RVS/WebSocket maxPayload 50MB: voice_upload mit WAV als base64 sprengt kein Frame-Limit mehr
- [x] Dynamischer STT-Timeout in aria-bridge: 300s waehrend whisper-bridge 'loading', 45s wenn 'ready'
- [x] service_status Broadcasts: f5tts/whisper melden Lade-Status, Banner in Diagnostic (unten rechts) + App (oben)
- [x] config_request Pattern: Bridges fragen beim Connect die aktuelle Voice-Config an, aria-bridge antwortet
- [x] F5-TTS Tuning via Diagnostic (Modell-ID, Checkpoint, cfg_strength, nfe_step) statt ENV-Vars — Hot-Reload bei Modell-Wechsel
- [x] Conversation-Window: Gespraechsmodus endet nach X Sekunden Stille (1.0-20.0s, Default 8s, einstellbar in Settings)
- [x] Porcupine Wake-Word-Integration in der App (Built-In Keywords + Custom spaeter, per Geraet einstellbar)
- [x] HF-Cache als Bind-Mount statt Docker Volume — kein .vhdx-Bloat auf Docker Desktop / Windows
- [x] cleanup-windows.ps1 / .bat: VHDX-Cleanup via diskpart (ohne Hyper-V) mit Self-Elevation
- [x] App Mute-/Auto-Playback-Bug: Closure-Bug geloest (ttsCanPlayRef live-gespiegelt, nicht mehr stale)
- [x] App Zombie-Recording: Ohr-aus kill laufende Aufnahme damit der Aufnahme-Button weiter funktioniert
- [x] App Text-Rendering: Nachrichten selektierbar + Autolink fuer URLs/E-Mails/Telefonnummern (Browser/Mail/Dialer)
- [x] TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings → 0.5-2.0x in 0.1-Schritten, Default 1.0)
- [x] Diagnostic: Voice-Preview-Modal (Play-Icon vor Delete-X, Textfeld mit Default, WAV im Browser abspielen)
## Offen
### Bugs (Prioritaet)
- [ ] App: Audioausgabe hoert ab und zu einfach auf (mitten im Satz oder zwischen Chunks)
- [ ] NO_REPLY wird als "NO" im Chat angezeigt — sollte still verworfen werden (Token nicht gesaeubert)
### Bugs
- [ ] App: Wake-Word "jarvis" triggert nicht zuverlaessig (Porcupine-Debugging via ADB-Logcat ausstehend)
- [ ] App: Stuerzt beim Lauschen ab, eventuell bei Nebengeraeuschen (Porcupine + Mic-Race, errorCallback haelt's jetzt zurueck — Dauertest ausstehend)
### App Features
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Background Audio Service (TTS auch bei minimierter App)
- [ ] Audio-Ducking: andere App-Audio-Ausgaben leiser stellen waehrend ARIA spricht (AudioFocus API)
- [ ] Audio-Muten waehrend Aufnahme/Ohr-Modus: andere Audio stumm (wie WhatsApp-Sprachaufnahme)
- [ ] Spracheingabe-Timeout erhoehen fuer laengere Texte
- [ ] Generierte TTS-Audiodaten in der Chat-Nachricht einbetten (oder lokal cachen), Play-Button spielt aus Cache statt Regenerierung via XTTS. Base64 im Tag <soundfile></soundfile> (invisible) oder lokaler Datei-Cache mit Referenz in der Message.
- [ ] QR-Code Onboarding: Diagnostic generiert QR mit RVS-Credentials, App scannt — keine manuelle Eingabe mehr
### TTS / Audio
- [ ] Audio-Normalisierung (Lautstaerke zwischen Chunks angleichen)
### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
- [ ] Auto-Compacting und Memory/Brain Verwaltung (SQLite?)
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Alle .env-Variablen ueber Diagnostic konfigurierbar machen (kein File-Sync mehr noetig, da alle ARIA-Container auf der gleichen VM laufen). Fallback .env bleibt fuer initialen Bootstrap.
- [ ] XTTS-Container: kleine Web-Oberflaeche fuer Credentials/Server-Config, oder zentral aus Diagnostic per RVS push
- [ ] Root-Cause OpenClaw Session-Reset: Herausfinden warum Sessions beim ersten chat.send nach Container-Restart verworfen werden (abortedLastRun / systemSent Theorie pruefen, ggf. Flag preemptiv patchen)
- [ ] Alle .env-Variablen ueber Diagnostic konfigurierbar machen (Fallback .env bleibt fuer initialen Bootstrap)
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
- [ ] Root-Cause OpenClaw Session-Reset: Herausfinden warum Sessions beim ersten chat.send nach Container-Restart verworfen werden

View File

@ -20,6 +20,9 @@ const ALLOWED_TYPES = new Set([
"audio_pcm",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",
"service_status",
"config_request",
]);
// Token-Raum: token -> { clients: Set<ws> }
@ -52,7 +55,10 @@ function cleanupRooms() {
// ── WebSocket-Server starten ────────────────────────────────────────
const wss = new WebSocketServer({ port: PORT });
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
// Default-Limit war der Killer fuer die voice_upload Pipeline.
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
wss.on("listening", () => {
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);

9
xtts/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# HuggingFace Model-Cache (Whisper + F5-TTS, geteilt zwischen den
# beiden Bridges via Bind-Mount, kann mehrere GB werden)
hf-cache/
# Voice-Samples (lokal, gehoert nicht ins Repo)
voices/
# Docker .env
.env

View File

@ -1,5 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY bridge.js package.json ./
RUN npm install --production
CMD ["node", "bridge.js"]

View File

@ -1,596 +0,0 @@
/**
* ARIA XTTS Bridge Verbindet XTTS v2 Server mit dem RVS
*
* Empfaengt tts_request ueber RVS rendert Audio via XTTS API sendet zurueck
* Empfaengt voice_upload speichert Voice-Sample fuer Cloning
* Empfaengt xtts_list_voices listet verfuegbare Stimmen
*/
const WebSocket = require("ws");
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const XTTS_API_URL = process.env.XTTS_API_URL || "http://xtts:8000";
const RVS_HOST = process.env.RVS_HOST || "";
const RVS_PORT = process.env.RVS_PORT || "443";
const RVS_TLS = process.env.RVS_TLS || "true";
const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true";
const RVS_TOKEN = process.env.RVS_TOKEN || "";
const VOICES_DIR = "/voices";
function log(msg) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
// ── RVS Verbindung ──────────────────────────────────
let rvsWs = null;
let retryDelay = 2;
function connectRVS(forcePlain) {
if (!RVS_HOST || !RVS_TOKEN) {
log("RVS nicht konfiguriert — beende");
process.exit(1);
}
const useTls = RVS_TLS === "true" && !forcePlain;
const proto = useTls ? "wss" : "ws";
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
log(`Verbinde zu RVS: ${proto}://${RVS_HOST}:${RVS_PORT}`);
const ws = new WebSocket(url);
ws.on("open", () => {
log("RVS verbunden — warte auf TTS-Requests");
rvsWs = ws;
retryDelay = 2;
// Keepalive
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
ws.send(JSON.stringify({ type: "heartbeat", timestamp: Date.now() }));
}
}, 25000);
});
ws.on("message", async (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === "xtts_request") {
await handleTTSRequest(msg.payload);
} else if (msg.type === "voice_upload") {
await handleVoiceUpload(msg.payload);
} else if (msg.type === "xtts_list_voices") {
await handleListVoices();
} else if (msg.type === "xtts_delete_voice") {
await handleDeleteVoice(msg.payload);
} else if (msg.type === "voice_preload") {
await handleVoicePreload(msg.payload);
} else if (msg.type === "config") {
// Diagnostic hat globale Voice gewechselt → Preload damit der naechste
// Render ohne Ladewartezeit startet + alle Clients "voice_ready" sehen
const v = msg.payload && msg.payload.xttsVoice;
if (v && v !== lastDiagnosticVoice) {
lastDiagnosticVoice = v;
await handleVoicePreload({ voice: v, source: "diagnostic" });
} else if (!v) {
lastDiagnosticVoice = "";
}
}
} catch (err) {
log(`Fehler: ${err.message}`);
}
});
ws.on("close", () => {
log("RVS Verbindung geschlossen");
rvsWs = null;
setTimeout(() => connectRVS(), Math.min(retryDelay * 1000, 30000));
retryDelay = Math.min(retryDelay * 2, 30);
});
ws.on("error", (err) => {
log(`RVS Fehler: ${err.message}`);
if (useTls && RVS_TLS_FALLBACK === "true") {
log("TLS fehlgeschlagen — Fallback auf ws://");
ws.removeAllListeners();
try { ws.close(); } catch (_) {}
connectRVS(true);
}
});
}
// ── TTS Request Handler ─────────────────────────────
/**
* Linearer Fade-In auf einen base64-PCM-Chunk (s16le).
* Mascht XTTS-Warmup-Glitches am Anfang eines Renders.
*/
function applyFadeIn(base64Pcm, sampleRate, channels, fadeMs) {
const buf = Buffer.from(base64Pcm, "base64");
const totalSamples = buf.length / 2; // s16le
const fadeSamples = Math.min(
Math.floor((fadeMs / 1000) * sampleRate) * channels,
totalSamples
);
for (let i = 0; i < fadeSamples; i++) {
const sample = buf.readInt16LE(i * 2);
const gain = i / fadeSamples;
buf.writeInt16LE(Math.round(sample * gain), i * 2);
}
return buf.toString("base64");
}
// ── TTS-Queue ──────────────────────────────────────
// XTTS verarbeitet Requests sequenziell, damit Streams sich nicht ueberlappen.
// Ohne Queue wuerden parallele Requests parallel streamen → App bekommt
// interleaved PCM-Chunks aus zwei Rendern → klingt wie Chaos.
let ttsQueue = Promise.resolve();
// Merkt sich die letzte in Diagnostic gewaehlte Voice, damit wir nicht bei jedem
// config-Broadcast (auch ohne Aenderung) einen Preload triggern.
let lastDiagnosticVoice = "";
function handleTTSRequest(payload) {
ttsQueue = ttsQueue.then(() => _runTTSRequest(payload)).catch(err => {
log(`TTS-Queue Fehler: ${err.message}`);
});
return ttsQueue;
}
async function _runTTSRequest(payload) {
const { text, voice, requestId, language, messageId } = payload;
if (!text) return;
// Markdown-Cleanup (Bridge macht jetzt auch Cleanup, aber safety net)
let cleanText = text
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/```[\s\S]*?```/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/#{1,6}\s*/g, "")
.replace(/>\s*/g, "")
.replace(/[-*]\s+/g, "")
.replace(/\n{2,}/g, ". ")
.replace(/\n/g, ", ")
.replace(/\s{2,}/g, " ")
.replace(/["""„]/g, "")
.replace(/\(\)/g, "")
.trim();
log(`TTS-Request (streaming): "${cleanText.slice(0, 80)}..." (${cleanText.length} chars, voice: ${voice || "default"})`);
try {
// Im local-Mode erwartet daswer123 XTTS speaker_wav als Basename (ohne .wav,
// ohne Pfad) — der Server prefixt EXAMPLE_FOLDER selbst. Wir checken hier
// nur das physische File ab um Warnungen zu loggen; runter ans API geht
// nur der Name.
const voiceFilePath = voice ? path.join(VOICES_DIR, `${voice}.wav`) : null;
const hasCustomVoice = voiceFilePath && fs.existsSync(voiceFilePath);
const speakerName = hasCustomVoice ? voice : "";
if (voice && !hasCustomVoice) {
log(`WARNUNG: Voice "${voice}" angefordert, aber ${voiceFilePath} existiert nicht — nehme Default`);
} else if (hasCustomVoice) {
log(`Voice "${voice}" verwendet (speaker_wav="${speakerName}")`);
}
let chunkIndex = 0;
let pcmMeta = null;
let firstChunkSeen = false;
const onChunk = (pcmBase64, meta) => {
if (!pcmMeta) pcmMeta = meta;
let outBase64 = pcmBase64;
// Fade-In auf den ersten Chunk — maskiert XTTS-Warmup-Glitches
// (autoregressiver Generator hat am Anfang wenig Kontext → Artefakte).
if (!firstChunkSeen && pcmBase64) {
firstChunkSeen = true;
outBase64 = applyFadeIn(pcmBase64, meta.sampleRate, meta.channels, 120);
}
sendToRVS({
type: "audio_pcm",
payload: {
requestId: requestId || "",
messageId: messageId || "",
base64: outBase64,
format: "pcm_s16le",
sampleRate: meta.sampleRate,
channels: meta.channels,
voice: voice || "default",
chunk: chunkIndex++,
final: false,
},
timestamp: Date.now(),
});
};
// /tts_stream fuer echtes Streaming (funktioniert im XTTS local-Mode).
// Wenn Server im apiManual/api-Mode laeuft: 400 → Fallback auf /tts_to_audio/.
try {
await streamXTTSAsPCM(
cleanText,
language || "de",
speakerName,
onChunk,
);
} catch (streamErr) {
log(`/tts_stream fehlgeschlagen (${streamErr.message.slice(0, 100)}) — Fallback /tts_to_audio/`);
await streamXTTSBatch(
cleanText,
language || "de",
speakerName,
onChunk,
);
}
// Am Ende: final-Flag damit App weiss "fertig" und Cache geschrieben werden kann
if (pcmMeta) {
sendToRVS({
type: "audio_pcm",
payload: {
requestId: requestId || "",
messageId: messageId || "",
base64: "",
format: "pcm_s16le",
sampleRate: pcmMeta.sampleRate,
channels: pcmMeta.channels,
voice: voice || "default",
chunk: chunkIndex++,
final: true,
},
timestamp: Date.now(),
});
}
log(`TTS komplett: ${chunkIndex} PCM-Frames gestreamt (${cleanText.length} chars)`);
} catch (err) {
log(`TTS Fehler: ${err.message}`);
sendToRVS({
type: "xtts_response",
payload: { requestId, error: err.message },
timestamp: Date.now(),
});
}
}
/**
* Ruft /tts_stream auf echter Streaming-Endpoint bei daswer123.
* Schickt was der Server verlangt (allow: GET), aber mit JSON-Body
* als POST scheitert mit 405. Manche Versionen wollen GET + Query,
* andere POST + JSON. Testen was funktioniert.
*/
function streamXTTSAsPCM(text, language, speakerWav, onPcmChunk) {
return new Promise((resolve, reject) => {
// Wichtig: speaker_wav MUSS als Query-Key dabei sein (Pydantic required) —
// auch bei default-voice mit leerem Wert. Sonst gibt's HTTP 422.
// stream_chunk_size=250: grosse Chunks = wenige Chunk-Grenzen = wenig
// Render-Artefakte. daswer123 erzeugt an Chunk-Boundaries haeufig Glitches
// in den Worten die ueber die Grenze gehen. Hoehere Latenz ist OK.
const qs = new URLSearchParams();
qs.set("text", text);
qs.set("language", language || "de");
qs.set("speaker_wav", speakerWav || "");
qs.set("stream_chunk_size", "250");
const url = new URL(XTTS_API_URL);
const fullPath = `/tts_stream?${qs.toString()}`;
const options = {
hostname: url.hostname,
port: url.port || 80,
path: fullPath,
method: "GET",
timeout: 60000,
};
log(`TTS GET /tts_stream?text=${text.slice(0, 30)}... (voice=${speakerWav ? "custom" : "default"})`);
const req = http.request(options, (res) => {
if (res.statusCode !== 200) {
let body = "";
res.on("data", (d) => { body += d.toString(); });
res.on("end", () => {
log(`XTTS /tts_stream ${res.statusCode}: ${body.slice(0, 300)}`);
reject(new Error(`XTTS HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
});
return;
}
log(`TTS stream verbunden, empfange PCM...`);
let headerParsed = false;
let sampleRate = 24000;
let channels = 1;
let leftover = Buffer.alloc(0); // ungerade Byte-Reste fuer das naechste Chunk
const HEADER_BYTES = 44;
let headerBuf = Buffer.alloc(0);
const PCM_CHUNK_BYTES = 8192; // ~170ms bei 24kHz s16 mono
res.on("data", (chunk) => {
let data = chunk;
// WAV-Header konsumieren (44 Bytes)
if (!headerParsed) {
headerBuf = Buffer.concat([headerBuf, data]);
if (headerBuf.length < HEADER_BYTES) return;
// Header lesen
const header = headerBuf.slice(0, HEADER_BYTES);
try {
channels = header.readUInt16LE(22);
sampleRate = header.readUInt32LE(24);
} catch (_) {}
headerParsed = true;
data = headerBuf.slice(HEADER_BYTES);
}
// leftover aus vorherigem Chunk + neuer data
let combined = Buffer.concat([leftover, data]);
// In PCM_CHUNK_BYTES-Happen zerlegen (Vielfache von 2 damit keine Sample-Splits)
while (combined.length >= PCM_CHUNK_BYTES) {
const slice = combined.slice(0, PCM_CHUNK_BYTES);
combined = combined.slice(PCM_CHUNK_BYTES);
onPcmChunk(slice.toString("base64"), { sampleRate, channels });
}
leftover = combined;
});
res.on("end", () => {
// Rest-Daten senden
if (leftover.length > 0) {
onPcmChunk(leftover.toString("base64"), { sampleRate, channels });
}
resolve();
});
res.on("error", reject);
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("XTTS API Timeout (60s)")); });
req.end();
});
}
/**
* Fallback: /tts_to_audio/ (POST JSON) rendert komplett, dann response.
* Kein echtes Streaming, aber stabil als Backup wenn /tts_stream nicht geht.
* Shared chunking-Logik mit streamXTTSAsPCM parst WAV-Header, stueckelt PCM.
*/
function streamXTTSBatch(text, language, speakerWav, onPcmChunk) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
text,
language: language || "de",
speaker_wav: speakerWav || "",
});
const url = new URL(XTTS_API_URL);
const options = {
hostname: url.hostname,
port: url.port || 80,
path: "/tts_to_audio/",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
timeout: 60000,
};
const req = http.request(options, (res) => {
if (res.statusCode !== 200) {
let rb = "";
res.on("data", (d) => { rb += d.toString(); });
res.on("end", () => reject(new Error(`XTTS Batch HTTP ${res.statusCode}: ${rb.slice(0, 200)}`)));
return;
}
let headerParsed = false;
let sampleRate = 24000;
let channels = 1;
let leftover = Buffer.alloc(0);
let headerBuf = Buffer.alloc(0);
const HEADER_BYTES = 44;
const PCM_CHUNK_BYTES = 8192;
res.on("data", (chunk) => {
let data = chunk;
if (!headerParsed) {
headerBuf = Buffer.concat([headerBuf, data]);
if (headerBuf.length < HEADER_BYTES) return;
const header = headerBuf.slice(0, HEADER_BYTES);
try { channels = header.readUInt16LE(22); sampleRate = header.readUInt32LE(24); } catch (_) {}
headerParsed = true;
data = headerBuf.slice(HEADER_BYTES);
}
let combined = Buffer.concat([leftover, data]);
while (combined.length >= PCM_CHUNK_BYTES) {
const slice = combined.slice(0, PCM_CHUNK_BYTES);
combined = combined.slice(PCM_CHUNK_BYTES);
onPcmChunk(slice.toString("base64"), { sampleRate, channels });
}
leftover = combined;
});
res.on("end", () => {
if (leftover.length > 0) onPcmChunk(leftover.toString("base64"), { sampleRate, channels });
resolve();
});
res.on("error", reject);
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("XTTS Batch Timeout (60s)")); });
req.write(body);
req.end();
});
}
// ── Voice Upload Handler ────────────────────────────
async function handleVoiceUpload(payload) {
const { name, samples } = payload;
if (!name || !samples || !Array.isArray(samples) || samples.length === 0) {
log("Voice Upload: Ungueltige Daten");
return;
}
log(`Voice Upload: "${name}" (${samples.length} Samples)`);
try {
// Alle Samples zusammenfuegen
const buffers = samples.map(s => Buffer.from(s.base64, "base64"));
const combined = Buffer.concat(buffers);
// Als WAV speichern
fs.mkdirSync(VOICES_DIR, { recursive: true });
const filePath = path.join(VOICES_DIR, `${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.wav`);
fs.writeFileSync(filePath, combined);
log(`Voice gespeichert: ${filePath} (${(combined.length / 1024).toFixed(0)}KB)`);
sendToRVS({
type: "xtts_voice_saved",
payload: { name, size: combined.length, path: filePath },
timestamp: Date.now(),
});
} catch (err) {
log(`Voice Upload Fehler: ${err.message}`);
}
}
// ── Voice Delete Handler ────────────────────────────
async function handleDeleteVoice(payload) {
const { name } = payload || {};
if (!name || typeof name !== "string") {
log("Voice Delete: ungueltiger Name");
return;
}
const safe = name.replace(/[^a-zA-Z0-9_-]/g, "_");
const filePath = path.join(VOICES_DIR, `${safe}.wav`);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
log(`Voice geloescht: ${filePath}`);
} else {
log(`Voice Delete: Datei existiert nicht (${filePath})`);
}
// Aktualisierte Liste an alle Clients senden
await handleListVoices();
} catch (err) {
log(`Voice Delete Fehler: ${err.message}`);
}
}
// ── Voice List Handler ──────────────────────────────
/**
* Preload einer Stimme rendert stumm ein kurzes Dummy-Audio, damit XTTS
* die Speaker-Latents laedt und der naechste echte Request ohne Wartezeit
* loslegen kann. Broadcastet "voice_ready" wenn fertig (oder mit error).
*/
async function handleVoicePreload(payload) {
const voice = (payload && payload.voice) || "";
const source = (payload && payload.source) || "unknown";
const requestId = (payload && payload.requestId) || "";
log(`Voice-Preload angefordert: "${voice}" (source=${source})`);
try {
let speakerName = "";
if (voice) {
const voiceFilePath = path.join(VOICES_DIR, `${voice}.wav`);
if (!fs.existsSync(voiceFilePath)) {
sendToRVS({
type: "voice_ready",
payload: { voice, requestId, error: "voice-file-not-found" },
timestamp: Date.now(),
});
log(`Preload abgebrochen: ${voiceFilePath} existiert nicht`);
return;
}
speakerName = voice;
}
// Dummy-Request via Queue — damit sich Preload nicht mit echtem TTS ueberholt.
const t0 = Date.now();
await new Promise((resolve, reject) => {
ttsQueue = ttsQueue.then(async () => {
try {
await streamXTTSAsPCM("ja.", "de", speakerName, () => {});
resolve();
} catch (err) {
reject(err);
}
}).catch(reject);
});
const ms = Date.now() - t0;
log(`Voice "${voice || "default"}" geladen in ${ms}ms`);
sendToRVS({
type: "voice_ready",
payload: { voice, requestId, loadMs: ms },
timestamp: Date.now(),
});
} catch (err) {
log(`Voice-Preload Fehler: ${err.message}`);
sendToRVS({
type: "voice_ready",
payload: { voice, requestId, error: err.message.slice(0, 200) },
timestamp: Date.now(),
});
}
}
async function handleListVoices() {
try {
const files = fs.existsSync(VOICES_DIR)
? fs.readdirSync(VOICES_DIR).filter(f => f.endsWith(".wav"))
: [];
const voices = files.map(f => ({
name: path.basename(f, ".wav"),
file: f,
size: fs.statSync(path.join(VOICES_DIR, f)).size,
}));
log(`Stimmen: ${voices.length} verfuegbar`);
sendToRVS({
type: "xtts_voices_list",
payload: { voices },
timestamp: Date.now(),
});
} catch (err) {
log(`Stimmen-Liste Fehler: ${err.message}`);
}
}
// ── RVS senden ──────────────────────────────────────
function sendToRVS(msg) {
if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
rvsWs.send(JSON.stringify(msg));
}
}
// ── Start ───────────────────────────────────────────
log("ARIA XTTS Bridge startet...");
log(`XTTS API: ${XTTS_API_URL}`);
log(`RVS: ${RVS_HOST}:${RVS_PORT}`);
// Warten bis XTTS API erreichbar ist
function waitForXTTS(callback, attempts) {
if (attempts <= 0) { log("XTTS API nicht erreichbar — starte trotzdem"); callback(); return; }
http.get(`${XTTS_API_URL}/docs`, (res) => {
log(`XTTS API erreichbar (HTTP ${res.statusCode})`);
callback();
}).on("error", () => {
log(`XTTS API noch nicht bereit — warte (${attempts} Versuche uebrig)...`);
setTimeout(() => waitForXTTS(callback, attempts - 1), 10000); // 10s statt 5s (Model laden dauert)
});
}
waitForXTTS(() => connectRVS(), 30); // Max 5min warten

View File

@ -1,7 +1,7 @@
# ════════════════════════════════════════════════
# ARIA XTTS v2 — GPU TTS Server
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
# Laeuft auf dem Gaming-PC (RTX 3060)
# Verbindet sich zum RVS fuer TTS-Requests
# Verbindet sich zum RVS fuer TTS/STT-Requests
# ════════════════════════════════════════════════
#
# Voraussetzungen:
@ -10,15 +10,18 @@
# - .env mit RVS-Verbindungsdaten
#
# Start: docker compose up -d
# Test: curl http://localhost:8000/docs
# ════════════════════════════════════════════════
services:
# ─── XTTS v2 API Server (GPU) ─────────────────
xtts:
image: daswer123/xtts-api-server:latest
container_name: aria-xtts
# ─── F5-TTS Bridge (GPU) ──────────────────────
# Ersetzt den frueheren XTTS-Stack. Empfaengt xtts_request via RVS,
# rendert via F5-TTS mit Voice-Cloning, streamt PCM an die App.
# Voice-Upload: speichert WAV und laesst whisper-bridge den Referenz-
# text transkribieren — der User muss nichts eintippen.
f5tts-bridge:
build: ./f5tts
container_name: aria-f5tts-bridge
deploy:
resources:
reservations:
@ -26,37 +29,57 @@ services:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "8000:8020"
volumes:
- xtts-models:/app/xtts_models # Model-Cache (~2GB)
- ./voices:/voices # Custom Voice Samples
- ./voices:/voices # WAV + TXT Referenz
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
# Direkt sichtbar im xtts/hf-cache/,
# einfach manuell zu loeschen, kein
# Docker-Desktop .vhdx Bloat.
# Wird mit whisper-bridge geteilt.
environment:
- COQUI_TOS_AGREED=1
# Local-Modus statt default "apiManual": Modell bleibt im GPU-VRAM,
# Render startet sofort, /tts_stream funktioniert.
# Default-CMD des Images liest diese ENV: -ms ${MODEL_SOURCE:-"apiManual"}
- MODEL_SOURCE=local
# Speaker-Folder auf unsere gemounteten voices zeigen lassen
- EXAMPLE_FOLDER=/voices
restart: unless-stopped
# ─── XTTS Bridge (verbindet zu RVS) ───────────
xtts-bridge:
build: .
container_name: aria-xtts-bridge
depends_on:
- xtts
volumes:
- ./voices:/voices # Shared mit XTTS-Server
environment:
- XTTS_API_URL=http://xtts:8020
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN}
- F5TTS_DEVICE=${F5TTS_DEVICE:-cuda}
- VOICES_DIR=/voices
restart: unless-stopped
volumes:
xtts-models:
# ─── Whisper STT (GPU) ────────────────────────
# Faster-Whisper auf der Gamebox statt auf der VM (CPU) —
# deutlich schneller. Verbindet sich selbst per WebSocket an
# den RVS und nimmt dort stt_request Nachrichten der aria-bridge
# entgegen, antwortet mit stt_response. Zusaetzlich nutzt die
# f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
# Voice-Uploads. Laedt das Modell beim Start vor; auf Config-
# Broadcasts (Diagnostic → whisperModel) wird zur Laufzeit hot-
# swapped.
whisper-bridge:
build: ./whisper
container_name: aria-whisper-bridge
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
environment:
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN}
- WHISPER_MODEL=${WHISPER_MODEL:-small}
- WHISPER_DEVICE=${WHISPER_DEVICE:-cuda}
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
- WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de}
volumes:
- ./hf-cache:/root/.cache/huggingface # gleicher Cache wie f5tts-bridge —
# ein Modell muss nur einmal pro
# Maschine geladen werden, kein
# Re-Download bei Container-Restart.
restart: unless-stopped

21
xtts/f5tts/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# PyTorch CUDA-Wheels zuerst (f5-tts zieht sonst CPU-only Torch rein)
RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
--index-url https://download.pytorch.org/whl/cu121
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py .
CMD ["python3", "bridge.py"]

856
xtts/f5tts/bridge.py Normal file
View File

@ -0,0 +1,856 @@
#!/usr/bin/env python3
"""
ARIA F5-TTS Bridge laeuft auf der Gamebox (RTX 3060).
Empfaengt xtts_request via RVS F5-TTS Voice Cloning auf GPU streamt
16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App.
Voice-Layout im VOICES_DIR:
{name}.wav Referenz-Audio (6-10s, 24kHz mono empfohlen)
{name}.txt Referenz-Text (UTF-8, was im WAV gesprochen wird)
Beim voice_upload senden wir intern einen stt_request an die whisper-bridge
und legen die Transkription als .txt ab der User muss keinen Text eingeben.
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
F5TTS_MODEL Default: F5TTS_v1_Base
F5TTS_DEVICE Default: cuda
VOICES_DIR Default: /voices
"""
import asyncio
import base64
import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Optional
import numpy as np
import soundfile as sf
import websockets
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("f5tts-bridge")
# HuggingFace + Torch download-Logs etwas daempfen
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# F5-TTS Konfiguration
# ─────────────────────────────────────────────────────────────────
# Defaults sind hard-coded — bewusst KEINE ENV-Vars (ausser F5TTS_DEVICE,
# weil Hardware-Bootstrap). Alle Settings werden zur Laufzeit via RVS
# config-Broadcast aus Diagnostic uebersteuert (Felder f5ttsModel,
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base"
DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
# cfg_strength: wie stark der Generator am Referenz-Voice klebt.
# Default F5-TTS = 2.0. Bei nicht-EN/CN Sprachen (Deutsch!) hilft 2.5+,
# damit das Modell nicht in eine andere Sprache abrutscht.
DEFAULT_F5TTS_CFG_STRENGTH = 2.5
DEFAULT_F5TTS_NFE_STEP = 32
VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
TARGET_SR = 24000 # F5-TTS native
# F5-TTS hat ein 12s Hard-Limit fuer Referenz-Audio. Laengere WAVs werden
# vom Modell stumm abgeschnitten — aber unser ref_text bleibt lang und passt
# dann nicht mehr zum gekuerzten Audio (Quali leidet, warmup-Render ist
# unnoetig lange). Wir clippen explizit auf 10s + re-transkribieren den Text
# damit beide synchron bleiben.
REF_MAX_SECONDS = 10.0
# Wird in einer Uebergangsphase als "ungueltige Referenz" erkannt (alte voices,
# die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung
# loeschen wir die .txt und ziehen den echten Text nach.
_LEGACY_PLACEHOLDER_REF = "Das ist ein Referenz Audio."
# ── Lazy F5-TTS Loader ──────────────────────────────────────
_F5TTS_cls = None
def _get_f5tts_cls():
"""Lazy import damit Startup-Logs nicht durch Torch-Warnungen zumuellen."""
global _F5TTS_cls
if _F5TTS_cls is None:
from f5_tts.api import F5TTS as _cls
_F5TTS_cls = _cls
return _F5TTS_cls
def _resolve_hf_path(p: str) -> str:
"""Wenn p mit 'hf://' anfaengt → aus HuggingFace Hub runterladen,
lokalen Pfad zurueckgeben. Sonst unveraendert.
Format: hf://user/repo/path/to/file.ext
Beispiel: hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors
"""
if not p or not p.startswith("hf://"):
return p
try:
from huggingface_hub import hf_hub_download
rest = p[5:]
parts = rest.split("/", 2)
if len(parts) < 3:
logger.warning("Ungueltiges hf:// Format: %s (erwarte hf://user/repo/path)", p)
return p
repo_id = f"{parts[0]}/{parts[1]}"
filename = parts[2]
logger.info("HF-Download: %s aus %s", filename, repo_id)
local = hf_hub_download(repo_id=repo_id, filename=filename)
logger.info("HF-Download fertig: %s", local)
return local
except Exception as e:
logger.exception("HF-Download fehlgeschlagen fuer %s: %s", p, e)
return p
class F5Runner:
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking).
Live-Settings (Modell, cfg_strength, nfe_step) werden ueber update_config()
aus dem Diagnostic-Config-Broadcast gesetzt; bei Modell-Wechsel wird
automatisch neu geladen.
"""
def __init__(self) -> None:
self.model = None
self._lock = asyncio.Lock()
# Aktuelle Werte — gestartet mit Hard-Defaults, ueberschrieben von Diagnostic
self.model_id: str = DEFAULT_F5TTS_MODEL
self.ckpt_file: str = DEFAULT_F5TTS_CKPT_FILE
self.vocab_file: str = DEFAULT_F5TTS_VOCAB_FILE
self.cfg_strength: float = DEFAULT_F5TTS_CFG_STRENGTH
self.nfe_step: int = DEFAULT_F5TTS_NFE_STEP
# Last load-time fuer service_status Broadcast
self.last_load_seconds: float = 0.0
self._load_started_at: float = 0.0
def _load_blocking(self) -> None:
cls = _get_f5tts_cls()
ckpt_resolved = _resolve_hf_path(self.ckpt_file) if self.ckpt_file else ""
vocab_resolved = _resolve_hf_path(self.vocab_file) if self.vocab_file else ""
logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...",
self.model_id, F5TTS_DEVICE, ckpt_resolved or "default")
self._load_started_at = time.time()
kwargs = {"model": self.model_id, "device": F5TTS_DEVICE}
if ckpt_resolved:
kwargs["ckpt_file"] = ckpt_resolved
if vocab_resolved:
kwargs["vocab_file"] = vocab_resolved
self.model = cls(**kwargs)
elapsed = time.time() - self._load_started_at
logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)",
elapsed, self.cfg_strength, self.nfe_step)
# Wird von outside (run_loop) gelesen um service_status auf 'ready' zu setzen
self.last_load_seconds = elapsed
async def ensure_loaded(self) -> None:
async with self._lock:
if self.model is not None:
return
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_blocking)
async def update_config(self, payload: dict) -> None:
"""Liest f5tts*-Felder aus einem config-Broadcast.
Bei Modell-relevantem Wechsel wird neu geladen.
Semantik:
- key fehlt in payload aktuellen Wert behalten
- key da, nicht-leerer str diesen Wert nehmen
- key da, leerer string RESET auf Hard-Default (User hat Feld
in Diagnostic geleert und Apply geklickt)
"""
if "f5ttsModel" in payload:
v = (payload.get("f5ttsModel") or "").strip()
new_model = v if v else DEFAULT_F5TTS_MODEL
else:
new_model = self.model_id
if "f5ttsCkptFile" in payload:
v = payload.get("f5ttsCkptFile") or ""
new_ckpt = v.strip() if isinstance(v, str) else ""
else:
new_ckpt = self.ckpt_file
if "f5ttsVocabFile" in payload:
v = payload.get("f5ttsVocabFile") or ""
new_vocab = v.strip() if isinstance(v, str) else ""
else:
new_vocab = self.vocab_file
try:
new_cfg = float(payload.get("f5ttsCfgStrength", self.cfg_strength))
except (TypeError, ValueError):
new_cfg = self.cfg_strength
try:
new_nfe = int(payload.get("f5ttsNfeStep", self.nfe_step))
except (TypeError, ValueError):
new_nfe = self.nfe_step
# Settings die KEINEN Modell-Reload brauchen (zur naechsten Synthese aktiv)
self.cfg_strength = new_cfg
self.nfe_step = new_nfe
# Settings die einen Reload triggern
model_changed = (new_model != self.model_id
or new_ckpt != self.ckpt_file
or new_vocab != self.vocab_file)
if model_changed:
logger.info("F5-TTS Config-Wechsel: model=%s ckpt=%s vocab=%s — Reload",
new_model, new_ckpt or "default", new_vocab or "default")
self.model_id = new_model
self.ckpt_file = new_ckpt
self.vocab_file = new_vocab
async with self._lock:
old = self.model
self.model = None
# Alte Instanz freigeben
try:
if old is not None:
del old
except Exception:
pass
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_blocking)
else:
logger.info("F5-TTS Live-Config: cfg_strength=%.2f nfe=%d", new_cfg, new_nfe)
def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str,
speed: float = 1.0) -> tuple[np.ndarray, int]:
logger.info("infer() text=%d chars, speed=%.2f, cfg=%.2f, nfe=%d",
len(gen_text), speed, self.cfg_strength, self.nfe_step)
wav, sr, _ = self.model.infer(
ref_file=ref_wav,
ref_text=ref_text,
gen_text=gen_text,
remove_silence=True,
seed=-1,
cfg_strength=self.cfg_strength,
nfe_step=self.nfe_step,
speed=speed,
)
# F5-TTS gibt float32 1D-Array — auf 24kHz sample-rate standard
if not isinstance(wav, np.ndarray):
wav = np.asarray(wav, dtype=np.float32)
if wav.ndim > 1:
wav = wav.squeeze()
return wav.astype(np.float32), int(sr)
async def synthesize(self, gen_text: str, ref_wav: str, ref_text: str,
speed: float = 1.0) -> tuple[np.ndarray, int]:
await self.ensure_loaded()
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._infer_blocking, gen_text, ref_wav, ref_text, speed)
# ── Helpers ─────────────────────────────────────────────────
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+")
def split_sentences(text: str, max_len: int = 350) -> list[str]:
"""Teilt langen Text an Satzgrenzen. Kurze Texte bleiben als-is."""
text = text.strip()
if not text:
return []
if len(text) <= max_len:
return [text]
parts = [p.strip() for p in _SENTENCE_SPLIT.split(text) if p.strip()]
# Zu kurze Fragmente mergen damit F5-TTS nicht an jedem Komma neu startet
merged: list[str] = []
buf = ""
for p in parts:
if len(buf) + len(p) + 1 <= max_len:
buf = f"{buf} {p}".strip()
else:
if buf:
merged.append(buf)
buf = p
if buf:
merged.append(buf)
return merged or [text]
def float_to_pcm16(wav: np.ndarray) -> bytes:
"""Float32 (-1..+1) → int16 little-endian bytes.
F5-TTS generiert gelegentlich NaN/Inf bei Instabilitaeten ohne sanitize
waere der Cast zu int16 undefiniert (RuntimeWarning + kaputter Sound).
"""
nan_count = int(np.isnan(wav).sum() + np.isinf(wav).sum())
if nan_count > 0:
logger.warning("F5-TTS Output enthaelt %d NaN/Inf samples — ersetze mit 0", nan_count)
wav = np.nan_to_num(wav, nan=0.0, posinf=1.0, neginf=-1.0)
wav = np.clip(wav, -1.0, 1.0)
pcm = (wav * 32767.0).astype(np.int16)
return pcm.tobytes()
def sanitize_voice_name(name: str) -> str:
return re.sub(r"[^a-zA-Z0-9_-]", "_", name)
def voice_paths(name: str) -> tuple[Path, Path]:
safe = sanitize_voice_name(name)
return VOICES_DIR / f"{safe}.wav", VOICES_DIR / f"{safe}.txt"
def normalize_ref_wav(src_wav: Path, max_seconds: float = REF_MAX_SECONDS) -> tuple[Path, bool]:
"""Bringt die Referenz-WAV in F5-TTS-freundliche Form:
* 24kHz mono
* max max_seconds Dauer
* Stille am Anfang + Ende abgeschnitten (silenceremove-Filter)
* Lautheit auf -16 LUFS normalisiert (loudnorm-Filter) damit
das Modell konsistente Amplituden sieht
F5-TTS reagiert empfindlich auf leise / verrauschte / zerhackte
Referenzen. Konsistente, saubere Input-Lautheit hilft der Quali.
Returns:
(path, was_modified) was_modified=True wenn die Datei wirklich
geaendert wurde (Caller sollte dann den passenden .txt invalidieren).
"""
tmp_out = src_wav.with_suffix(".conv.wav")
# silenceremove am Anfang: bis -50dB gesprochen wird
# silenceremove am Ende: ueber -50dB rein, dann 0.5s stille als Cutoff
# loudnorm: EBU R128, Ziel -16 LUFS
af = ("silenceremove=start_periods=1:start_duration=0.05:start_threshold=-50dB,"
"silenceremove=stop_periods=1:stop_duration=0.5:stop_threshold=-50dB,"
"loudnorm=I=-16:TP=-1.5:LRA=11")
cmd = ["ffmpeg", "-y", "-i", str(src_wav),
"-af", af,
"-ar", str(TARGET_SR), "-ac", "1",
"-t", str(max_seconds),
"-f", "wav", str(tmp_out)]
r = subprocess.run(cmd, capture_output=True, timeout=30)
if r.returncode != 0:
logger.warning("ffmpeg-Normalisierung von %s fehlgeschlagen: %s",
src_wav, r.stderr.decode(errors="replace")[:300])
try:
tmp_out.unlink()
except OSError:
pass
return src_wav, False
os.replace(tmp_out, src_wav)
try:
info = sf.info(str(src_wav))
logger.info("Referenz-WAV normalisiert: %s (%.1fs, %dHz mono, -16 LUFS, silence getrimmt)",
src_wav.name, info.duration, info.samplerate)
except Exception:
logger.info("Referenz-WAV normalisiert: %s", src_wav.name)
return src_wav, True
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
# ── Interne Transkription via whisper-bridge ────────────────
_pending_stt: dict[str, asyncio.Future] = {}
_STT_TIMEOUT_S = 60.0
async def request_transcription(ws, wav_path: Path, language: str = "de") -> Optional[str]:
"""Sendet einen stt_request an die whisper-bridge (ueber RVS) und wartet auf stt_response."""
try:
with open(wav_path, "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode("ascii")
except Exception as e:
logger.error("Lesen %s fehlgeschlagen: %s", wav_path, e)
return None
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
fut: asyncio.Future = loop.create_future()
_pending_stt[request_id] = fut
try:
await _send(ws, "stt_request", {
"requestId": request_id,
"audio": audio_b64,
"mimeType": "audio/wav",
# KEIN hardcoded model — whisper-bridge nimmt das bereits
# geladene. Sonst wuerde hier ein Swap auf 'small' passieren und
# danach muesste das in Diagnostic konfigurierte Modell (z.B.
# large-v3) wieder geladen werden → doppelter Download.
"language": language,
})
return await asyncio.wait_for(fut, timeout=_STT_TIMEOUT_S)
except asyncio.TimeoutError:
logger.warning("Transkription Timeout fuer %s", wav_path.name)
return None
except Exception as e:
logger.warning("Transkription Fehler: %s", e)
return None
finally:
_pending_stt.pop(request_id, None)
# ── TTS-Request Handler ─────────────────────────────────────
# Queue damit sich parallele Requests nicht ueberlappen (GPU-Throughput)
_tts_queue: asyncio.Queue[tuple] = asyncio.Queue()
async def _tts_worker(ws, runner: F5Runner) -> None:
"""Serialisiert Synthesen — GPU kann sonst OOM gehen."""
while True:
text, voice, request_id, message_id, language, speed = await _tts_queue.get()
try:
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed)
except Exception:
logger.exception("TTS-Worker Fehler")
finally:
_tts_queue.task_done()
async def _do_tts(ws, runner: F5Runner, text: str, voice: str,
request_id: str, message_id: str, language: str,
speed: float = 1.0) -> None:
t0 = time.time()
ref_wav_path, ref_txt_path = voice_paths(voice) if voice else (None, None)
# WAV zu lang? F5-TTS limitiert intern auf 12s, dann passt der txt nicht
# mehr zum Audio. Wir clippen explizit auf 10s und invalidieren den txt,
# damit er on-the-fly passend zum gekuerzten Audio neu transkribiert wird.
if voice and ref_wav_path and ref_wav_path.exists():
try:
info = sf.info(str(ref_wav_path))
if info.duration > REF_MAX_SECONDS + 0.5:
logger.info("Voice '%s' WAV ist %.1fs (>%.0fs) → clippen + txt neu",
voice, info.duration, REF_MAX_SECONDS)
_, modified = normalize_ref_wav(ref_wav_path)
if modified and ref_txt_path and ref_txt_path.exists():
ref_txt_path.unlink()
except Exception as e:
logger.warning("Konnte WAV-Dauer nicht pruefen: %s", e)
# Legacy-Platzhalter erkennen → behandeln als "kein txt" und neu transkribieren
if voice and ref_txt_path and ref_txt_path.exists():
try:
existing = ref_txt_path.read_text(encoding="utf-8").strip()
if existing == _LEGACY_PLACEHOLDER_REF or not existing:
logger.info("Voice '%s' hat Legacy-Platzhalter → loesche, transkribiere neu", voice)
ref_txt_path.unlink()
except Exception:
pass
has_custom = bool(voice and ref_wav_path and ref_wav_path.exists() and ref_txt_path.exists())
if voice and not has_custom:
# Wenn nur WAV da ist aber kein txt → on-the-fly transkribieren
if ref_wav_path and ref_wav_path.exists() and (not ref_txt_path or not ref_txt_path.exists()):
logger.info("Voice '%s' hat kein txt — transkribiere on-the-fly", voice)
text_ref = await request_transcription(ws, ref_wav_path, language)
if text_ref and text_ref.strip():
try:
ref_txt_path.write_text(text_ref.strip(), encoding="utf-8")
has_custom = True
logger.info("Referenz-Text nachgezogen: '%s'", text_ref[:60])
except Exception as e:
logger.warning("Referenz-Text speichern fehlgeschlagen: %s", e)
if not has_custom:
logger.warning("Voice '%s' nicht komplett (%s, txt=%s) — nehme Default",
voice, ref_wav_path, (ref_txt_path and ref_txt_path.exists()))
if has_custom:
ref_wav_str = str(ref_wav_path)
ref_text = ref_txt_path.read_text(encoding="utf-8").strip()
else:
# Fallback: kein Custom-Voice. F5-TTS braucht IMMER eine Referenz,
# wir nehmen default_ref.wav/txt falls vorhanden, sonst die erste
# gefundene Voice im Ordner.
default_wav = VOICES_DIR / "default_ref.wav"
default_txt = VOICES_DIR / "default_ref.txt"
if default_wav.exists() and default_txt.exists():
ref_wav_str = str(default_wav)
ref_text = default_txt.read_text(encoding="utf-8").strip()
else:
# Nimm irgendein vorhandenes voice-Paar
pair = next(
((w, t) for w, t in (
(v, v.with_suffix(".txt")) for v in VOICES_DIR.glob("*.wav")
) if t.exists()),
None,
)
if not pair:
logger.error("Keine Referenz-Stimme im VOICES_DIR — TTS abgebrochen")
return
ref_wav_str, ref_text = str(pair[0]), pair[1].read_text(encoding="utf-8").strip()
sentences = split_sentences(text)
logger.info("F5-TTS: %d Satz(e), voice=%s, speed=%.2fx (%s)",
len(sentences), voice or "default", speed, ref_wav_str)
chunk_index = 0
pcm_sr = TARGET_SR
for i, sent in enumerate(sentences):
try:
wav, sr = await runner.synthesize(sent, ref_wav_str, ref_text, speed)
pcm_sr = sr
pcm_bytes = float_to_pcm16(wav)
# Erste PCM-Chunk des allerersten Satzes bekommt Fade-In (maskiert
# eventuelle Warmup-Glitches). Alle anderen Chunks bleiben wie sind.
if i == 0 and chunk_index == 0:
pcm_bytes = _fade_in_pcm16(pcm_bytes, sr, 120)
# Stueckeln
for off in range(0, len(pcm_bytes), PCM_CHUNK_BYTES):
slice_ = pcm_bytes[off:off + PCM_CHUNK_BYTES]
await _send(ws, "audio_pcm", {
"requestId": request_id,
"messageId": message_id,
"base64": base64.b64encode(slice_).decode("ascii"),
"format": "pcm_s16le",
"sampleRate": sr,
"channels": 1,
"voice": voice or "default",
"chunk": chunk_index,
"final": False,
})
chunk_index += 1
except Exception as e:
logger.exception("F5-TTS Synthese-Fehler (Satz %d)", i)
await _send(ws, "xtts_response", {
"requestId": request_id,
"error": str(e)[:200],
})
return
# Final-Marker
await _send(ws, "audio_pcm", {
"requestId": request_id,
"messageId": message_id,
"base64": "",
"format": "pcm_s16le",
"sampleRate": pcm_sr,
"channels": 1,
"voice": voice or "default",
"chunk": chunk_index,
"final": True,
})
logger.info("TTS komplett: %d Chunks, %.2fs render (voice=%s, text=%d chars)",
chunk_index, time.time() - t0, voice or "default", len(text))
def _fade_in_pcm16(pcm: bytes, sr: int, fade_ms: int) -> bytes:
"""Linear Fade-In auf erste fade_ms — maskiert Warmup-Glitches."""
arr = np.frombuffer(pcm, dtype=np.int16).copy()
fade_samples = min(int((fade_ms / 1000.0) * sr), len(arr))
if fade_samples <= 0:
return pcm
ramp = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32)
arr[:fade_samples] = (arr[:fade_samples].astype(np.float32) * ramp).astype(np.int16)
return arr.tobytes()
# ── Voice Management Handlers ───────────────────────────────
async def handle_voice_upload(ws, payload: dict) -> None:
name = (payload.get("name") or "").strip()
samples = payload.get("samples") or []
if not name or not samples:
logger.warning("voice_upload: ungueltig (name=%r, samples=%d)", name, len(samples))
return
logger.info("Voice-Upload: '%s' (%d Samples)", name, len(samples))
try:
VOICES_DIR.mkdir(parents=True, exist_ok=True)
safe = sanitize_voice_name(name)
wav_path = VOICES_DIR / f"{safe}.wav"
txt_path = VOICES_DIR / f"{safe}.txt"
# Samples zusammenfuegen
buffers = [base64.b64decode(s.get("base64", "")) for s in samples]
with open(wav_path, "wb") as f:
for b in buffers:
f.write(b)
size_kb = wav_path.stat().st_size / 1024
logger.info("Voice WAV gespeichert: %s (%.0fKB)", wav_path, size_kb)
# Auf 24kHz mono clippen auf 10s (F5-TTS Hard-Limit ist 12s,
# kuerzer = schnellerer Warmup + Text+Audio bleiben aligned)
normalize_ref_wav(wav_path)
# Transkription ueber whisper-bridge anfragen
logger.info("Transkribiere '%s' via whisper-bridge...", name)
text = await request_transcription(ws, wav_path, language="de")
if text and text.strip():
txt_path.write_text(text.strip(), encoding="utf-8")
logger.info("Voice '%s' komplett (txt: %s)", name, text[:80])
ref_text_for_response = text.strip()
else:
# KEIN Platzhalter mehr schreiben! Beim ersten echten TTS-Use wird
# on-the-fly nachtranskribiert. Wenn die whisper-bridge dann online
# ist, klappt's — sonst koennte der User die .txt manuell anlegen.
logger.warning("Voice '%s': Transkription fehlgeschlagen — .txt bleibt leer, "
"wird on-the-fly bei erstem Render nachgezogen", name)
ref_text_for_response = ""
await _send(ws, "xtts_voice_saved", {
"name": name, "size": int(size_kb * 1024), "refText": ref_text_for_response,
})
# Liste aktualisieren
await handle_list_voices(ws)
except Exception as e:
logger.exception("voice_upload Fehler")
await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]})
async def handle_list_voices(ws) -> None:
try:
voices = []
if VOICES_DIR.exists():
for wav in sorted(VOICES_DIR.glob("*.wav")):
txt = wav.with_suffix(".txt")
voices.append({
"name": wav.stem,
"file": wav.name,
"size": wav.stat().st_size,
"hasRefText": txt.exists(),
})
logger.info("Stimmen-Liste: %d", len(voices))
await _send(ws, "xtts_voices_list", {"voices": voices})
except Exception:
logger.exception("handle_list_voices Fehler")
async def handle_delete_voice(ws, payload: dict) -> None:
name = (payload.get("name") or "").strip()
if not name:
return
try:
wav, txt = voice_paths(name)
for p in (wav, txt):
if p.exists():
p.unlink()
logger.info("Voice geloescht: %s", p)
await handle_list_voices(ws)
except Exception:
logger.exception("handle_delete_voice Fehler")
# Letzte diagnostisch-gesetzte Voice (verhindert Endlos-Preload bei jedem config)
_last_diag_voice = ""
async def handle_voice_preload(ws, payload: dict, runner: F5Runner) -> None:
voice = (payload.get("voice") or "").strip()
request_id = payload.get("requestId", "")
logger.info("Voice-Preload angefordert: '%s'", voice or "default")
try:
ref_wav, ref_txt = voice_paths(voice) if voice else (None, None)
if voice and (not ref_wav or not ref_wav.exists()):
await _send(ws, "voice_ready", {"voice": voice, "requestId": request_id, "error": "voice-file-not-found"})
return
# Ref-Text sicherstellen (falls nur WAV da ist)
if voice and ref_txt and not ref_txt.exists():
text = await request_transcription(ws, ref_wav, language="de")
if text:
ref_txt.write_text(text.strip(), encoding="utf-8")
logger.info("Referenz-Text beim Preload nachgezogen")
# Dummy-Render zum Warmup
t0 = time.time()
await _do_tts(ws, runner, "ja.", voice, f"preload-{request_id}", "", "de")
ms = int((time.time() - t0) * 1000)
await _send(ws, "voice_ready", {"voice": voice, "requestId": request_id, "loadMs": ms})
except Exception as e:
logger.exception("Voice-Preload Fehler")
await _send(ws, "voice_ready", {"voice": voice, "requestId": request_id, "error": str(e)[:200]})
# ── Haupt-Loop ──────────────────────────────────────────────
async def _broadcast_status(ws, state: str, **extra) -> None:
"""Sendet service_status fuer das F5-TTS Modul.
state: 'loading' | 'ready' | 'error'."""
payload = {"service": "f5tts", "state": state}
payload.update(extra)
await _send(ws, "service_status", payload)
async def run_loop(runner: F5Runner) -> None:
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
global _last_diag_voice
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try:
logger.info("Verbinde zu RVS: %s", masked)
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
# Status-Broadcast: erst loading, dann ready nach erfolgreichem Load.
# Plus: config_request damit wir die persistierte Diagnostic-Config
# bekommen, falls aria-bridge ihre nicht von alleine sendet.
async def _load_with_status():
try:
if runner.model is not None:
logger.info("Initial: broadcaste ready (Modell schon im RAM: %s)", runner.model_id)
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
else:
logger.info("Initial: broadcaste loading + lade Modell '%s'", runner.model_id)
await _broadcast_status(ws, "loading", model=runner.model_id)
await runner.ensure_loaded()
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "f5tts"})
except Exception as e:
logger.exception("Initial-Load crashed: %s", e)
try:
await _broadcast_status(ws, "error", error=str(e)[:200])
except Exception:
pass
asyncio.create_task(_load_with_status())
# TTS-Worker fuer diese Verbindung starten
worker = asyncio.create_task(_tts_worker(ws, runner))
try:
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {}
if mtype == "xtts_request":
try:
speed = float(payload.get("speed") or 1.0)
except (TypeError, ValueError):
speed = 1.0
if not (0.1 <= speed <= 5.0):
speed = 1.0
await _tts_queue.put((
payload.get("text", ""),
payload.get("voice", "") or "",
payload.get("requestId", ""),
payload.get("messageId", ""),
payload.get("language", "de"),
speed,
))
elif mtype == "voice_upload":
asyncio.create_task(handle_voice_upload(ws, payload))
elif mtype == "xtts_list_voices":
asyncio.create_task(handle_list_voices(ws))
elif mtype == "xtts_delete_voice":
asyncio.create_task(handle_delete_voice(ws, payload))
elif mtype == "voice_preload":
asyncio.create_task(handle_voice_preload(ws, payload, runner))
elif mtype == "stt_response":
# Antwort auf unseren internen Transkriptions-Request
req_id = payload.get("requestId", "")
fut = _pending_stt.get(req_id)
if fut and not fut.done():
if payload.get("error"):
fut.set_result(None)
else:
fut.set_result(payload.get("text") or "")
elif mtype == "config":
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
async def _update_with_status(p):
# Schaut ob ein Modell-Wechsel ansteht — falls ja:
# erst loading-Status, dann update, dann ready.
old_model = (runner.model_id, runner.ckpt_file, runner.vocab_file)
new_model_id = (p.get("f5ttsModel") or runner.model_id,
p.get("f5ttsCkptFile", runner.ckpt_file) or "",
p.get("f5ttsVocabFile", runner.vocab_file) or "")
will_reload = old_model != new_model_id
if will_reload:
await _broadcast_status(ws, "loading", model=new_model_id[0])
try:
await runner.update_config(p)
if will_reload:
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
except Exception as e:
if will_reload:
await _broadcast_status(ws, "error", error=str(e)[:200])
asyncio.create_task(_update_with_status(payload))
# Voice-Preload bei Wechsel
v = (payload.get("xttsVoice") or "").strip()
if v and v != _last_diag_voice:
_last_diag_voice = v
asyncio.create_task(handle_voice_preload(
ws, {"voice": v, "source": "diagnostic"}, runner,
))
elif not v:
_last_diag_voice = ""
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning("Verbindung verloren: %s", e)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
async def main() -> None:
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
sys.exit(1)
VOICES_DIR.mkdir(parents=True, exist_ok=True)
runner = F5Runner()
await run_loop(runner)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)

View File

@ -0,0 +1,5 @@
f5-tts>=1.0.0
websockets>=12.0
numpy>=1.24
soundfile>=0.12
requests>=2.31

View File

@ -1,8 +0,0 @@
{
"name": "aria-xtts-bridge",
"version": "1.0.0",
"private": true,
"dependencies": {
"ws": "^8.16.0"
}
}

14
xtts/whisper/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py .
CMD ["python3", "bridge.py"]

309
xtts/whisper/bridge.py Normal file
View File

@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""
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.
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
WHISPER_MODEL Default: small
WHISPER_DEVICE Default: cuda
WHISPER_COMPUTE_TYPE Default: float16
WHISPER_LANGUAGE Default: de
"""
import asyncio
import base64
import json
import logging
import os
import subprocess
import sys
import tempfile
import time
from typing import Optional
import numpy as np
import websockets
from faster_whisper import WhisperModel
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("whisper-bridge")
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
class WhisperRunner:
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
def __init__(self) -> None:
self.model_size: str = WHISPER_MODEL
self.model: Optional[WhisperModel] = None
self._lock = asyncio.Lock()
def _load_blocking(self, size: str) -> None:
logger.info(
"Lade Whisper '%s' (device=%s, compute=%s)",
size, WHISPER_DEVICE, WHISPER_COMPUTE_TYPE,
)
t0 = time.time()
self.model = WhisperModel(
size, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE_TYPE,
)
self.model_size = size
logger.info("Whisper '%s' geladen in %.1fs", size, time.time() - t0)
async def ensure_loaded(self, desired_size: str) -> None:
if desired_size not in ALLOWED_MODELS:
logger.warning("Ungueltiges Whisper-Modell '%s' — nutze %s", desired_size, WHISPER_MODEL)
desired_size = WHISPER_MODEL
async with self._lock:
if self.model is not None and self.model_size == desired_size:
return
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]:
if self.model is None:
return "", 0.0
def _run():
segments, info = self.model.transcribe(
audio, language=language, beam_size=5, vad_filter=True,
)
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)
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
"""Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM."""
if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type:
ext = ".mp4"
elif "wav" in mime_type:
ext = ".wav"
elif "ogg" in mime_type or "opus" in mime_type:
ext = ".ogg"
else:
ext = ".bin"
in_fh = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
try:
in_fh.write(base64.b64decode(audio_b64))
in_fh.close()
out_path = in_fh.name + ".raw"
cmd = ["ffmpeg", "-y", "-i", in_fh.name, "-ar", "16000", "-ac", "1", "-f", "f32le", out_path]
result = subprocess.run(cmd, capture_output=True, timeout=30)
if result.returncode != 0:
logger.error("FFmpeg Fehler: %s", result.stderr.decode(errors="replace")[:300])
return np.zeros(0, dtype=np.float32)
try:
return np.fromfile(out_path, dtype=np.float32)
finally:
try:
os.unlink(out_path)
except OSError:
pass
finally:
try:
os.unlink(in_fh.name)
except OSError:
pass
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
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
if not audio_b64:
await _send(ws, "stt_response", {"requestId": request_id, "error": "no-audio"})
return
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)
await runner.ensure_loaded(model)
load_ms = int((time.time() - t_load) * 1000)
if needs_load:
await _broadcast_status(ws, "ready",
model=runner.model_size,
loadSeconds=load_ms / 1000.0)
audio = ffmpeg_to_float32(audio_b64, mime_type)
if audio.size == 0:
await _send(ws, "stt_response", {"requestId": request_id, "error": "ffmpeg-failed"})
return
duration_s = len(audio) / 16000.0
logger.info("STT-Request: %.1fs Audio, model=%s, lang=%s", duration_s, runner.model_size, language)
t_stt = time.time()
text, detected_duration = await runner.transcribe(audio, language)
stt_ms = int((time.time() - t_stt) * 1000)
logger.info("STT-Ergebnis (%dms): '%s'", stt_ms, text[:100])
await _send(ws, "stt_response", {
"requestId": request_id,
"text": text.strip(),
"durationS": duration_s,
"sttMs": stt_ms,
"loadMs": load_ms,
"model": runner.model_size,
})
except Exception as e:
logger.exception("STT-Request fehlgeschlagen")
await _send(ws, "stt_response", {
"requestId": request_id,
"error": str(e)[:200],
})
async def _broadcast_status(ws, state: str, **extra) -> None:
"""Sendet service_status fuer das Whisper-Modul.
state: 'loading' | 'ready' | 'error'."""
payload = {"service": "whisper", "state": state}
payload.update(extra)
await _send(ws, "service_status", payload)
async def run_loop(runner: WhisperRunner) -> None:
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
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
# 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:
logger.info("Initial: broadcaste ready (Modell schon im RAM: %s)", runner.model_size)
await _broadcast_status(ws, "ready", model=runner.model_size)
else:
init_model = runner.model_size or WHISPER_MODEL
logger.info("Initial: broadcaste loading (model=%s)", init_model)
await _broadcast_status(ws, "loading", model=init_model)
logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "whisper"})
except Exception as e:
logger.exception("Initial-Handshake crashed: %s", e)
asyncio.create_task(_initial_handshake())
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {}
if mtype == "stt_request":
req_id = payload.get("requestId", "?")
audio_len = len(payload.get("audio", ""))
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 == "config":
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",
new_model,
" (initial)" if runner.model is None else " (Wechsel)")
async def _swap_with_status(target):
await _broadcast_status(ws, "loading", model=target)
try:
t0 = time.time()
await runner.ensure_loaded(target)
elapsed = time.time() - t0
await _broadcast_status(ws, "ready",
model=runner.model_size,
loadSeconds=elapsed)
except Exception as e:
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)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
async def main() -> None:
if not RVS_HOST:
logger.error("RVS_HOST ist nicht gesetzt — Abbruch")
sys.exit(1)
runner = WhisperRunner()
await run_loop(runner)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)

View File

@ -0,0 +1,4 @@
faster-whisper==1.0.3
websockets>=12.0
numpy>=1.24
requests>=2.31