Compare commits

..
121 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.7 32ddac002f fix: stream_chunk_size auf 250 erhoeht — weniger Render-Artefakte
XTTS daswer123 erzeugt an Chunk-Grenzen oft Glitches in den Worten
die ueber die Grenze gehen. 100 → 250 = weniger Grenzen pro Satz =
sauberere Sprachausgabe. Erste-Audio-Latenz steigt um ein paar Sekunden,
was aber OK ist seit die App Pre-Roll gepuffert ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:56:00 +02:00
duffyduck bbbe69d928 release: bump version to 0.0.4.7 2026-04-22 18:46:25 +02:00
duffyduckandClaude Opus 4.7 23c39d5bba feat: Dezimalzahlen fuer TTS ausschreiben + Leading-Silence im Stream
- aria_bridge clean_text_for_tts: "0.1" / "0,5" / "1,25" wird jetzt als
  "null komma eins" / "null komma fuenf" / "eins komma zwei fuenf"
  ausgeschrieben. Lookahead verhindert Match auf IP-artige Strings.
- PcmStreamPlayer: 200ms Stille am Stream-Anfang, damit AudioTrack
  sauber anfaehrt und die ersten Worte nicht verschluckt werden.
  (XTTS-Warmup + play()-Startup-Latenz)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:44:38 +02:00
duffyduck 5328dc8595 release: bump version to 0.0.4.6 2026-04-22 18:32:31 +02:00
duffyduckandClaude Opus 4.7 0c03b4f161 fix: Stream-Ende wartet auf playbackHeadPosition vor release()
AudioTrack.stop() + release() direkt nach dem letzten write() killt die
letzten Sekunden Audio — die Samples sind zwar im Buffer, aber noch
nicht durch die Hardware rausgespielt. Deshalb brach die Sprachausgabe
mitten im Satz ab (z.B. bei "diesmal").

Fix: Writer-Thread wartet im finally-Block bis playbackHeadPosition die
Anzahl geschriebener Frames erreicht, dann erst stop()/release().
Safety: 2s Stall-Detection, falls AudioTrack haengen bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:31:12 +02:00
duffyduck 31fe70bab5 release: bump version to 0.0.4.5 2026-04-22 18:18:20 +02:00
duffyduckandClaude Opus 4.7 39251b3d32 feat: AudioTrack Pre-Roll — Playback startet erst nach 2.5s Vorrat
User-Diagnose: Erneutes Abspielen aus Cache funktioniert komplett, aber
Live-Stream bricht ab. Bedeutet: PCM kommt an, Cache ist okay — Problem
ist Buffer-Underrun im AudioTrack wenn XTTS (RTF 1.48 auf RTX 3060)
langsamer rendert als Echtzeit-Playback konsumiert.

Fix: AudioTrack.play() wird NICHT mehr sofort beim start() aufgerufen.
Stattdessen:
- start() baut AudioTrack, Writer-Thread startet, spielt aber noch nicht
- writeChunk() fuellt queue, Writer schreibt in AudioTrack-internen Buffer
  (blocked wenn der voll ist)
- Sobald bytesBuffered >= 2.5s Audio im Buffer: play() aufrufen
- Falls end() kommt bevor Pre-Roll erreicht (kurze Texte): trotzdem play()

Das gibt dem Stream Zeit Vorrat aufzubauen. XTTS kann dann pausieren
zwischen Text-Chunks ohne dass Playback stottert.

Pre-Roll 2.5s reicht fuer typische Render-Pausen zwischen Chunks.
Buffer groesse = 2x Pre-Roll damit wir auch extrem bursty Delivery
puffern koennen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:16:02 +02:00
duffyduckandClaude Opus 4.7 0623de32a0 tune: stream_chunk_size 200 -> 100 gegen 6s Initial-Latenz
Mit RTF 1.48 (RTX 3060) rechnet XTTS fuer 200 chars ca. 6s bis erster
PCM-Chunk rauskommt — User wartet nach ARIA-Antwort 6s auf Sprachausgabe.

stream_chunk_size=100: Erster Chunk in ~3s bereit, reduziert
Initial-Latenz um ~50%. 100 chars sind auch noch gross genug dass
der AudioTrack-Buffer (128KB ≈ 2.7s Audio) zwischen Render-Chunks
nicht leerlaeuft → kein mid-sentence Abbruch wie bei 40.

Falls bei bestimmten Texten doch Gaps: stream_chunk_size zurueck auf
150, oder pre-roll im Android PcmStreamPlayer einbauen (nur starten
wenn X ms gepuffert sind).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:08:10 +02:00
duffyduckandClaude Opus 4.7 cd5e6e7ee6 fix: stream_chunk_size 40 -> 200 gegen Audio-Abbrueche mid-sentence
Bei stream_chunk_size=40 teilte XTTS Text in ~40-char Batches.
Zwischen Batches pausiert XTTS (RTF 1.48 auf RTX 3060 → langsamer
als Realtime-Wiedergabe). AudioTrack-Buffer lief leer, Track
stoppte, nachkommender PCM kam zu spaet → Audio bricht mid-sentence
ab (User-Bug: bei 73-char Text Abbruch nach Wort 'diesmal' was genau
an der 40-char Grenze lag).

stream_chunk_size=200:
- Kurze Saetze (<200 chars) komplett in einem Render → kein Abbruch
- Laengere Texte: groessere Chunks, laenger Audio pro Chunk als
  Render-Pause → Buffer bleibt gefuellt
- Kompromiss: first-audio-latency etwas hoeher, aber keine Abbrueche

Wenn spaeter Audio-Abbrueche bei langen Texten: stream_chunk_size
noch groesser setzen ODER einen "pre-roll" Buffer in der App.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:06:25 +02:00
duffyduckandClaude Opus 4.7 ee3e0a0af6 fix: XTTS local-Mode per ENV statt command-Override
Das Image-Default-CMD liest Konfig aus ENV Variablen:
  CMD: ... -ms \${MODEL_SOURCE:-"apiManual"}

Also reicht MODEL_SOURCE=local — command bleibt Image-Default und wir
sparen uns den brueckigen Override der schief ging (python nicht da,
flag-Namen raten, etc.).

Zusaetzlich: EXAMPLE_FOLDER=/voices damit der Speaker-Folder auf unser
gemountetes /voices zeigt (sonst /app/example was nur die Demo-Voices
enthaelt).

Kein command override mehr noetig — das Image macht alles wie vorher,
nur mit local-Mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:59:39 +02:00
duffyduckandClaude Opus 4.7 0783b1b99d fix: XTTS command nutzt python3 statt python
Image hat nur /usr/bin/python3, kein 'python'-Symlink.
Vorher ging's weil kein command override — das Image-Default CMD
lief durch. Wir ueberschreiben nur damit wir -ms local setzen koennen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:58:29 +02:00
duffyduckandClaude Opus 4.7 5492c7a46f fix: XTTS command braucht 'python -m xtts_api_server' als erstes Arg
NVIDIA-Entrypoint fuehrt 'exec \$@' aus — erstes Arg muss ein
ausfuehrbares sein. Nur Flags zu geben ('--listen') fuehrt zu
'exec: --: invalid option'.

Fix: command=['python','-m','xtts_api_server','-ms','local',...]
Damit wird der xtts_api_server Python-Modul gestartet und im
local-Mode konfiguriert.

Ob die Flag-Namen exakt stimmen (-hs/-p/-ms/-o/-mf/-sf) — falls
nicht, poppt ein klarer Python-Fehler im Log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:49:29 +02:00
duffyduckandClaude Opus 4.7 4cbe184faa feat: XTTS auf local-Mode (dauerhaft im VRAM) + /tts_stream + Fallback
Root cause der langen Render-Zeiten und /tts_stream 400-Errors:
daswer123 default ist apiManual/api-Mode — Modell wird pro Request
gefetched/reloaded, Streaming unsupported.

Fix in xtts/docker-compose.yml:
  command: ['--listen', '-p', '8020', '-t', 'http://0.0.0.0:8020',
            '-ms', 'local',
            '-o', '/app/output', '-mf', '/app/xtts_models', '-sf', '/voices']

-ms local:
  - Modell dauerhaft im GPU-VRAM (~2GB, passt auf RTX 3060 mit 12GB)
  - Render startet sofort, kein per-Request-Load mehr
  - /tts_stream unterstuetzt → echtes progressive streaming
  - time-to-first-audio ~500ms statt 8-11s

xtts/bridge.js:
  /tts_stream primary, /tts_to_audio/ als Fallback wenn Stream fehlt.
  Robust: wenn User spaeter den Mode wieder umstellt, fallback greift.

Erste Ladung nach dem Wechsel dauert einmalig laenger (Modell ins VRAM
laden). Danach: schnell + streaming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:38:53 +02:00
duffyduckandClaude Opus 4.7 647a1cb726 fix: XTTS nutzt direkt /tts_to_audio/ — /tts_stream nicht verfuegbar
XTTS-Server (daswer123) im API-Modus antwortet auf /tts_stream mit:
  HTTP 400: "HTTP Streaming is only supported for local models"

Das Feature braucht MODE=local in der XTTS-Config (Modell direkt im
Server-Prozess). Userbetreibt im Remote-Modus → kein Streaming.

Der try /tts_stream + fallback /tts_to_audio Ansatz war reine Ver-
schwendung: jeder Request wartete 6ms auf 400, bevor der Fallback
griff. Jetzt geht's direkt an /tts_to_audio/.

Kein echtes Streaming, aber:
- Queue sorgt fuer sequentielle Verarbeitung (kein Overlap mehr)
- 32x AudioTrack-Buffer faengt den bursty Response ab
- aria-bridge spiegelt audio_pcm nicht mehr (kein Doppel-Audio)

Wenn User spaeter /tts_stream haben will:
  XTTS-Server mit MODE=local oder --streaming-mode starten,
  dann kann man /tts_stream als primary einfuehren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:23:55 +02:00
duffyduckandClaude Opus 4.7 73263b69a6 fix: /tts_stream — speaker_wav muss IMMER als query-param gesetzt sein
XTTS-Server (daswer123) markiert speaker_wav als required Pydantic-Feld.
Mein 'if (speakerWav) qs.set(...)' hat den Key bei default-voice
weggelassen → HTTP 422 'Field required, input: null' → Fallback auf
/tts_to_audio/ hat gegriffen, aber Streaming nie gefunden.

Log-Beweis vom User:
  XTTS /tts_stream 422: {"detail":[{"type":"missing","loc":["query",
    "speaker_wav"],"msg":"Field required","input":null}]}

Fix: Key immer setzen, leerer String bei default-voice. POST-Variante
(/tts_to_audio/ JSON-Body) hat das auch so akzeptiert — GET-Query nun
gleiches Verhalten.

Ab jetzt sollte /tts_stream endlich greifen und echte Streaming-Latenz
(~300-500ms) zeigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:47:28 +02:00
duffyduckandClaude Opus 4.7 c62ceafdc2 fix: XTTS-Endpoint mit Fallback-Chain + Diagnose-Logs
Problem: /tts_stream hat bei User nicht funktioniert → keine
Sprachausgabe mehr. Server hatte vorher 405 fuer POST geantwortet,
meine Umstellung auf GET scheint aber einen anderen Fehler zu
produzieren der nicht geloggt wurde.

Fix:
- streamXTTSAsPCM() = /tts_stream (GET, Streaming) mit ausfuehrlichem
  Error-Logging bei non-200 Response
- streamXTTSBatch() = /tts_to_audio/ (POST, Batch) als Fallback
- handleTTSRequest versucht Stream zuerst, bei Exception Fallback
  auf Batch — so gibt's IMMER Audio, auch wenn /tts_stream kaputt ist
- Log zeigt welcher Pfad benutzt wurde

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:53:10 +02:00
duffyduckandClaude Opus 4.7 9b5a35cb4a fix: /tts_stream als GET mit Query-Params (war 405 Method Not Allowed)
daswer123 xtts-api-server hat /tts_stream nur als GET:
  allow: GET → POST gab 405 → Request hing.

Umstellung:
- method: 'GET'
- text/language/speaker_wav/stream_chunk_size als URLSearchParams
  im Query-String
- kein body mehr (kein req.write, kein Content-Length)

Ab jetzt echter streaming-Flow: Samples kommen waehrend XTTS noch
rendert, time-to-first-audio ~300-500ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:52:16 +02:00
duffyduckandClaude Opus 4.7 5ac1a0a522 revert: XTTS-Endpoint zurueck auf /tts_to_audio/
/tts_stream war bei der aktiven daswer123-Version nicht erreichbar —
Requests hingen stille, App bekam kein Audio.

Zurueck auf /tts_to_audio/ + Queue + 32x AudioTrack-Buffer. Das ist
zwar nicht echt-streaming aber stabil. Ueberlappung sollte durch die
Queue weg sein, Buffer toleriert den bursty Delivery.

Echt-Streaming-Migration spaeter mit verifizierter Server-Version
oder anderem Endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:48:27 +02:00
duffyduck a28b46a809 release: bump version to 0.0.4.4 2026-04-20 16:42:19 +02:00
duffyduckandClaude Opus 4.7 59c8d36a3d fix: Streaming TTS nutzt jetzt echt den /tts_stream Endpoint von XTTS
Vorher: /tts_to_audio/ — XTTS rendert kompletten WAV BEVOR es
antwortet. Mein "streaming" war nur fake-chunking des fertigen WAV.
Time-to-first-audio = komplette Render-Zeit (2-4s), dann Burst,
dann Stille. Plus bei langen Antworten: Queue blockiert.

Jetzt: /tts_stream — daswer123's chunked-transfer endpoint.
Samples flutschen waehrend der Generierung durch die Response raus.

Parameter:
- stream_chunk_size=40 → XTTS rendert in ~40-char Haeppchen intern,
  time-to-first-audio ~300-500ms statt 2-4s
- WAV-Header kommt wie gewohnt am Anfang (44 Bytes), danach raw PCM
  → mein existierender Header-Parser + 8KB-Chunker passen weiter

Voraussetzung: daswer123/xtts-api-server hat diesen Endpoint (ab
Version ~0.8.x). Sollte bei der aktuellen Version drin sein.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:35:55 +02:00
duffyduck 79ba7b8487 release: bump version to 0.0.4.3 2026-04-20 08:01:46 +02:00
duffyduckandClaude Opus 4.7 ba62cec78c fix: Disk-Banner — Safe-Cleanup als Default + Aggressiv hinter Expander
Safe-Variante (Default):
  docker builder prune -a -f && docker image prune -a -f
  → Build-Cache + ungenutzte Images, KEINE Volumes angefasst.
  → 90% des Platzproblems geloest, Null Datenverlust-Risiko.

Aggressive Variante (nur auf Wunsch, hinter 'Mehr'-Button):
  docker system prune -a --volumes -f
  → Zusaetzlich ungenutzte Volumes.
  → Nur sicher wenn alle ARIA-Container LAUFEN (sonst werden
     openclaw-config/claude-config/aria-shared als "ungenutzt"
     behandelt und zerstoert — Sessions weg).
  → Hinweistext orange hervorgehoben mit Warnung.

Banner-Button 'Sicher aufraeumen' kopiert die sichere Variante.
'Mehr' klappt die Erklaerung der aggressiven Variante aus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:39:22 +02:00
duffyduckandClaude Opus 4.7 f15b3f583f feat: Disk-Space Banner im Diagnostic mit Cleanup-Command zum Kopieren
Server:
- checkDiskSpace() prueft alle 30s 'df -B1 /shared' (zeigt Host-Disk
  da /shared ein Volume auf dem Docker-FS ist)
- 4 Stufen: ok (<70%), info (70%), warn (85%), critical (95%)
- Broadcastet disk_status nur bei Aenderung (Level oder Prozent)
- currentDiskStatus wird gecached → neu verbundene Clients bekommen
  den aktuellen Stand sofort beim 'init'

UI:
- Sticky Banner ganz oben, versteckt wenn Disk ok
- Farbe nach Level: gelb (info), orange (warn), rot (critical)
- Zeigt Prozent, Used/Total/Avail in GB, konkrete Situation
- Cleanup-Command als monospace Code mit Copy-Button ('docker system
  prune -a --volumes -f') — Click auf Code oder Button kopiert ins
  Clipboard, Fallback auf Range-Selektion
- 'Schliessen' Button fuer temporaeres Ausblenden (kommt aber wieder
  bei naechster Aenderung)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:36:36 +02:00
duffyduckandClaude Opus 4.7 402bddc18a fix: Streaming TTS — Queue in XTTS-Bridge + groesserer Android-Buffer
1) Ueberlappende Streams
   Wenn zwei xtts_requests schnell hintereinander kamen, rannten
   sie parallel durch handleTTSRequest. Beide HTTP-Requests an XTTS
   liefen gleichzeitig, beide streamen PCM an App → Chunks aus BEIDEN
   Renders landeten interleaved in der AudioTrack-Queue → Chaos.

   Fix: ttsQueue als Promise-Chain — handleTTSRequest() haengt sich
   ans Ende der Kette an. Requests werden sequenziell abgearbeitet.

2) AudioTrack-Buffer zu klein fuer bursty Delivery
   XTTS /tts_to_audio/ ist NICHT echt streaming — der Server rendert
   intern den kompletten WAV und schickt ihn dann burst-weise. Der
   alte 8x-MinBuffer (ca 200-400ms) war zu klein um das abzufangen.

   Fix: Buffer auf 32x MinSize / mind. 128KB = ca. 2.7s bei 24kHz.
   Das toleriert typische XTTS-Render-Latenz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:27:16 +02:00
duffyduckandClaude Opus 4.7 350069d371 fix: Streaming TTS — doppeltes Audio + Gaps zwischen Saetzen
Zwei Probleme gefunden:

1) DOPPELTES AUDIO (Kern-Ursache der Artefakte)
   aria-bridge hat audio_pcm von XTTS-Bridge empfangen und per
   _send_to_rvs rebroadcastet. RVS broadcast geht an ALLE Clients
   ausser Sender — die App bekam jeden Chunk also zwei mal:
     XTTS-Bridge → RVS → App + aria-bridge
     aria-bridge → RVS → App (nochmal!) + XTTS-Bridge
   Zwei ueberlagerte PCM-Streams klingen wie Doubled/Artefakte.
   Fix: aria-bridge ignoriert audio_pcm jetzt. messageId schickt
   XTTS-Bridge selbst im Payload (via xtts_request -> messageId).

2) GAPS ZWISCHEN SAETZEN (abgehackt)
   xtts/bridge.js teilte Text in ~150-char Chunks und rief pro Chunk
   einen eigenen /tts_to_audio/ Request. Zwischen Chunks lag die
   XTTS-Render-Zeit (1-3s) → hoerbare Pausen.
   Fix: cleanText geht JETZT in einem Request komplett an XTTS.
   Ein zusammenhaengender Stream → keine Satz-Gaps mehr.
   Kompromiss: Erste Samples kommen spaeter (ganze Text-Render dauert
   laenger als der erste Satz alleine), aber dann kontinuierlich
   ohne Unterbrechung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:15:57 +02:00
duffyduck 019c078393 release: bump version to 0.0.4.2 2026-04-19 23:01:55 +02:00
duffyduckandClaude Opus 4.7 d411df4074 feat: Mode-Wechsel auch aus Diagnostic global syncronisiert
Vorher: Diagnostic's setMode sendete einen faked chat mit der
Aktivierungsphrase ('ARIA, Hangar-Modus') — das wurde erst in
_process_core_response auf dem ARIA-Antwort-Text detected, war
unzuverlaessig und nutzte nicht den sauberen mode-Message-Path.

Nachher: sauberer set_mode-Pfad mit Live-Sync.

diagnostic/server.js:
- Neue action 'set_mode' → sendet type=mode an RVS direkt
- RVS-Message-Handler: type=mode Broadcast von Bridge wird an
  Browser-Clients durchgereicht

diagnostic/index.html:
- setMode() nutzt jetzt action=set_mode (keine Phrase mehr)
- updateModeUI separat — wird bei Broadcast auch aufgerufen
- Mode-Broadcast vom Server syncs UI live (andere Diagnostic/App
  hat gewechselt → unser UI aktualisiert sofort)
- Button data-mode + MODE_LABELS auf kanonische IDs umgestellt
  (nicht_stoeren, fluester statt dnd, whisper)

bridge/modes.py:
- canonical_id() liefert die IDs die App + Diagnostic kennen
  (nicht_stoeren, fluester, ...) — damit Broadcast-ID zur UI-ID passt

bridge/aria_bridge.py:
- _broadcast_current_mode nutzt canonical_id statt enum.name.lower()

Flow jetzt:
  Diagnostic wechselt Mode → set_mode → Bridge → persist + broadcast
  → alle Apps + alle Diagnostic-Browser-Tabs aktualisieren sofort

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:57:41 +02:00
duffyduckandClaude Opus 4.7 763e0d79ab feat: Betriebsmodus global + persistent + Live-Sync
Vorher:
- Modus war nur in-memory in der Bridge, Restart = zurueck auf NORMAL
- App-Wechsel wurde zwar empfangen, aber nicht an andere Geraete
  gebroadcastet (nur Bestaetigung an den Sender)
- Neue App-Verbindung wusste nicht welcher Modus gerade aktiv ist

Jetzt:
- Persistiert in /shared/config/mode.json beim Wechsel
- Beim Bridge-Start: _load_persisted_mode() holt letzten aktiven Modus
- _broadcast_current_mode() sendet an ALLE Clients (Broadcast) —
  jedes verbundene Geraet bekommt live den Wechsel mit
- Bei RVS-Reconnect: sofortiger Broadcast damit neu verbundene Apps/
  Diagnostic ihre UI syncen koennen
- Loop-Schutz: payload.sender=="bridge" wird im mode-Handler ignoriert
  (sonst echo → Broadcast-Storm bei verbundenem RVS)

Beispiel-Flow:
  Geraet A aktiviert 'Hangar'
  → Bridge empfaengt mode-msg
  → persist in mode.json
  → broadcast an alle Clients (mit sender="bridge")
  → Geraet B/C/Diagnostic empfangen → UI updated sofort
  → Bridge-Restart spaeter: HANGAR wird wieder geladen

Anmerkung zu echten OS-Push bei geschlossener App:
Das braucht FCM/Firebase + BackgroundService — deutlich mehr Arbeit,
ist separat als Feature fuer spaeter zu sehen. Live-Sync bei geoeffneter
App (WebSocket verbunden) funktioniert jetzt zuverlaessig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:53:47 +02:00
duffyduckandClaude Opus 4.7 47fe4ad655 fix: Modus-Wechsel via App/ID funktioniert jetzt
Bug: App ModeSelector sendet rvs.send('mode', { mode: 'normal' })
mit ID, Bridge's detect_mode_switch() sucht aber nach Aktivierungs-
phrasen wie 'aria, normal-modus' → kein Match → Modus-Wechsel
wurde ignoriert, TTS-Verhalten blieb auf NORMAL haengen.

Fix:
- modes.py: mode_from_id() mappt IDs zu Mode-Enum
  ('normal', 'dnd', 'nicht_stoeren', 'fluester', 'whisper',
   'hangar', 'gaming' — flexibel)
- aria_bridge.py: mode-Handler versucht erst ID-Mapping, dann
  Phrasen-Erkennung als Fallback
- Unbekannte Modi werden geloggt
- Bestaetigung wird an alle Clients zurueckgesendet damit App-UI
  synchron bleibt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:51:18 +02:00
duffyduckandClaude Opus 4.7 99cb83202e feat: App XTTS-Voice-Auswahl + Aufnahme + Loeschen (geraetelokal)
App Settings: Voice-Sektion (nur wenn TTS an)
- Liste aller XTTS-Server-Stimmen mit Auswahl-Radio + X zum Loeschen
- 'Standard' fuer Diagnostic-Default-Voice (keine lokale Ueberschreibung)
- 'Aktualisieren' Button laedt Liste neu (xtts_list_voices via RVS)
- 'Eigene Stimme aufnehmen' oeffnet VoiceCloneModal

VoiceCloneModal: 30s Aufnahme + Upload
- Vorlese-Text (>30s Lesedauer, thematisch passend)
- Rot-pulsierender Stop-Button, live Timer + Progressbar
- Auto-Stop bei 30s, Hinweise ab 15s ('genug fuer gute Clonung')
- Nach Stop: Namenseingabe (a-Z, 0-9, _, -), Upload via voice_upload
- Nach Upload: Modal schliesst, Settings bekommt xtts_voice_saved
  und setzt automatisch die neue Stimme als gewaehlt

Voice-Flow App → Bridge → XTTS (geraetelokal):
- Jeder chat/audio/tts_request schickt aria_xtts_voice (AsyncStorage)
  mit der Message mit
- Bridge speichert _next_voice_override bei chat/audio Empfang,
  nutzt es fuer die naechste ARIA-Antwort und resettet dann
- Fallback: globale xtts_voice aus voice_config.json (Diagnostic)

Ergebnis:
- Gerat A hat 'stefan' geclont → ARIA antwortet Geraet A mit stefan
- Gerat B hat nichts gewaehlt → ARIA antwortet Geraet B mit Default
- Diagnostic-Einstellung wirkt als fallback-default fuer neue Geraete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:48:24 +02:00
duffyduckandClaude Opus 4.7 fc2438be2d fix/feat: XTTS-Voice korrekt persistiert, Loeschen + Voice-per-Request
Bug-Fix: Voice-Auswahl verschwand nach Page-Load
- xtts_voices_list Handler rebuildet das Dropdown — vorheriger select.value
  ging dabei verloren. Jetzt wird der Wert gemerkt und nach Rebuild
  wiederhergestellt (falls die Stimme noch existiert).

Feature: Stimmen loeschen (Diagnostic)
- XTTS-Bridge: neuer handleDeleteVoice — entfernt /voices/<name>.wav
  und schickt aktualisierte Liste per xtts_voices_list
- RVS: xtts_delete_voice in ALLOWED_TYPES
- Diagnostic Server: Action xtts_delete_voice forwarded via RVS
- Diagnostic UI: renderVoiceList zeigt alle Custom-Voices mit X-Button
  Bei Loeschen der gerade aktiven Stimme: auf Default zuruecksetzen

Feature: Voice-per-Request in Bridge
- App kann mit jedem Chat ein voice-Feld mitschicken
- Bridge merkt sich _next_voice_override, nutzt es fuer die NAECHSTE
  ARIA-Antwort (einmalig, dann reset)
- tts_request (Play-Button) akzeptiert voice im Payload als Override
- Fallback: globale xtts_voice aus voice_config.json
- So kann jedes Geraet seine eigene Stimme haben ohne den globalen
  Default zu aendern

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:43:26 +02:00
duffyduckandClaude Opus 4.7 40e48b046b feat: App TTS-Einstellungen vereinfacht + Mund-Button fuer lokales Muten
SettingsScreen:
- Piper-Reste entfernt (defaultVoice, highlightVoice, Speed-Slider,
  Highlight-Trigger-Info)
- Nur noch EIN Toggle 'Sprachausgabe auf diesem Geraet' — geraetelokal,
  persistent in aria_tts_enabled (AsyncStorage)
- Keine Config-Propagation mehr via RVS (das waere ja global gewesen)
- Hinweis dass Stimme + Voice-Cloning zentral in der Diagnose sind

ChatScreen: Mund-Button (👄 / 🤐)
- Neben Ohr-Button im Eingabebereich, NUR sichtbar wenn TTS im Setting
  grundsaetzlich aktiv ist
- Tap toggelt Mute: 👄 an / 🤐 rot gemutet
- Persistent in aria_tts_muted (AsyncStorage)
- Stoppt bei Muten sofort laufende Wiedergabe (stopPlayback)
- Settings-Toggle wird alle 2s gepollt damit Aenderungen greifen
  (einfache Loesung ohne globalen State-Context)

Audio-Handling respektiert lokalen Zustand
- Incoming audio/audio_pcm: nur abspielen wenn ttsDeviceEnabled && !ttsMuted
- Cache wird TROTZDEM immer geschrieben — Play-Button funktioniert
  spaeter aus Cache, auch waehrend Mute
- audioService.handlePcmChunk akzeptiert silent-Flag: skipt AudioTrack
  aber baut weiterhin den WAV-Cache pro messageId

Jedes Android-Geraet mit der App hat seinen eigenen Mute-Zustand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:33:36 +02:00
duffyduckandClaude Opus 4.7 f801d99748 feat: Piper komplett entfernt — nur noch XTTS v2 als TTS
Breaking Change: wenn XTTS-Bridge (Gaming-PC) offline ist, bleibt ARIA
stumm. Chat-Antworten kommen weiter an, aber kein Audio. Das ist
bewusst akzeptiert — XTTS klingt einfach grauenhaft viel besser.

Bridge (aria_bridge.py):
- from piper import ... raus
- VoiceEngine-Klasse komplett entfernt (synthesize, speak, select_voice)
- EPIC_TRIGGERS + load_epic_triggers raus (Highlight-Voice-Feature
  ohne Piper sinnlos)
- self.voice_engine, voice_name, requested_voice Aufrufe weg
- _process_core_response: immer XTTS, kein Fallback
- tts_request Handler: immer XTTS
- config Handler: nur ttsEnabled + xttsVoice + whisperModel
- import wave raus

bridge/requirements.txt: piper-tts raus
bridge/Dockerfile: Kommentar aktualisiert
docker-compose.yml: ./aria-data/voices Mount raus
aria-data/config/aria.env.example: PIPER_RAMONA/PIPER_THORSTEN raus
get-voices.sh: komplett geloescht (war nur Piper-Downloader)

Diagnostic UI (index.html):
- Piper Panel (Standard-Stimme / Highlight-Stimme / Speed-Sliders) weg
- TTS Engine Dropdown weg (immer XTTS)
- TTS Diagnose Tab zeigt nur noch XTTS-Status + Test-Button
- sendVoiceConfig sendet nur noch ttsEnabled/xttsVoice/whisperModel
- toggleXTTSPanel als no-op Legacy-Stub (JS-Calls bleiben safe)

Diagnostic Server (server.js):
- handleSendVoiceConfig: nur noch ttsEnabled + xttsVoice + whisperModel
- handleTestTTS: via xtts_request (nicht mehr Piper subprocess)
- handleCheckTTS: via xtts_list_voices ueber RVS
- handleGetVoiceConfig/Defaults bereinigt
- Highlight-Trigger UI bleibt, wird aber von Bridge nicht mehr
  ausgewertet (dead-code im UI, spaeter ggf. fuer XTTS-Voice-Switch)

README + issue.md aktualisiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:24:42 +02:00
duffyduckandClaude Opus 4.7 6ab6196739 feat: Streaming TTS — PCM-Stream statt WAV-Chunks (Weg A)
Pipeline: XTTS-Server → xtts-bridge → aria-bridge → RVS → App AudioTrack

XTTS-Bridge (Gaming-PC):
- streamXTTSAsPCM(): liest /tts_to_audio/ Response inkrementell,
  parst WAV-Header (samplerate/channels), teilt PCM in 8KB-Chunks
  (~170ms bei 24kHz s16 mono) und sendet jeden als audio_pcm.
- Finaler Chunk mit final=true nach letztem Text-Chunk

aria-bridge:
- audio_pcm Handler leitet payload 1:1 weiter, filled messageId aus
  requestId → messageId Map falls XTTS-Bridge messageId nicht hatte
- Alter xtts_response Pfad bleibt als Legacy-Fallback (WAV)

RVS: audio_pcm in ALLOWED_TYPES

Android Native:
- PcmStreamPlayerModule (Kotlin): AudioTrack MODE_STREAM mit
  Writer-Thread und BlockingQueue. start(rate, ch) / writeChunk(b64)
  / end() / stop()
- 8x MinBufferSize grosszuegig dimensioniert, glatt auch bei
  Netz-Aussetzern
- Registered im MainApplication via PcmStreamPlayerPackage

App JS:
- audioService.handlePcmChunk(): erkennt neue Session (messageId-Wechsel),
  started nativen Stream, cached PCM-Bytes pro Message. Bei final=true
  Stream sauber schliessen + _savePcmBufferAsWav → WAV-File im
  tts_cache/<messageId>.wav
- _savePcmBufferAsWav: baut 44-byte WAV-Header (PCM s16le, korrekte
  samplerate/channels), haengt alle gesammelten base64-PCM-Chunks an
- stopPlayback beendet auch aktiven PCM-Stream
- ChatScreen routet type=audio_pcm an handlePcmChunk, bei final
  setzt audioPath in der Message

Play-Button: falls messageId einen audioPath hat → WAV aus Cache
(Sound-basiert), egal ob Original-TTS Piper oder XTTS war.

Audio-Focus:
- requestDuck() beim Stream-Start, release() bei Stream-Ende
- Andere Apps (Spotify etc.) werden leiser waehrend ARIA spricht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:01:27 +02:00
duffyduckandClaude Opus 4.7 eb12281dfc feat: TTS-Zeitbereiche + Diagnostic-Debug-Toggle + Play-Button respektiert Engine
TTS-Cleanup erweitert:
- Zeitbereiche: '8:00-9:00 Uhr' / '8-9 Uhr' → 'acht bis neun Uhr'
- Uhrzeiten: '8:30 Uhr' → 'acht Uhr dreissig', '15 Uhr' → 'fuenfzehn Uhr'
- Kleine Zahlen-Bereiche: '5-6' → 'fuenf bis sechs' (nur ≤24)
- Zahlen 0-59 als deutsche Woerter (inkl. 'einundzwanzig', 'fuenfundvierzig')

Diagnostic: TTS-Debug Einblenden
- Checkbox 'TTS-Text einblenden' in der Chat-Test Kopfzeile
- Unter ARIA-Nachrichten erscheint die aufbereitete Variante
  (blauer Border + Label 'TTS:')
- Nur in Diagnostic, nicht in der App
- LocalStorage persistiert den Toggle-Zustand
- Minimaler JS-Port von clean_text_for_tts als Fallback

Play-Button respektiert Engine:
- Bridge: tts_request nutzt jetzt die aktive TTS-Engine (Piper/XTTS),
  Text wird durch clean_text_for_tts aufbereitet
- messageId wird vom Play-Button mitgeschickt → Bridge verknuepft
  generiertes Audio mit der urspruenglichen Message
- XTTS-Chunks: requestId → messageId Map (LRU 100 Eintraege),
  beim xtts_response wird die Basis-UUID extrahiert und die
  messageId dem audio-Frame angehaengt
- App cached auch XTTS-Audio jetzt (letzter Satz pro Message —
  echte Chunk-Konkatenation bleibt TODO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:48:32 +02:00
duffyduck 1fb1fdef9e release: bump version to 0.0.4.1 2026-04-19 21:00:49 +02:00
duffyduckandClaude Opus 4.7 593d26e0ff fix: QR-Code overflowed Container — auf SVG umgestellt mit width/height 100%
Der QR wurde mit createImgTag() als fester Pixel-IMG gerendert und
ueberlappte den Warnhinweis + Button rechts daneben. Fix:

- createSvgTag mit cellSize=4 + scalable=true
- SVG skaliert auf width:100%/height:100% der 220x220 Box
- Container: flex-shrink:0 (damit Flex ihn nicht weiter schrumpft)
- overflow:hidden als Sicherheit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:28:41 +02:00
duffyduckandClaude Opus 4.7 394abb58be fix: Runtime-Config Layout + Eye-Toggle fuer Token-Felder
- Eingabefelder haben jetzt width:100% + box-sizing:border-box,
  keine Ueberlappung mehr im Grid
- Token-Felder haben einen Augen-Button daneben (👁/👀) zum
  Anzeigen/Verbergen des Inhalts
- Kleineres Label-Grid (140px statt 150px), grosszuegigerer Gap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:27:56 +02:00
duffyduckandClaude Opus 4.7 fc3bee6d05 feat: Runtime-Config via Diagnostic UI — kein .env-Sync mehr
Framework fuer zentrale Runtime-Konfiguration:
- /api/runtime-config (GET/POST) persistiert in /shared/config/runtime.json
- Werte haben Vorrang ueber die ENV-Variablen aus aria.env
- Feldliste: RVS_HOST/PORT/TLS/TOKEN, ARIA_AUTH_TOKEN, WHISPER_MODEL/LANGUAGE
- Atomic write (tmp + rename) fuer Konsistenz

Bridge:
- load_config() liest nach aria.env noch runtime.json und ueberschreibt
  die Werte. Aenderungen werden beim Neustart der Bridge uebernommen.

Diagnostic UI:
- Neue Sektion "Runtime-Konfiguration" in Einstellungen
- Formular fuer RVS-Credentials + Aria-Auth-Token
- "Speichern" persistiert, triggert auch QR-Code-Regenerierung
- Hinweis: Diagnostic-Container selbst bleibt auf ENV (erstmal)

issue.md konsolidiert — 6 groessere Tasks dieser Session als erledigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:18:37 +02:00
duffyduckandClaude Opus 4.7 b203503fd8 feat: QR-Code Onboarding + TTS-Audio-Cache im Filesystem
QR-Code Onboarding
- Diagnostic: GET /api/onboarding gibt RVS-Credentials zurueck
- Einstellungen-UI: neue Sektion mit QR-Code (qrcode-generator via CDN)
- Format kompatibel mit bestehendem QRScanner.parseQRData (host/port/tls/token)
- App-SettingsScreen hatte QR-Scanner bereits — funktioniert out of the box
- Warnhinweis zu Token im Klartext

TTS-Audio-Cache
- Bridge: jede ARIA-Chat-Nachricht bekommt eine messageId (UUID)
  Audio-Payload wird mit messageId verknuepft (Piper-Pfade)
- ChatScreen: messageId + audioPath in ChatMessage Interface
- audioService.cacheAudio(): speichert Base64 in DocumentDirectory/tts_cache/<id>.wav
- audioService.playFromPath(): spielt aus Cache ohne Regenerierung
- Play-Button: wenn audioPath gesetzt → aus Cache, sonst tts_request
- cleanupOldTTSCache(): alte unreferenzierte WAVs (>30 Tage) weg
- Persistiert via AsyncStorage — ueberlebt App-Restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:16:25 +02:00
duffyduckandClaude Opus 4.7 8b0a72dc9b feat: NO_REPLY-Filter + Audio-Ducking + TTS-Cleanup
1) NO_REPLY Token wird in Bridge und Diagnostic erkannt und still
   verworfen. Toleranz fuer Variationen (Whitespace, Punkt, Quotes).
   Kein Chat-Eintrag, kein TTS.

2) AudioFocusModule (Kotlin) mit requestDuck / requestExclusive /
   release. AudioService ruft:
   - requestExclusive() bei Aufnahme-Start → andere Apps pausieren
   - requestDuck() bei TTS-Playback-Start → andere Apps leiser
   - release() bei Stop/Queue-Ende
   MainApplication registriert AudioFocusPackage.

3) clean_text_for_tts() in Bridge — zentrale Aufbereitung:
   - <voice>...</voice> Tag wird bevorzugt (falls ARIA es schreibt)
   - Code-Bloecke (``` und `) komplett raus
   - Markdown (Fett/Kursiv/Links/Headings/Listen) geschleift
   - Einheiten ausgeschrieben: 22GB → 22 Gigabyte, 85% → 85 Prozent
   - Abkuerzungen buchstabiert: CPU → C P U, API → A P I
   - URLs durch "ein Link" ersetzt
   Genutzt in VoiceEngine.synthesize und im XTTS-Request — Chat-Text
   an die App bleibt unveraendert (original Markdown), nur TTS kriegt
   die aufbereitete Version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:10:54 +02:00
duffyduckandClaude Opus 4.7 23add7a107 docs: Neue Issues - TTS-Cleanup, Audio-Ducking, Config-UI, NO_REPLY-Bug
- Bug: NO_REPLY wird als "NO" angezeigt
- Audio-Ducking + Mute waehrend Aufnahme
- Spracheingabe-Timeout erhoehen
- TTS-Text-Aufbereitung (Code raus, Einheiten ausschreiben)
- Audio-Cache in Messages (kein Regenerieren beim Play-Button)
- Piper evtl. entfernen
- .env → Diagnostic UI migrieren
- QR-Code Onboarding
- XTTS Web-Oberflaeche / zentral via Diagnostic
- Root-Cause OpenClaw Session-Reset pruefen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:04:47 +02:00
duffyduckandClaude Opus 4.7 caf84196fb fix: Reset-File-Regex - Timestamp endet mit Z (ohne Punkt davor)
Die OpenClaw Reset-Files heissen <uuid>.jsonl.reset.<iso>Z
(nicht <uuid>.jsonl.reset.<iso>.Z). Der falsche Regex matchte
nie, alle Archive wurden als "verwaist" angezeigt statt als "archiv".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:32:24 +02:00
duffyduck 099b9651a6 release: bump version to 0.0.4.0 2026-04-18 12:28:08 +02:00
duffyduckandClaude Opus 4.7 76d72a1eef feat: Archivierte Session-Versionen (OpenClaw .reset.* Files) in Diagnostic
OpenClaw resettet Sessions beim ersten chat.send nach Container-Restart
(wenn abortedLastRun / systemSent Inkonsistenz erkannt wurde) und
benennt die alte .jsonl in .jsonl.reset.<timestamp>.Z um. Der Inhalt
war also gar nicht verloren, nur unsichtbar.

Diagnostic:
- handleListSessions scannt jetzt auch *.jsonl.reset.* Files
- Reset-Files bekommen archived:true + resetAt-Timestamp
- Neue UI-Sektion "Archivierte Versionen" (collapsible <details>)
  mit Export-Button, zeigt aufklappbar alle gesicherten alten Sessions
- Aktivieren ist fuer Archive deaktiviert (zerstoert aktive Session)
- Loeschen + Export stehen zur Verfuegung

tools/export-jsonl-to-md.js:
- Standalone Node-Script zum Konvertieren beliebiger .jsonl (auch reset-Files)
- Nutzbar via stdin, exakt gleiche Export-Logik wie Diagnostic
- Fuer Rettungsaktionen direkt auf der VM

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:24:54 +02:00
duffyduckandClaude Opus 4.7 87deede078 fix: Session Msgs-Counter zaehlt echte Nachrichten, nicht alle Zeilen
Vorher: wc -l auf der .jsonl — zaehlt auch Tool-Calls, Run-Events,
Metadata-Eintraege mit. Diagnostic zeigte z.B. "10 Msgs" fuer eine
Session mit 6 echten User/Assistant-Nachrichten.

Jetzt: grep -cE '"role":"(user|assistant)"' — zaehlt nur echte
Konversations-Messages. Matcht wie der Export und die Chat-History
das interpretieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:09:01 +02:00
duffyduckandClaude Opus 4.7 6fec8588c1 fix: Gespraechsmodus - strenger Speech-Gate + Crash-Prevention
Probleme:
- Hintergrundgeraeusche wurden als Sprache erkannt und an Whisper geschickt
- App stuerzte nach laengerem Zuhoeren ab (OOM / Cache-Ueberlauf)

Aenderungen:
- VAD_SPEECH_THRESHOLD_DB -35 -> -28 (filtert Raum-Ambient)
- VAD_SPEECH_MIN_MS 300 -> 500 (keine Huestler/Klopfer mehr)
- Max-Aufnahmedauer 30s (Notbremse gegen Runaway-Loops)
- _cleanupStaleCacheFiles(): alte aria_recording_/aria_tts_ Files (>30s)
  werden vor jeder neuen Aufnahme geloescht
- ChatScreen: capMessages() begrenzt Messages-Array auf 500 Eintraege
  (OOM-Schutz in langen Gespraechen)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:05:15 +02:00
duffyduckandClaude Opus 4.7 aafdbcd57a fix: Thinking indicator beim Seitenladen nicht mehr sichtbar
Zwei display:-Deklarationen im inline-style der Diagnostic-Chat-Leiste
haben sich gegenseitig ueberschrieben — 'display:flex' war die zweite
und hat 'display:none' aushebelt. Indicator war so beim Seitenaufbau
sichtbar bis JS ein idle-Event empfing.

- HTML: 'display:flex' aus inline-style entfernt
- JS: beim Anzeigen explizit display='flex' setzen (statt 'block')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:58:39 +02:00
duffyduck 08da28f475 release: bump version to 0.0.3.9 2026-04-18 11:52:53 +02:00
duffyduckandClaude Opus 4.7 8c1014d281 fix: Thinking indicator respringt nach chat:final durch trailing events
Nach chat:final kommen oft noch agent-Events rein (Core raeumt nach),
die den Thinking-Indicator wieder anspringen liessen.

- Diagnostic: 3s-Settled-Window nach chat:final, agent_activity-Broadcasts
  werden in dem Fenster unterdrueckt (idle kommt weiter durch).
- Bridge: Gleiches Fenster in _emit_activity() — App bekommt keine
  trailing thinking/tool-Events mehr nach dem finalen Antwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:51:22 +02:00
duffyduckandClaude Opus 4.7 271fc4edf6 docs: cleanup.sh + README updates for latest features
- cleanup.sh: sicherer (default) + aggressiver (--full) Docker-Cleanup
  mit Speicher-Report vor/nach
- README: Phase-1-Liste, Diagnostic-Features und App-Features um die
  neuen Punkte ergaenzt (Speech Gate, Session-Persistenz, Session-Export,
  App Thinking-Indicator, Whisper-Modellauswahl, 16kHz-Aufnahme)
- README: Neuer Abschnitt "Docker-Cleanup" mit cleanup.sh Usage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:46:12 +02:00
duffyduck cd390a4115 release: bump version to 0.0.3.8 2026-04-18 11:41:12 +02:00
duffyduckandClaude Opus 4.7 a65ed579d2 feat: Whisper model selector + 16kHz mono recording
- App: AudioSamplingRateAndroid 16000 + AudioChannelsAndroid 1
  → Whisper bekommt direkt sein Ziel-Format, kein Resample mehr
- Bridge: STTEngine.reload() laedt Modell zur Laufzeit neu
  (tiny/base/small/medium/large-v3)
- Bridge: Config-Message triggert Hot-Reload wenn whisperModel sich aendert
- Bridge: Default auf 'medium' (besser als 'small' bei aehnlicher Latenz)
- Diagnostic: Neue Sektion "Whisper (Spracherkennung)" mit Dropdown,
  auto-save bei Auswahl, beim Laden wird der gespeicherte Wert gesetzt
- Diagnostic/Server: send_voice_config merged whisperModel in voice_config.json
- aria.env.example: WHISPER_MODEL + WHISPER_LANGUAGE dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:37:27 +02:00
duffyduckandClaude Opus 4.7 2ad1f57382 feat: Thinking indicator + cancel button in the app
- Bridge: _emit_activity() spiegelt OpenClaw agent events als agent_activity
  an RVS, dedupliziert State-Wechsel. chat:final/error senden idle.
- Bridge: Neuer cancel_request-Handler ruft Diagnostic /api/cancel per HTTP.
- Diagnostic: Neuer POST /api/cancel Endpoint (gleiche Logik wie WS-Cancel).
- RVS: agent_activity + cancel_request in ALLOWED_TYPES.
- App: Gelber Indicator ueber der Input-Bar mit Text je nach Activity,
  roter Abbrechen-Button. Cancel sendet cancel_request via RVS.
- issue.md: Erledigte Bugfixes + Features konsolidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:22:02 +02:00
duffyduckandClaude Opus 4.7 58e3cfd3e6 feat: Session export as markdown in Diagnostic
- ⬇ Button per Session-Zeile — exportiert auch inaktive Sessions
- Server parst JSONL, extrahiert User/Assistant-Nachrichten mit Timestamp
- Metadata-Prefix wird entfernt, Markdown mit # Session-Header generiert
- Browser-Download via Blob + download-Attribut

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:14:15 +02:00
duffyduckandClaude Opus 4.7 7de4ee8f5b fix: Stuck "ARIA denkt..." indicator after pipeline ends
- pipelineEnd() now broadcasts agent_activity: idle unconditionally
- chat:error and chat:final paths broadcast idle outside of active pipeline
- Gateway close event ends active pipeline + broadcasts idle
- Prevents indicator from hanging after timeout/error/disconnect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:11:12 +02:00
duffyduckandClaude Opus 4.7 213edac3a7 fix: Session persistence - respect user choice across container restarts
- sessionFromFile flag prevents auto-pick after first start
- Atomic write (temp + rename) with loud error logging
- Auto-pick filters out aria-bridge/aria-diagnostic when user sessions exist
- handleSetActiveSession reports persistence failures to client

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:03:26 +02:00
duffyduckandClaude Opus 4.6 acc13aef6b fix: Speech gate - only send recording if actual speech detected
- VAD_SPEECH_THRESHOLD_DB = -35 (louder than silence threshold)
- Needs 300ms of speech before counting as real speech
- Recording discarded if only background noise detected
- Prevents sending garbage to Whisper in conversation mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:20:05 +02:00
duffyduck 4bbc6f7787 release: bump version to 0.0.3.7 2026-04-11 13:18:17 +02:00
duffyduckandClaude Opus 4.6 20f2ea1829 fix: Conversation mode starts recording immediately when ear button tapped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:15:26 +02:00
duffyduckandClaude Opus 4.6 2d23f0668b docs: update README with conversation mode, multi-attachments, markdown cleanup
- Conversation mode (ear button) documented in App Features
- Multiple attachments + paste support
- Markdown cleanup for TTS
- Auto-Update FileProvider + check button
- Roadmap: 22 items in Phase 1 completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:43:09 +02:00
duffyduckandClaude Opus 4.6 d6030a06b7 docs: update issue.md - move completed items, clean up open list
28 items completed, 10 remaining open

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:23:04 +02:00
duffyduck 0df76e2af6 release: bump version to 0.0.3.6 2026-04-11 12:19:00 +02:00
duffyduckandClaude Opus 4.6 f80fe1df93 fix: Inverted FlatList - newest messages always visible at bottom
- No more scrollToEnd/scrollToIndex needed
- FlatList inverted=true with reversed data
- New messages appear at bottom automatically
- User scrolls up to see history (natural chat behavior)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:17:32 +02:00
duffyduck cff421bc53 release: bump version to 0.0.3.5 2026-04-11 12:13:41 +02:00
duffyduckandClaude Opus 4.6 bca925d385 fix: Use scrollToIndex with viewPosition:1 for reliable bottom scroll
- scrollToIndex targets last message at bottom of viewport
- onScrollToIndexFailed fallback to scrollToEnd
- More reliable than scrollToEnd with dynamic heights

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:12:24 +02:00
duffyduck 9abde89805 release: bump version to 0.0.3.4 2026-04-11 12:09:23 +02:00
duffyduckandClaude Opus 4.6 ea4f639fcb fix: Auto-scroll retry with multiple delays (100, 300, 600, 1000ms)
FlatList needs time to render - single setTimeout(150) was unreliable.
Now tries 4 times on initial load, 2 times for new messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:07:54 +02:00
duffyduck 64cd5f7d52 release: bump version to 0.0.3.3 2026-04-11 12:04:37 +02:00
duffyduckandClaude Opus 4.6 843ebe1d8f fix: Remove duplicate closure ending in ChatScreen (build error)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:03:20 +02:00
duffyduckandClaude Opus 4.6 764619f076 fix: Comprehensive markdown/formatting cleanup for TTS (Piper + XTTS)
- Remove **bold**, *italic*, `code`, code blocks, links, headers, quotes, lists
- Replace newlines with natural pauses (period/comma)
- Remove quotation marks, empty brackets
- Fixes text being swallowed/garbled by TTS engines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:47:04 +02:00
duffyduckandClaude Opus 4.6 e3a0cfb55a docs: mark conversation mode as done, keep Porcupine as Phase 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:42:33 +02:00
duffyduckandClaude Opus 4.6 2929749314 feat: Conversation mode (ear button) - auto-record after ARIA speaks
- Ear button activates conversation mode (green dot)
- After TTS playback finishes → 800ms pause → auto-start recording
- VAD stops recording on silence → sends to ARIA → ARIA answers → TTS → loop
- Like a natural conversation / walkie-talkie mode
- Audio service fires onPlaybackFinished when queue empty

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:40:55 +02:00
duffyduckandClaude Opus 4.6 51b9512f4e docs: mark scroll bugs as fixed in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:37:53 +02:00
duffyduckandClaude Opus 4.6 ffcfa44eef fix: Auto-scroll to last message on app start + new messages
- useEffect on messages array instead of onContentSizeChange
- Instant jump (no animation) when loading history
- Animated scroll for single new messages
- Scroll pauses when user scrolls up, resumes at bottom

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:37:30 +02:00
duffyduckandClaude Opus 4.6 6363da97b1 feat: Multiple attachments + paste support (App + Diagnostic)
App:
- Multiple pending attachments (horizontal scroll preview)
- Individual remove (X) or clear all
- Send button shows when any attachment pending
- All files sent before text message

Diagnostic:
- Clip icon for file selection (multiple)
- Paste images/files from clipboard (Ctrl+V)
- Pending preview with thumbnails
- Files sent via RVS before text message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:34:33 +02:00
duffyduckandClaude Opus 4.6 07ed2cdcf6 docs: mark attachment text feature as done in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:06:13 +02:00
duffyduckandClaude Opus 4.6 5ad68b7dfc feat: Attachments not sent immediately - add text/voice before sending
- File/photo selection stores as pending (not sent immediately)
- Preview bar shows pending attachment above input field
- User can add text message before sending (e.g. "Was siehst du?")
- Send button appears when attachment is pending (even without text)
- Placeholder changes to "Text zum Anhang (optional)..."
- X button to cancel pending attachment
- File + text sent together (file first, then chat message)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:05:50 +02:00
duffyduckandClaude Opus 4.6 8a6ee018ea docs: mark text message bug as fixed in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:59:48 +02:00
duffyduckandClaude Opus 4.6 b42590ff95 docs: mark auto-update bugs as fixed in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:54:57 +02:00
duffyduck 056b579c47 release: bump version to 0.0.3.2 2026-04-11 09:53:54 +02:00
duffyduckandClaude Opus 4.6 576e612cd0 fix: release.sh clears Metro + Gradle cache before build (version consistency)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:51:41 +02:00
duffyduck c2faa06a15 release: bump version to 0.0.3.1 2026-04-10 23:19:40 +02:00
duffyduckandClaude Opus 4.6 d3ed3556eb fix: Bridge chat handler was missing send_to_core (text messages ignored)
The chat handler checked sender but never forwarded the text to aria-core.
Only voice messages worked because they went through the audio→STT→send_to_core path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:13:29 +02:00
duffyduck d960d125c0 release: bump version to 0.0.3.0 2026-04-10 09:07:20 +02:00
duffyduck 89d5d7ec0a release: bump version to 0.0.2.9 2026-04-10 09:01:47 +02:00
duffyduckandClaude Opus 4.6 ea0c13936b fix: release.sh deletes old APKs on RVS before uploading new one
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:00:45 +02:00
duffyduckandClaude Opus 4.6 773c976822 fix: Auto-update APK install via FileProvider + dynamic version
- Native ApkInstallerModule: FileProvider content:// URI for Android 7+
- REQUEST_INSTALL_PACKAGES permission in AndroidManifest
- file_paths.xml for FileProvider cache access
- APP_VERSION reads from package.json (not hardcoded)
- "Auf Updates pruefen" button in Settings
- Version display reads from package.json dynamically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:59:52 +02:00
duffyduckandClaude Opus 4.6 cd05ed2379 docs: add auto-update FileProvider bug + update check button to issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:55:30 +02:00
duffyduck 054e4057d8 release: bump version to 0.0.2.8 2026-04-10 08:49:47 +02:00
duffyduckandClaude Opus 4.6 3943e79bb1 docs: document .env.example with detailed comments, explain both tokens in README
- ARIA_AUTH_TOKEN: Gateway auth (who can talk to ARIA)
- RVS_TOKEN: Pairing token (same room in RVS relay)
- RVS_UPDATE_HOST: SSH target for auto-update APK copy
- All variables with German comments and examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:45:26 +02:00
duffyduckandClaude Opus 4.6 87f4317c15 docs: add auto-update APK not reaching RVS bug to issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:39:59 +02:00
duffyduckandClaude Opus 4.6 50aa793910 fix: Proxy SSH volume read-write (ARIA can manage keys without -F workaround)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:34:35 +02:00
duffyduckandClaude Opus 4.6 5efc9865a8 docs: add 6 new bugs/features to issue.md
- Session persistence on container restart
- App: text/image/attachment messages not working (only voice)
- App: audio stops randomly
- App: auto-scroll to last message on start + new messages
- App: add text/voice to attachments
- Prioritized bugs section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:31:49 +02:00
duffyduckandClaude Opus 4.6 949c573c49 fix: XTTS chunk size 150 chars (faster render, preload overlaps playback)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:52:56 +02:00
duffyduckandClaude Opus 4.6 f7f450a09d fix: XTTS streaming mode - send each chunk immediately, comma between sentences
- Back to streaming: render chunk → send immediately → next chunk
- App plays with preloading queue (no waiting for all chunks)
- Comma instead of dot between sentences in chunk (no "Punkt" read aloud)
- Sentence-ending dots already removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:48:50 +02:00
duffyduckandClaude Opus 4.6 81f7c38383 fix: XTTS splits concatenated audio into ~8s parts (seamless with preload)
- All chunks rendered and PCM concatenated (consistent voice)
- Split into ~8 second WAV parts (not per-sentence)
- 8s is long enough for preload overlap, small enough for WebSocket
- Parts include part/totalParts metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:41:14 +02:00
duffyduckandClaude Opus 4.6 2c785cb37a feat: XTTS concatenates chunks into seamless WAV (no stuttering)
- All chunks rendered sequentially, PCM data concatenated
- Single WAV with proper header sent back (no queue needed in app)
- If total > 800KB, split into parts (WebSocket limit)
- Eliminates stuttering between sentences

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:40:16 +02:00
duffyduckandClaude Opus 4.6 57e65b061c docs: update issue.md with XTTS streaming as next priority
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:38:21 +02:00
duffyduck aa54765b03 release: bump version to 0.0.2.7 2026-04-10 02:24:58 +02:00
duffyduckandClaude Opus 4.6 8929bc99bb fix: XTTS groups sentences into ~250 char chunks for consistent voice quality
- 2-3 sentences per chunk (more context = stable voice/volume)
- Max 250 chars per chunk (keeps WebSocket packets manageable)
- Dots re-added between sentences within a chunk (natural pauses)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:23:29 +02:00
duffyduckandClaude Opus 4.6 0428c06612 fix: Audio preloading to prevent stuttering, remove trailing dots for XTTS
- Preload next audio while current plays (eliminates gap between sentences)
- Remove trailing dots from sentences (XTTS reads them aloud)
- stopPlayback cleans up preloaded audio

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:21:19 +02:00
duffyduck a7eb3cf433 release: bump version to 0.0.2.6 2026-04-10 02:11:04 +02:00
duffyduckandClaude Opus 4.6 e4e0e793a8 fix: Audio queue for sequential TTS playback (no overlap/skip)
- Audio packets queued instead of stopping previous
- _playNext() plays sequentially, each sentence after the previous
- stopPlayback() clears queue
- Fixes overlapping/skipping with XTTS sentence-by-sentence rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:09:35 +02:00
duffyduckandClaude Opus 4.6 b3d3b8b6bc fix: XTTS bridge splits text into sentences sequentially
- XTTS-Bridge does sentence splitting (not ARIA-Bridge)
- Sequential rendering: correct order guaranteed
- Each sentence sent as separate xtts_response
- Markdown removal before splitting
- App starts playback after first sentence (faster UX)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:03:29 +02:00
duffyduckandClaude Opus 4.6 06bc456221 fix: XTTS splits long text into sentences before sending (WebSocket size limit)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:56:25 +02:00
duffyduckandClaude Opus 4.6 3461f45207 docs: update README with XTTS v2 setup details, voice cloning guide
- Architecture diagram for XTTS flow (Gaming-PC ↔ RVS ↔ ARIA-VM)
- Port 8020 (not 8000), token must match, model caching
- Voice cloning step-by-step guide
- TTS engine switching (Piper/XTTS) with fallback
- Known limitation: RVS zombie connections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:49:08 +02:00
duffyduckandClaude Opus 4.6 a17d4acc13 fix: XTTS bridge shares /voices volume with XTTS server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:40:41 +02:00
duffyduckandClaude Opus 4.6 62fd9193a1 fix: XTTS voice dropdown shows saved voice after page reload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:34:00 +02:00
duffyduckandClaude Opus 4.6 2329645df4 fix: XTTS voices list + upload use fresh RVS connection with response wait
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:24:55 +02:00
duffyduckandClaude Opus 4.6 8a435ddf6c fix: voice upload uses send() via server, not client-side sendToRVS_raw
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:15:29 +02:00
duffyduckandClaude Opus 4.6 25b754ba31 fix: voice upload Base64 conversion (chunked, no stack overflow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:08:32 +02:00
duffyduckandClaude Opus 4.6 b734593bf2 fix: Bridge _send_to_rvs ping-check before send, force reconnect on zombie
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:37:22 +02:00
duffyduckandClaude Opus 4.6 16847ce6f7 fix: TTS toggle global above engine selector, health check /docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:27:55 +02:00
duffyduckandClaude Opus 4.6 6300829317 fix: XTTS model cache volume path /app/xtts_models
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:44:29 +02:00
duffyduckandClaude Opus 4.6 a1e1ee31bd fix: XTTS bridge port 8020, longer startup wait
- XTTS API runs on port 8020 (not 8000)
- Bridge waits up to 5min for model download (30x10s)
- Health check uses / instead of /docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:45 +02:00
duffyduck 7ed70b876d updated image public path 2026-04-07 23:06:26 +02:00
duffyduck 3ca85da906 release: bump version to 0.0.2.5 2026-04-05 20:12:56 +02:00
duffyduck d6a89168ef release: bump version to 0.0.2.4 2026-04-05 19:51:19 +02:00
duffyduckandClaude Opus 4.6 cb33a20694 docs: update README with XTTS, auto-update, watchdog, TTS settings
- Architecture: Added XTTS v2 (Gaming-PC) and auto-update flow
- Diagnostic: Thinking indicator, cancel button, TTS tab, voice cloning
- App: Play button, chat search, auto-update, voice speed settings
- RVS: Auto-update APK distribution over WebSocket
- Watchdog: 2min warning → 5min doctor --fix → 8min container restart
- Roadmap: Phase 1 fully completed, updated Phase 2+3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:46:16 +02:00
duffyduckandClaude Opus 4.6 a242693751 feat: XTTS v2 integration, auto-update system, TTS engine abstraction
- XTTS v2: Docker setup for Gaming-PC (GPU), bridge via RVS relay
- XTTS: Voice cloning UI in Diagnostic (multi-file upload)
- XTTS: Engine selectable (Piper local vs XTTS remote) with fallback
- Auto-Update: RVS serves APK over WebSocket (no HTTP needed)
- Auto-Update: App checks version on start, prompts install
- Auto-Update: release.sh copies APK to RVS via scp
- Bridge: TTS engine abstraction (piper/xtts), config persistent
- Bridge: xtts_response handler, tts_request on-demand
- Diagnostic: TTS engine dropdown, XTTS voice panel, voice cloning
- App: Play button on ARIA messages, chat search, update service
- Wake word: Disabled LiveAudioStream (crash fix), Phase 1 placeholder
- Watchdog: Container restart after 8min stuck
- Chat backup: on-the-fly to /shared/config/chat_backup.jsonl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:42:10 +02:00
61 changed files with 1092 additions and 6947 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
# App, Bridge, Diagnostic und XTTS-Bridge verbinden sich hierueber.
# Alle muessen den gleichen Host, Port und Token nutzen.
# Hostname des RVS-Servers (z.B. rvs.example.de oder example.com)
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
RVS_HOST=rvs.example.de
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
+57 -220
View File
@@ -34,21 +34,13 @@ ARIA hat zwei Rollen:
└───────────┬───────────────────────────┬─────────────────┘
│ WebSocket Tunnel │ WebSocket Tunnel
▼ ▼
┌─────────────────────────────────┐
│ 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 │
└─────────────────────────────────┘
┌───────────────────────────┐
│ Gaming-PC (optional) │
│ RTX 3060, Docker+WSL2 │
│ XTTS v2 (natuerliche │
│ Stimmen, Voice Cloning) │
│ xtts/docker-compose.yml │
└───────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ARIA-VM (Proxmox, Debian 13) — ARIAs Wohnung │
│ Basissystem + Docker. Rest richtet ARIA selbst ein. │
@@ -65,10 +57,8 @@ ARIA hat zwei Rollen:
│ │ Liest BOOTSTRAP.md + AGENT.md │ │
│ │ │ │
│ │ [bridge] ARIA Voice Bridge Container │ │
│ │ Wake-Word (lokales Mikro auf VM) │ │
│ │ STT primaer remote (Gamebox-Whisper) │ │
│ │ Fallback: lokales faster-whisper (CPU) │ │
│ │ TTS via F5-TTS auf Gamebox │ │
│ │ Whisper STT · Wake-Word │ │
│ │ TTS remote via XTTS v2 auf Gaming-PC │ │
│ │ Bruecke: App <> RVS <> Bridge <> ARIA │ │
│ │ │ │
│ │ [diagnostic] Selbstcheck-UI + Einstellungen │ │
@@ -89,12 +79,9 @@ ARIA hat zwei Rollen:
|-----|----|-----|
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
| ARIA Core | Debian 13 VM | `docker compose up -d && ./aria-setup.sh` |
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
| XTTS v2 (optional) | Gaming-PC (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
@@ -160,12 +147,11 @@ 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 als Fallback, Sprache, Wake-Word)
# Bei Bedarf anpassen (Whisper-Modell, Sprache, Wake-Word)
```
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.
TTS laeuft ausschliesslich ueber XTTS v2 auf dem Gaming-PC — siehe Abschnitt
"XTTS v2 — High-Quality TTS" weiter unten.
### 5. RVS-Token generieren & Container starten
@@ -298,34 +284,25 @@ braucht ARIA mehrere API-Roundtrips.
## Voice Bridge
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
auf der Gamebox.
Die Bridge verbindet die Android App mit ARIA und bietet lokale Sprachverarbeitung.
**Nachrichtenfluss:**
```
Text: App → RVS → Bridge → 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)
Audio: App → RVS → Bridge → FFmpeg → Whisper STT → chat.send → aria-core
Datei: App → RVS → Bridge → /shared/uploads/ → chat.send (mit Pfad) → aria-core
aria-core → Antwort → Gateway → Diagnostic → RVS → App
→ Bridge → xtts_request (RVS) → f5tts-bridge
→ audio_pcm Stream → RVS → App AudioTrack
→ Bridge → XTTS (PCM-Stream) → RVS → App AudioTrack
```
### Features
- **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)
- **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
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
### Betriebsmodi
@@ -347,16 +324,14 @@ 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, 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
- **Einstellungen**: TTS aktiv-Toggle, XTTS-Voice (gecloned), Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload)
- **XTTS Voice Cloning**: Audio-Samples hochladen, eigene Stimme erstellen
- **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)
@@ -378,69 +353,23 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
### Features
- Text-Chat mit ARIA
- **Sprachaufnahme**: Tap-to-Talk (tippen startet, tippen stoppt, Auto-Stop bei Stille via VAD)
- **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)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min)
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
- **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.
- **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
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.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)
- **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
- **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, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS
- **Einstellungen**: TTS aktiv, XTTS-Voice, 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 🎙️, **Bereit-Sound** (Ding-Dong, optional in Settings) + Toast "🎤 sprich jetzt" sobald das Mikro wirklich offen ist
- 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.6–0.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
@@ -510,36 +439,10 @@ Der Update-Flow:
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
aria-core → Antwort → Bridge → F5-TTS (Gaming-PC) → PCM-Stream → RVS → App
aria-core → Antwort → Bridge → XTTS (Gaming-PC) → PCM-Stream → RVS → App
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
```
### Audio-Verhalten in der App
| Phase | Andere App (Spotify) | ARIA-Mikro |
|------------------------------|----------------------|-------------------------|
| Idle / Ohr aus | spielt frei | aus |
| Wake-Word lauscht (armed) | spielt frei | passiv (openWakeWord) |
| User-Aufnahme laeuft | pausiert (EXCLUSIVE) | Recording |
| Aufnahme zu Ende | resumed | aus |
| ARIA denkt/schreibt (~20s) | spielt frei | aus |
| TTS startet | pausiert (DUCK) | aus (oder barge) |
| TTS spielt (auch GPU-Pausen) | bleibt pausiert | barge wenn Wake-Word |
| TTS zu Ende | nach 800ms resumed | (Conversation-Window) |
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert |
| Anruf vorbei (Auto-Resume) | pausiert wieder | aus |
| Neue Frage waehrend Anruf | — | (Resume verworfen) |
Mechanismen: Underrun-Schutz im PcmStreamPlayer (Stille-Fill in Render-
Pausen), Conversation-Focus bei Wake-Word, Foreground-Service mit
mediaPlayback|microphone, Anruf-Erkennung ueber TelephonyManager +
AudioFocus-Loss-Listener mit Polling-Fallback (VoIP). Bei Anruf wird
die Wiedergabe-Position gemerkt — nach dem Auflegen spielt ARIA ab
der genauen Stelle weiter (oder verwirft das wenn der User waehrend
des Telefonats per Text eine neue Frage gestellt hat). PcmPlayback-
Finished-Event vom Native sorgt dafuer dass Spotify erst pausiert
bleibt bis ARIA wirklich verstummt ist.
### Datei-Pipeline (Bilder & Anhaenge)
```
@@ -594,7 +497,8 @@ aria-data/
│ └── diag-state/ ← Diagnostic persistenter State
│
│ (im Shared Volume /shared/config/):
│ ├── voice_config.json ← TTS-Einstellungen (Stimme, Speed, F5-TTS-Tuning)
│ ├── voice_config.json ← TTS-Einstellungen (Stimme, Speed, Engine)
│ ├── highlight_triggers.json ← Highlight-Trigger Woerter
│ └── chat_backup.jsonl ← Nachrichten-Backup (on-the-fly)
│
└── ssh/ ← SSH Keys fuer VM-Zugriff
@@ -636,7 +540,7 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
---
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
## XTTS v2 — GPU TTS Server (optional)
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
@@ -645,27 +549,22 @@ ueber verschiedene Netze hinweg.
### Architektur
```
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)
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
↕ RVS (Rechenzentrum, WebSocket Relay)
ARIA-VM
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
TTS via xtts_request → audio_pcm Stream
└── aria-bridge: tts_engine="xtts" → xtts_request via RVS → wartet auf xtts_response
```
### Voraussetzungen
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
- NVIDIA Container Toolkit
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
- GPU mit mindestens 4GB VRAM (6GB+ empfohlen)
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
### Setup
@@ -675,76 +574,38 @@ cd xtts
cp .env.example .env
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
docker compose up -d
# Erster Start laedt die Modelle (Whisper ~1-3GB je nach Groesse, F5-TTS ~1GB)
# Erster Start laedt ~2GB Model herunter (danach gecacht)
```
Die Modelle werden in den Volumes `f5tts-models` und `whisper-models` gecacht
und muessen nur einmal geladen werden.
**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.
### Features
**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
- **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.
### TTS-Config
In der Diagnostic unter Einstellungen → Sprachausgabe:
- **TTS aktiv**: Global An/Aus
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
- **XTTS Stimme**: Default oder gecloned (Maia, etc.)
> F5-TTS ist die einzige Engine — wenn die Gamebox offline ist, bleibt ARIA stumm.
> XTTS ist die einzige Engine — wenn der Gaming-PC offline ist, bleibt ARIA stumm.
> Chat-Antworten kommen weiter an (nur kein Audio).
### Stimme klonen
1. App oder Diagnostic → "Stimme klonen" → Audio-Dateien hochladen
(WAV/MP3, 1-10 Dateien, ~6-10s gesamt)
1. "Stimme klonen" → Audio-Dateien hochladen (WAV/MP3, 1-10 Dateien, min. 6-10s gesamt)
2. Name vergeben → "Stimme erstellen"
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.
3. "Laden" klicken → neue Stimme in der Auswahl
4. Stimme auswaehlen → Config wird automatisch gespeichert
> **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
@@ -812,10 +673,8 @@ 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 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.
- **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.
- **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.
@@ -841,7 +700,7 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] SSH-Zugriff auf VM (aria-wohnung)
- [x] Diagnostic Web-UI + Einstellungen
- [x] Session-Verwaltung + Chat-History
- [x] Stimmen-Einstellungen (frueher Piper Ramona/Thorsten, Highlight-Trigger) — durch XTTS, dann F5-TTS Voice Cloning ersetzt
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed, Highlight-Trigger) — durch XTTS v2 Voice Cloning ersetzt
- [x] Piper komplett entfernt — nur noch XTTS v2 als TTS (Gaming-PC)
- [x] Streaming TTS: PCM-Chunks direkt in AudioTrack, nahtlose Wiedergabe
- [x] TTS satzweise fuer lange Texte
@@ -861,28 +720,6 @@ 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 einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
- [x] Barge-In: User kann ARIA waehrend Antwort unterbrechen, aria-core bekommt Kontext-Hint
- [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort)
- [x] PcmPlaybackFinished-Event: AudioFocus wird erst released wenn AudioTrack wirklich durch ist — kein Spotify-mid-TTS mehr
- [x] Edge-Case: neue Frage waehrend Telefonat verwirft pending Auto-Resume, neueste Antwort gewinnt
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
- [x] Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen ist — akustische Bestaetigung, in Settings abschaltbar
- [x] Wake-Word parallel zu TTS mit AcousticEchoCanceler — "Computer" sagen waehrend ARIA spricht stoppt sie und oeffnet Mikro
- [x] GPS-Position mit Nachrichten mitsenden (Toggle in Settings) — ARIA nutzt sie nur bei standortbezogenen Fragen, im Chat sichtbar nur in ihrer Antwort
- [x] Sprachnachrichten ohne STT-Result werden nach Timeout automatisch entfernt (skaliert mit Aufnahmedauer)
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
- [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
@@ -898,5 +735,5 @@ docker exec aria-core ssh aria-wohnung hostname
- [ ] STARFACE Telefonie-Skill
- [ ] Desktop Client (Tauri)
- [ ] bKVM Remote IT-Support
- [ ] Custom-`.onnx`-Upload fuer Wake-Word ueber Diagnostic (ohne App-Rebuild)
- [ ] Porcupine Wake Word (on-device "ARIA" in der App)
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)
-5
View File
@@ -13,7 +13,6 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs';
import { initLogger } from './src/services/logger';
// --- Navigation ---
@@ -45,10 +44,6 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
const App: React.FC = () => {
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
useEffect(() => {
// Verbose-Logging-Setting laden BEVOR andere Module loslegen.
// initLogger ist async aber blockt nichts — solange er noch laueft,
// loggen wir normal (Default an), danach respektiert console.log das Setting.
initLogger().catch(() => {});
const initConnection = async () => {
const config = await rvs.loadConfig();
if (config) {
+2 -18
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10008
versionName "0.1.0.8"
versionCode 407
versionName "0.0.4.7"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -104,19 +104,6 @@ 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 {
@@ -124,9 +111,6 @@ 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 {
@@ -4,19 +4,6 @@
<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" />
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
Aufnahme im Gespraechsmodus). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
@@ -48,10 +35,5 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".AriaPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" />
</application>
</manifest>
@@ -1,108 +0,0 @@
package com.ariacockpit
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
/**
* Foreground-Service der den App-Prozess waehrend TTS-Wiedergabe am Leben
* haelt — Android killt sonst den Prozess sobald die App im Hintergrund ist
* und ARIA verstummt mitten im Satz.
*
* Notification ist persistent (ongoing) waehrend der Service laeuft.
* Tap auf die Notification bringt MainActivity zurueck nach vorne.
*
* foregroundServiceType="mediaPlayback" ist Pflicht ab Android 14, sonst
* wirft startForeground() eine SecurityException.
*/
class AriaPlaybackService : Service() {
companion object {
private const val TAG = "AriaPlaybackService"
private const val CHANNEL_ID = "aria_playback"
private const val NOTIFICATION_ID = 1042
const val EXTRA_REASON = "reason" // "tts" | "wake" | "rec" | ""
}
private var currentReason: String = ""
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
currentReason = reason
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
try {
startForeground(NOTIFICATION_ID, buildNotification(reason))
} catch (e: Exception) {
Log.e(TAG, "startForeground fehlgeschlagen", e)
stopSelf()
}
// START_NOT_STICKY: wenn Android den Service killt, NICHT automatisch
// wieder starten — die App entscheidet wann der Service noetig ist.
return START_NOT_STICKY
}
override fun onDestroy() {
Log.i(TAG, "Foreground-Service gestoppt")
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
"ARIA Audio-Wiedergabe",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Notification waehrend ARIA spricht (haelt die App im Hintergrund am Leben)"
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
}
}
private fun buildNotification(reason: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else
PendingIntent.FLAG_UPDATE_CURRENT
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
val (title, body) = when (reason) {
"tts" -> "ARIA spricht" to "Antwort wird abgespielt — antippen oeffnet die App"
"rec" -> "ARIA hoert zu" to "Sprachaufnahme laeuft — antippen oeffnet die App"
"wake" -> "ARIA bereit" to "Wake-Word lauscht passiv — antippen oeffnet die App"
else -> "ARIA aktiv" to "Hintergrund-Modus — antippen oeffnet die App"
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
}
}
@@ -5,71 +5,26 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.util.Log
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
/**
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps + emittiert Loss-Events
* an JS damit ARIA bei VoIP-Anrufen (WhatsApp/Signal/Discord/...) aufhoert
* zu sprechen — diese Anrufe gehen nicht ueber TelephonyManager, sondern
* requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE was wir hier mitbekommen.
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps.
*
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
* - release() → Focus abgeben, andere Apps duerfen wieder
*
* Events:
* - "AudioFocusChanged" mit type:
* "loss" — endgueltiger Verlust (Anruf, andere App permanent)
* "loss_transient" — vorruebergehender Verlust (kurze Unterbrechung)
* "gain" — Fokus zurueck
*/
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "AudioFocus"
companion object { private const val TAG = "AudioFocus" }
private var currentRequest: AudioFocusRequest? = null
private fun audioManager(): AudioManager? =
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
private fun emitFocusChange(type: String) {
try {
val params = Arguments.createMap().apply { putString("type", type) }
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("AudioFocusChanged", params)
} catch (e: Exception) {
Log.w(TAG, "emit failed: ${e.message}")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
Log.i(TAG, "AUDIOFOCUS_LOSS (z.B. Anruf, anderer Player permanent)")
emitFocusChange("loss")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT (kurze Unterbrechung)")
emitFocusChange("loss_transient")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// Notification-Sound o.ae. — wir ignorieren das, ARIA macht weiter
Log.d(TAG, "AUDIOFOCUS_LOSS_CAN_DUCK ignoriert")
}
AudioManager.AUDIOFOCUS_GAIN -> {
Log.i(TAG, "AUDIOFOCUS_GAIN")
emitFocusChange("gain")
}
}
}
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
val am = audioManager()
if (am == null) {
@@ -86,29 +41,23 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
.build()
val req = AudioFocusRequest.Builder(durationHint)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(focusListener)
.setOnAudioFocusChangeListener { /* kein Callback noetig */ }
.build()
currentRequest = req
am.requestAudioFocus(req)
} else {
@Suppress("DEPRECATION")
am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint)
}
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
}
/** 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.
*/
/** Andere Apps werden leiser (TTS spricht). */
@ReactMethod
fun requestDuck(promise: Promise) {
requestFocus(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
AudioAttributes.USAGE_ASSISTANT,
promise,
)
@@ -131,82 +80,14 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true)
}
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
* GAIN beanspruchen — das System invalidiert dabei den haengenden Stack-
* Eintrag des anderen Players — und sofort wieder abandonen. Spotify
* bekommt den Focus-Gain und resumed.
*
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
* laesst den AudioFocusRequest haengen.
*/
@ReactMethod
fun kickReleaseMedia(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
// Async laufen lassen — wir wollen einen request, Pause, dann abandon.
// Ohne Pause merkt das System (und damit Spotify) die kurze Owner-
// Wechsel oft gar nicht. 250ms reicht erfahrungsgemaess fuer den
// Focus-Stack-Refresh.
Thread {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(kickListener)
.build()
am.requestAudioFocus(kickReq)
Thread.sleep(250)
am.abandonAudioFocusRequest(kickReq)
} else {
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
@Suppress("DEPRECATION")
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
Thread.sleep(250)
@Suppress("DEPRECATION")
am.abandonAudioFocus(kickListener)
}
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt (250ms Pause)")
} catch (e: Exception) {
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
}
}.start()
promise.resolve(true)
}
private fun release() {
val am = audioManager() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentRequest?.let { am.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(focusListener)
am.abandonAudioFocus(null)
}
currentRequest = null
}
/** Aktueller Audio-Mode: NORMAL=0, IN_CALL=2, IN_COMMUNICATION=3, CALL_SCREENING=4.
* IN_COMMUNICATION ist der typische VoIP-Anruf-Mode (WhatsApp, Signal, etc.) —
* kann gepollt werden um zu erkennen wann der Anruf vorbei ist (zurueck NORMAL). */
@ReactMethod
fun getMode(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(0)
return
}
promise.resolve(am.mode)
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -1,59 +0,0 @@
package com.ariacockpit
import android.content.Intent
import android.os.Build
import android.util.Log
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
/**
* RN-Bridge fuer den AriaPlaybackService.
*
* Wird vom JS waehrend einer TTS-Wiedergabe gestartet damit Android den
* App-Prozess nicht killt wenn die App im Hintergrund ist (= ARIA spricht
* weiter, auch wenn Stefan die App minimiert hat).
*
* Service stoppt entweder explizit per stop() oder wird von Android
* mitgekillt wenn der Prozess weg ist (was bei Foreground-Service nur
* passiert wenn der User die App force-stopped).
*/
class BackgroundAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "BackgroundAudio"
companion object { private const val TAG = "BackgroundAudio" }
@ReactMethod
fun start(reason: String, promise: Promise) {
try {
val ctx = reactApplicationContext
val intent = Intent(ctx, AriaPlaybackService::class.java)
intent.putExtra(AriaPlaybackService.EXTRA_REASON, reason ?: "")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "start fehlgeschlagen: ${e.message}")
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val ctx = reactApplicationContext
ctx.stopService(Intent(ctx, AriaPlaybackService::class.java))
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "stop fehlgeschlagen: ${e.message}")
promise.reject("STOP_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -1,16 +0,0 @@
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 BackgroundAudioPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(BackgroundAudioModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -21,9 +21,6 @@ class MainApplication : Application(), ReactApplication {
add(ApkInstallerPackage())
add(AudioFocusPackage())
add(PcmStreamPlayerPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -1,413 +0,0 @@
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.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
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
// Audio-Effects: Echo-Cancellation (gegen ARIAs eigene TTS-Stimme die sonst
// das Wake-Word triggern wuerde) + Noise-Suppression. Per VOICE_COMMUNICATION
// Audio-Source schon vorhanden, aber explizites Aktivieren ist robuster.
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = 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)
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
// zur TTS-Wiedergabe gelauscht wird.
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf,
)
if (record.state != AudioRecord.STATE_INITIALIZED) {
record.release()
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)")
return
}
audioRecord = record
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
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)
}
}
private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
try { agc?.release() } catch (_: Exception) {}
aec = null; ns = null; agc = null
}
@ReactMethod
fun stop(promise: Promise) {
running.set(false)
try {
captureThread?.join(1500)
} catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
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
releaseAudioEffects()
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
}
}
}
@@ -1,16 +0,0 @@
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()
}
}
@@ -4,15 +4,12 @@ import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.util.Base64
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.LinkedBlockingQueue
/**
@@ -33,19 +30,12 @@ import java.util.concurrent.LinkedBlockingQueue
class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "PcmStreamPlayer"
// Fallback wenn JS keinen Wert uebergibt.
private const val DEFAULT_PREROLL_SECONDS = 3.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
// Sekunden Audio die VOR play()-Start gepuffert sein muessen.
// 2.5s Vorrat = genug um XTTS-Render-Pausen zwischen Chunks zu puffern.
private const val PREROLL_SECONDS = 2.5
// 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.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
private const val LEADING_SILENCE_SECONDS = 0.2
}
override fun getName() = "PcmStreamPlayer"
@@ -63,28 +53,18 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
// ── Lifecycle ──
@ReactMethod
fun start(sampleRate: Int, channels: Int, prerollSeconds: Double, promise: Promise) {
fun start(sampleRate: Int, channels: Int, promise: Promise) {
try {
// Alte Session beenden falls vorhanden
stopInternal()
// 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
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
// Buffer entkoppelt von Preroll — fester ~4s-Buffer. OnePlus A12
// mit USAGE_ASSISTANT laeuft AudioTrack erst ab ~3s gepufferter
// Daten an. Wir padden Kurztexte vor play() auf 3s (siehe Block
// nach mainLoop), Buffer braucht ~1s Headroom weil write() blockt.
val bufferSize = (bytesPerSecond * 4).coerceAtLeast(minBuf * 8)
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
val prerollTarget = (bytesPerSecond * PREROLL_SECONDS).toInt()
val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2)
prerollBytes = prerollTarget
bytesBuffered = 0
playbackStarted = false
@@ -108,20 +88,7 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
// Start-Threshold runterdrehen: Default ist bufferSize/2 (= 2s bei 4s
// Buffer). AudioTrack startet sonst nicht bevor 2s im Puffer sind —
// bei kurzen TTS-Antworten (3 Worte ~ 1.4s) bleibt pos auf 0 stehen.
// 0.1s reicht damit AudioTrack sofort mit dem ersten Chunk anlaeuft.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
val startFrames = (sampleRate / 10).coerceAtLeast(1) // 100ms
newTrack.setStartThresholdInFrames(startFrames)
Log.i(TAG, "Start-Threshold gesetzt: ${startFrames} frames (~100ms)")
} catch (e: Exception) {
Log.w(TAG, "setStartThresholdInFrames failed: ${e.message}")
}
}
// AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht.
track = newTrack
queue.clear()
writerShouldStop = false
@@ -131,9 +98,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val t = track ?: return@Thread
try {
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (leadingBytes > 0) {
val silence = ByteArray(leadingBytes)
val silenceBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (silenceBytes > 0) {
val silence = ByteArray(silenceBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
@@ -142,83 +109,25 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
bytesBuffered += silence.size
}
// 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 lange nichts mehr
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
// 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei
// Modell-Wechsel oder kalter GPU) nicht den Stream abreissen.
var idleMs = 0L
val maxIdleMs = 120_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) {
while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run {
if (endRequested) {
// Falls play() noch gar nicht lief (Stream ohne data
// ueberhaupt — sehr seltene Edge-Case): jetzt anstossen
// damit das finally{}-Wait nicht endlos blockt.
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
if (!playbackStarted) {
try { t.play(); playbackStarted = true } catch (_: Exception) {}
try { t.play() } catch (_: Exception) {}
playbackStarted = true
}
break@mainLoop
return@Thread
}
// 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
null
} ?: continue
// play() beim ALLERERSTEN data-chunk aufrufen — egal wie wenig
// Daten da sind. Sonst stallt AudioTrack auf OnePlus A12 wenn
// play() erst gerufen wird nachdem der Buffer komplett gefuellt
// ist. Pre-Roll als "Vorrat aufbauen" passiert dann waehrend
// der Track schon spielt — Underrun-Schutz fuettert ggf. Stille.
if (!playbackStarted) {
// Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet beim 1. Chunk (${bytesBuffered}B leading + ${data.size}B data)")
Log.i(TAG, "Playback gestartet nach Pre-Roll ${bytesBuffered + data.size} Bytes")
} catch (e: Exception) {
Log.w(TAG, "play() failed: ${e.message}")
}
@@ -232,19 +141,6 @@ 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 {
@@ -254,21 +150,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
var lastPos = -1
var stalledCount = 0
var retried = false
while (!writerShouldStop) {
val pos = t.playbackHeadPosition
if (pos >= totalFrames) break
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
if (pos == lastPos) {
stalledCount++
// Nach 500ms Stillstand: AudioTrack-Quirk auf manchen
// Geraeten (OnePlus A12) — play() nochmal anstossen.
if (stalledCount == 10 && pos == 0 && !retried) {
retried = true
Log.w(TAG, "playback nicht angefahren — retry play()")
try { t.play() } catch (e: Exception) {
Log.w(TAG, "retry play() failed: ${e.message}")
}
}
if (stalledCount > 40) {
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
break
@@ -283,21 +170,10 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} catch (_: Exception) {}
try { t.stop() } catch (_: Exception) {}
try { t.release() } catch (_: Exception) {}
// RN-Event: AudioTrack ist wirklich durch (alle Samples gespielt).
// JS released erst JETZT den AudioFocus — sonst spielt Spotify
// beim end()-Cap waehrend ARIA noch redet (15s+ je nach Buffer).
try {
val params = Arguments.createMap()
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PcmPlaybackFinished", params)
} catch (e: Exception) {
Log.w(TAG, "PlaybackFinished emit failed: ${e.message}")
}
}
}, "PcmStreamWriter").apply { start() }
Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B (${prerollSec}s)")
Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
@@ -320,27 +196,11 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
}
/** 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).
*/
/** Signalisiert: keine weiteren Chunks. Writer wartet auf Queue-Abschluss, dann stoppt. */
@ReactMethod
fun end(promise: Promise) {
endRequested = 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()
promise.resolve(true)
}
/** Harter Stop (Cancel) — Queue verwerfen. */
@@ -350,9 +210,6 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
promise.resolve(true)
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
private fun stopInternal() {
writerShouldStop = true
endRequested = true
@@ -1,126 +0,0 @@
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) {}
}
@@ -1,16 +0,0 @@
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()
}
}
+1 -3
View File
@@ -1,9 +1,7 @@
buildscript {
ext {
buildToolsVersion = "34.0.0"
// 24 = Android 7.0 (Nougat). Verlangt von Porcupine (Picovoice).
// Realistisch eh das Minimum: alles unter 7.0 hat <1% Marktanteil.
minSdkVersion = 24
minSdkVersion = 23
compileSdkVersion = 34
targetSdkVersion = 34
ndkVersion = "25.1.8937393"
+2 -15
View File
@@ -167,23 +167,10 @@ export CI=true
if [ "$MODE" = "debug" ]; then
./gradlew assembleDebug
OUT_DIR="app/build/outputs/apk/debug"
APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
else
./gradlew assembleRelease
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
APK_PATH="app/build/outputs/apk/release/app-release.apk"
fi
cd ..
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.0.8",
"version": "0.0.4.7",
"private": true,
"scripts": {
"android": "react-native run-android",
Binary file not shown.
-26
View File
@@ -1,26 +0,0 @@
/**
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung.
*
* Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
*/
import React from 'react';
import { Text, TextStyle, StyleProp } from 'react-native';
interface Props {
text: string;
style?: StyleProp<TextStyle>;
}
const MessageText: React.FC<Props> = ({ text, style }) => {
return (
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
);
};
export default MessageText;
+46 -37
View File
@@ -44,6 +44,7 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
const [meterDb, setMeterDb] = useState(-160);
const pulseAnim = useRef(new Animated.Value(1)).current;
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const isLongPress = useRef(false);
// Puls-Animation starten/stoppen
useEffect(() => {
@@ -92,62 +93,66 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
}, [isRecording]);
// VAD Silence Callback — Auto-Stop.
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
// feuert (silenceFired-Latch).
const onCompleteRef = useRef(onRecordingComplete);
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
// VAD Silence Callback — Auto-Stop
useEffect(() => {
const unsubSilence = audioService.onSilenceDetected(async () => {
if (audioService.getRecordingState() !== 'recording') return;
const result = await audioService.stopRecording();
if (!isRecording) return;
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 500) {
onCompleteRef.current(result);
onRecordingComplete(result);
}
});
return unsubSilence;
}, []);
}, [isRecording, onRecordingComplete]);
// Auto-Start fuer Wake Word (extern getriggert)
const startAutoRecording = useCallback(async () => {
if (disabled || isRecording) return;
const started = await audioService.startRecording(true); // autoStop = true
if (started) {
isLongPress.current = false;
setIsRecording(true);
}
}, [disabled, isRecording]);
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop.
const tapBusy = useRef(false);
// Push-to-Talk: Lang druecken
const handlePressIn = async () => {
if (disabled || isRecording) return;
isLongPress.current = true;
const started = await audioService.startRecording(false); // kein autoStop
if (started) {
setIsRecording(true);
}
};
const handlePressOut = async () => {
if (!isRecording || !isLongPress.current) return;
isLongPress.current = false;
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
};
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop
const handleTap = async () => {
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) {
setIsRecording(true);
}
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);
}
// 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;
}
};
@@ -178,6 +183,10 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
isRecording && styles.buttonOuterRecording,
{ transform: [{ scale: pulseAnim }] },
]}
onStartShouldSetResponder={() => true}
onResponderGrant={handlePressIn}
onResponderRelease={handlePressOut}
onResponderTerminate={handlePressOut}
>
<TouchableOpacity
activeOpacity={0.8}
+56 -479
View File
@@ -18,26 +18,17 @@ import {
Image,
ScrollView,
Modal,
ToastAndroid,
AppState,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
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 { playWakeReadySound } from '../services/wakeReadySound';
import {
acquireBackgroundAudio,
releaseBackgroundAudio,
} from '../services/backgroundAudio';
import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import { RecordingResult } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@@ -61,10 +52,6 @@ interface ChatMessage {
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
audioPath?: string;
/** Korrelations-ID fuer Sprachnachrichten — wird mit dem STT-Result zurueck-
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
* auch wenn mehrere Aufnahmen parallel offen sind. */
audioRequestId?: string;
}
// --- Konstanten ---
@@ -80,45 +67,6 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
* Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
* Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
const CHAT_IMAGE_STYLE = {
width: 260,
borderRadius: 8,
marginBottom: 6,
backgroundColor: '#0D0D1A',
} as const;
const ChatImage: React.FC<{
uri: string;
onPress: () => void;
onError: () => void;
}> = ({ uri, onPress, onError }) => {
const [aspectRatio, setAspectRatio] = useState<number>(4 / 3);
useEffect(() => {
let cancelled = false;
Image.getSize(uri, (w, h) => {
if (!cancelled && w > 0 && h > 0) {
// Aspect-Ratio capen damit sehr lange Panorama-Bilder oder hohe
// Screenshot-Streifen die Bubble nicht sprengen
const r = Math.max(0.5, Math.min(2.5, w / h));
setAspectRatio(r);
}
}, () => {});
return () => { cancelled = true; };
}, [uri]);
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<Image
source={{ uri }}
style={[CHAT_IMAGE_STYLE, { aspectRatio }]}
resizeMode="cover"
onError={onError}
/>
</TouchableOpacity>
);
};
async function getAttachmentDir(): Promise<string> {
try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
@@ -154,28 +102,16 @@ 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);
@@ -186,99 +122,31 @@ const ChatScreen: React.FC = () => {
return `msg_${Date.now()}_${messageIdCounter.current}`;
};
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
// sofort greift, ohne Context- oder Event-System)
// TTS-Settings beim Mount + bei Screen-Fokus neu laden (damit Settings-Toggle sofort greift)
useEffect(() => {
const loadSettings = async () => {
const loadTtsSettings = async () => {
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
setTtsDeviceEnabled(enabled !== 'false'); // default true
const muted = await AsyncStorage.getItem('aria_tts_muted');
const isMuted = muted === 'true';
setTtsMuted(isMuted); // default false
audioService.setMuted(isMuted); // service-internen Flag synchronisieren
setTtsMuted(muted === 'true'); // default false
const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed();
const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true');
};
loadSettings();
const interval = setInterval(loadSettings, 2000);
loadTtsSettings();
// Poll alle 2s um Settings-Aenderung mitzubekommen (einfache Loesung ohne Context)
const interval = setInterval(loadTtsSettings, 2000);
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();
// Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
// (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
// bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
if (s !== 'off') acquireBackgroundAudio('wake').catch(() => {});
else releaseBackgroundAudio('wake').catch(() => {});
});
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(() => {}); };
}, []);
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
useEffect(() => {
let lastState: string = AppState.currentState;
const sub = AppState.addEventListener('change', (next) => {
if (lastState !== 'active' && next === 'active') {
wakeWordService.setResumeCooldown(1500);
}
lastState = next;
});
return () => sub.remove();
}, []);
// Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro
// auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess
// sonst und die Aufnahme bricht ab).
useEffect(() => {
const unsub = audioService.onStateChange((s) => {
if (s === 'recording') acquireBackgroundAudio('rec').catch(() => {});
else releaseBackgroundAudio('rec').catch(() => {});
});
return () => unsub();
}, []);
// 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;
AsyncStorage.setItem('aria_tts_muted', String(next));
// Ref synchron updaten — sonst kommen noch Chunks im selben Tick
// mit canPlay=true durch (Race vor dem useEffect-Update).
ttsCanPlayRef.current = ttsDeviceEnabled && !next;
// Globalen Mute-Flag im audioService setzen — uebersteuert auch
// payload.silent in handlePcmChunk und stoppt laufende Wiedergabe.
audioService.setMuted(next);
// Bei Muten sofort laufende Wiedergabe stoppen
if (next) audioService.stopPlayback();
return next;
});
}, [ttsDeviceEnabled]);
}, []);
// Chat-Verlauf aus AsyncStorage laden
const isInitialLoad = useRef(true);
@@ -370,51 +238,17 @@ const ChatScreen: React.FC = () => {
if (message.type === 'chat') {
const sender = (message.payload.sender as string) || '';
const dbgText = ((message.payload.text as string) || '').slice(0, 60);
console.log('[Chat] chat-event sender=%s text=%s', sender || '(none)', dbgText);
// 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).
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben
if (sender === 'stt') {
const sttText = (message.payload.text as string) || '';
const sttAudioReqId = (message.payload.audioRequestId as string) || '';
if (!sttText) {
return;
}
setMessages(prev => {
const newText = `\uD83C\uDFA4 ${sttText}`;
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
// gesendet wurden und ihre STT-Results ueberlappen.
if (sttAudioReqId) {
const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId);
if (idxById >= 0) {
const next = prev.slice();
next[idxById] = { ...next[idxById], text: newText };
return next;
}
}
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
// nimmt die ERSTE noch unaufgeloeste Placeholder.
const idx = prev.findIndex(m =>
if (sttText) {
setMessages(prev => prev.map(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx >= 0) {
const next = prev.slice();
next[idx] = { ...next[idx], text: newText };
return next;
}
// Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen
return capMessages([...prev, {
id: nextId(),
sender: 'user',
text: newText,
timestamp: message.timestamp,
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}]);
});
? { ...m, text: `\uD83C\uDFA4 ${sttText}` }
: m
));
}
return;
}
@@ -456,12 +290,7 @@ const ChatScreen: React.FC = () => {
}
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
// 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);
}
const canPlay = ttsDeviceEnabled && !ttsMuted;
if (message.type === 'audio' && message.payload.base64) {
const b64 = message.payload.base64 as string;
const refId = (message.payload.messageId as string) || '';
@@ -495,46 +324,6 @@ const ChatScreen: React.FC = () => {
const activity = (message.payload.activity as string) || 'idle';
const tool = (message.payload.tool as string) || '';
setAgentActivity({ activity, tool });
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
}
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
// gerade in Diagnostic gewaehlten Wert zurueck. User-Wahl in der App
// wird dadurch ueberschrieben.
if (message.type === ('config' as any)) {
const newVoice = ((message.payload as any).xttsVoice as string) ?? '';
localXttsVoiceRef.current = newVoice;
AsyncStorage.setItem('aria_xtts_voice', newVoice);
}
// XTTS-Bridge meldet Stimme fertig geladen (kurzer Status-Toast)
if (message.type === ('voice_ready' as any)) {
const v = ((message.payload as any).voice as string) ?? '';
const err = (message.payload as any).error as string | undefined;
if (err) {
ToastAndroid.show(`Stimme "${v}" Fehler: ${err}`, ToastAndroid.LONG);
} else {
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);
}
});
@@ -575,18 +364,10 @@ const ChatScreen: React.FC = () => {
useEffect(() => {
const unsubWake = wakeWordService.onWakeWord(async () => {
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (started) {
// Erst JETZT signalisieren dass das Mikro wirklich offen ist —
// vorher war's noch in der Init-Phase. So weiss der User exakt
// ab wann er reden kann. "Bereit"-Sound (Ding-Dong) ist optional
// ueber Settings → Wake-Word abschaltbar.
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
} else {
// Mikrofon nicht verfuegbar, naechsten Versuch
// Aufnahme mit Auto-Stop (VAD) starten
const started = await audioService.startRecording(true);
if (!started) {
// Mikrofon nicht verfuegbar, Wake Word wieder aktivieren
wakeWordService.resume();
}
});
@@ -595,18 +376,14 @@ const ChatScreen: React.FC = () => {
const unsubSilence = audioService.onSilenceDetected(async () => {
const result = await audioService.stopRecording();
if (result && result.durationMs > 500) {
// User hat im Fenster gesprochen → Sprachnachricht senden
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
// Sprachnachricht senden (gleiche Logik wie handleVoiceRecording)
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
rvs.send('audio', {
@@ -614,75 +391,23 @@ const ChatScreen: React.FC = () => {
durationMs: result.durationMs,
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }),
});
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
} 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);
}
});
// Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
const unsubBarge = wakeWordService.onBargeIn(async () => {
console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Aufnahme');
audioService.haltAllPlayback('barge-in via wake-word');
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
// Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
await new Promise(r => setTimeout(r, 150));
const windowMs = await loadConvWindowMs();
const started = await audioService.startRecording(true, windowMs);
if (started) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
}
});
// TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist,
// parallel mitlauschen — User kann "Computer" sagen statt manuell tappen.
// PLUS: Foreground-Service-Slot 'tts' belegen damit Android den App-
// Prozess nicht killt wenn die App im Hintergrund ist.
const unsubTtsStart = audioService.onPlaybackStarted(() => {
acquireBackgroundAudio('tts').catch(() => {});
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
wakeWordService.startBargeListening().catch(() => {});
}
});
const unsubTtsEnd = audioService.onPlaybackFinished(() => {
releaseBackgroundAudio('tts').catch(() => {});
// Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder
// das Mikro greifen kann.
wakeWordService.stopBargeListening().catch(() => {});
// Wake Word wieder aktivieren
if (wakeWordActive) wakeWordService.resume();
});
return () => {
unsubWake();
unsubSilence();
unsubBarge();
unsubTtsStart();
unsubTtsEnd();
};
}, [wakeWordActive]);
// Wake Word Toggle Handler
const toggleWakeWord = useCallback(async () => {
if (wakeWordActive) {
// 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();
wakeWordService.stop();
setWakeWordActive(false);
} else {
const started = await wakeWordService.start();
@@ -725,23 +450,17 @@ const ChatScreen: React.FC = () => {
// GPS-Position holen (optional)
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
if (!gpsEnabled) {
console.log('[GPS] gpsEnabled=false → kein Standort');
return Promise.resolve(null);
}
if (!gpsEnabled) return Promise.resolve(null);
return new Promise((resolve) => {
Geolocation.getCurrentPosition(
(position) => {
const loc = {
resolve({
lat: position.coords.latitude,
lon: position.coords.longitude,
};
console.log('[GPS] Position: lat=%s lon=%s', loc.lat, loc.lon);
resolve(loc);
});
},
(error) => {
console.warn('[GPS] getCurrentPosition Fehler:', error?.code, error?.message);
(_error) => {
resolve(null);
},
{ enableHighAccuracy: false, timeout: 5000 },
@@ -751,29 +470,6 @@ const ChatScreen: React.FC = () => {
// --- Nachricht senden ---
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
// plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer.
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
// langsame STTs nicht versehentlich aufgeraeumt werden.
const scheduleStaleAudioCleanup = useCallback((audioRequestId: string, recordingMs: number) => {
const timeoutMs = 60000 + recordingMs;
setTimeout(() => {
setMessages(prev => {
const idx = prev.findIndex(m =>
m.audioRequestId === audioRequestId &&
m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx < 0) return prev;
console.log('[Chat] Sprachnachricht ohne STT-Result nach %dms entfernt: %s',
timeoutMs, audioRequestId);
ToastAndroid.show('Sprachnachricht nicht erkannt — entfernt', ToastAndroid.SHORT);
return prev.filter((_, i) => i !== idx);
});
}, timeoutMs);
}, []);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
@@ -787,8 +483,6 @@ const ChatScreen: React.FC = () => {
setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const userMsg: ChatMessage = {
@@ -799,17 +493,13 @@ const ChatScreen: React.FC = () => {
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
rvs.send('chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
const cancelRequest = useCallback(() => {
@@ -817,37 +507,15 @@ const ChatScreen: React.FC = () => {
rvs.send('cancel_request' as any, {});
}, []);
// Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
// mach lieber X" sagen wie in einem echten Gespraech.
const interruptAriaIfBusy = useCallback(() => {
const speaking = audioService.isPlayingAudio();
const thinking = agentActivity.activity !== 'idle';
if (!speaking && !thinking) return false;
console.log('[Chat] Barge-In: speaking=%s thinking=%s — interrupting ARIA',
speaking, thinking);
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
if (thinking) {
setAgentActivity({ activity: 'idle', tool: '' });
rvs.send('cancel_request' as any, {});
}
return true;
}, [agentActivity]);
// Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
@@ -855,25 +523,9 @@ const ChatScreen: React.FC = () => {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }),
});
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
// Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit
// den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn-
// Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen.
// Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback
// und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"-
// Knopf.
if (wakeWordService.isConversing()) {
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
await wakeWordService.endConversation();
}
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
}, [getCurrentLocation]);
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
@@ -890,7 +542,6 @@ const ChatScreen: React.FC = () => {
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
const location = await getCurrentLocation();
const msgId = nextId();
@@ -940,8 +591,6 @@ const ChatScreen: React.FC = () => {
}
// An RVS senden
console.log('[Chat] sende file: name=%s mime=%s size=%s b64Bytes=%s',
name, mimeType, file.size, base64.length);
rvs.send('file', {
name,
type: mimeType,
@@ -957,7 +606,6 @@ const ChatScreen: React.FC = () => {
rvs.send('chat', {
text: messageText,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }),
});
}
@@ -981,9 +629,11 @@ const ChatScreen: React.FC = () => {
{item.attachments?.map((att, idx) => (
<View key={idx}>
{att.type === 'image' && att.uri ? (
<ChatImage
uri={att.uri}
onPress={() => setFullscreenImage(att.uri || null)}
<TouchableOpacity onPress={() => setFullscreenImage(att.uri || null)} activeOpacity={0.8}>
<Image
source={{ uri: att.uri }}
style={styles.attachmentImage}
resizeMode="cover"
onError={() => {
setMessages(prev => prev.map(m =>
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
@@ -992,6 +642,7 @@ const ChatScreen: React.FC = () => {
));
}}
/>
</TouchableOpacity>
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
@@ -1029,33 +680,26 @@ 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)) && (
<MessageText
text={item.text}
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
/>
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
{item.text}
</Text>
)}
{/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Bridge-TTS mit aktueller Engine */}
{!isUser && item.text.length > 0 && (
<TouchableOpacity
style={styles.playButton}
onPress={async () => {
// Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte
// Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall
// ueber RVS neu rendern lassen statt stumm zu bleiben.
const cachePath = item.audioPath?.replace(/^file:\/\//, '') || '';
const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false;
if (cached) {
audioService.playFromPath(item.audioPath!);
return;
onPress={() => {
if (item.audioPath) {
audioService.playFromPath(item.audioPath);
} else {
// messageId mitschicken damit die Bridge das generierte Audio
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
rvs.send('tts_request' as any, {
text: item.text,
voice: localXttsVoiceRef.current,
messageId: item.messageId || '',
});
}
// messageId mitschicken damit die Bridge das generierte Audio
// wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
rvs.send('tts_request' as any, {
text: item.text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
messageId: item.messageId || '',
});
}}
>
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
@@ -1088,49 +732,6 @@ 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}>
@@ -1273,10 +874,7 @@ const ChatScreen: React.FC = () => {
style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
onPress={toggleWakeWord}
>
<Text style={styles.wakeWordIcon}>
{wakeWordState === 'conversing' ? '🎙️' :
wakeWordState === 'armed' ? '👂' : '🔇'}
</Text>
<Text style={styles.wakeWordIcon}>{wakeWordActive ? '👂' : '🔇'}</Text>
</TouchableOpacity>
</>
)}
@@ -1348,25 +946,6 @@ 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,
@@ -1399,11 +978,9 @@ const styles = StyleSheet.create({
color: '#E0E0F0',
},
attachmentImage: {
// Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die
// Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent-
// Breite wuerde RN das Bild auf 0px schrumpfen → "Strich".
width: 260,
aspectRatio: 4 / 3,
width: '100%',
minHeight: 200,
maxHeight: 400,
borderRadius: 8,
marginBottom: 6,
backgroundColor: '#0D0D1A',
+17 -815
View File
@@ -15,58 +15,14 @@ import {
StyleSheet,
Alert,
Platform,
ToastAndroid,
ActivityIndicator,
Modal,
PermissionsAndroid,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import DocumentPicker from 'react-native-document-picker';
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
import {
TTS_PREROLL_DEFAULT_SEC,
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,
MAX_RECORDING_DEFAULT_SEC,
MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY,
VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX,
VAD_SILENCE_DB_OVERRIDE_KEY,
TTS_SPEED_DEFAULT,
TTS_SPEED_MIN,
TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import audioService from '../services/audio';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import {
isWakeReadySoundEnabled,
setWakeReadySoundEnabled,
playWakeReadySound,
} from '../services/wakeReadySound';
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';
import updateService from '../services/updater';
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
@@ -90,18 +46,6 @@ interface EventEntry {
type LogTab = 'live' | 'events';
// Settings-Sub-Screens. Reihenfolge im Hauptmenue.
const SETTINGS_SECTIONS = [
{ id: 'connection', icon: '🔌', label: 'Verbindung', desc: 'Server, Token, Status, Verbindungslog' },
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
// Container-Farben fuer Live-Logs
const SOURCE_COLORS: Record<string, string> = {
'aria-core': '#4A9EFF', // Blau
@@ -129,30 +73,11 @@ const SettingsScreen: React.FC = () => {
const [autoDownload, setAutoDownload] = useState(true);
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 [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false);
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
// So bleibt aller geteilte State im selben Component-Closure und wir
// brauchen keine react-navigation-Stack-Setup.
const [currentSection, setCurrentSection] = useState<string | null>(null);
let logIdCounter = 0;
@@ -174,61 +99,6 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
if (saved !== null) setTtsEnabled(saved === 'true');
});
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
if (saved !== null) setGpsEnabled(saved === 'true');
});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) {
setTtsPrerollSec(n);
}
}
});
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(MAX_RECORDING_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) {
setMaxRecordingSec(n);
}
}
});
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') {
const n = parseFloat(saved);
if (isFinite(n) && n >= VAD_SILENCE_DB_MIN && n <= VAD_SILENCE_DB_MAX) {
setVadSilenceDb(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);
});
isWakeReadySoundEnabled().then(setWakeReadySound);
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved);
});
@@ -380,31 +250,6 @@ const SettingsScreen: React.FC = () => {
}
rvs.send('xtts_list_voices' as any, {});
}
// Diagnostic-Voice-Wechsel → lokale App-Stimme auf den neuen Default zuruecksetzen.
// Zusaetzlich Preload triggern, damit der User weiss wann's geladen ist.
if (message.type === ('config' as any)) {
const newVoice = ((message.payload as any).xttsVoice as string) ?? '';
setXttsVoice(newVoice);
AsyncStorage.setItem('aria_xtts_voice', newVoice);
if (newVoice) {
setLoadingVoice(newVoice);
}
}
// XTTS-Bridge meldet: Stimme fertig geladen
if (message.type === ('voice_ready' as any)) {
const v = ((message.payload as any).voice as string) ?? '';
const err = (message.payload as any).error as string | undefined;
const ms = (message.payload as any).loadMs as number | undefined;
setLoadingVoice(null);
if (err) {
ToastAndroid.show(`Stimme "${v}" konnte nicht geladen werden: ${err}`, ToastAndroid.LONG);
} else {
const suffix = ms ? ` (${(ms / 1000).toFixed(1)}s)` : '';
ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit${suffix}`, ToastAndroid.SHORT);
}
}
});
return () => {
@@ -463,31 +308,9 @@ const SettingsScreen: React.FC = () => {
// --- GPS Toggle ---
const handleGPSToggle = useCallback(async (value: boolean) => {
if (value && Platform.OS === 'android') {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
{
title: 'ARIA — Standort an Anfragen anhaengen',
message: 'Damit ARIA bei Anfragen wie "Wo ist der naechste...?" den '
+ 'Standort kennt, darf die App den ungefaehren Standort lesen. '
+ 'Wird nur bei jeder Anfrage einmal abgerufen, nicht im Hintergrund.',
buttonPositive: 'Erlauben',
buttonNegative: 'Abbrechen',
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
ToastAndroid.show('Standort-Berechtigung abgelehnt', ToastAndroid.SHORT);
return;
}
} catch (err) {
console.warn('[Settings] GPS-Permission Request gescheitert:', err);
return;
}
}
const handleGPSToggle = useCallback((value: boolean) => {
setGpsEnabled(value);
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
// In Produktion: Wert in AsyncStorage persistieren
}, []);
// --- XTTS Voice ---
@@ -495,13 +318,6 @@ const SettingsScreen: React.FC = () => {
const selectVoice = useCallback((voiceName: string) => {
setXttsVoice(voiceName);
AsyncStorage.setItem('aria_xtts_voice', voiceName);
// Preload nur fuer Custom-Voices — "Standard" braucht keinen Ladevorgang
if (voiceName) {
setLoadingVoice(voiceName);
rvs.send('voice_preload' as any, { voice: voiceName, source: 'app' });
} else {
setLoadingVoice(null);
}
}, []);
const deleteVoice = useCallback((name: string) => {
@@ -566,39 +382,7 @@ const SettingsScreen: React.FC = () => {
/>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{currentSection === null && (
<>
{SETTINGS_SECTIONS.map(s => (
<TouchableOpacity
key={s.id}
style={styles.menuItem}
onPress={() => setCurrentSection(s.id)}
>
<Text style={styles.menuItemIcon}>{s.icon}</Text>
<View style={styles.menuItemTextWrap}>
<Text style={styles.menuItemLabel}>{s.label}</Text>
<Text style={styles.menuItemDesc}>{s.desc}</Text>
</View>
<Text style={styles.menuItemChevron}>›</Text>
</TouchableOpacity>
))}
</>
)}
{currentSection !== null && (
<TouchableOpacity
style={styles.subScreenHeader}
onPress={() => setCurrentSection(null)}
>
<Text style={styles.subScreenBack}>‹</Text>
<Text style={styles.subScreenTitle}>
{SETTINGS_SECTIONS.find(s => s.id === currentSection)?.label || ''}
</Text>
</TouchableOpacity>
)}
{/* === Verbindung === */}
{currentSection === 'connection' && (<>
<Text style={styles.sectionTitle}>Verbindung</Text>
<View style={styles.card}>
{/* Status-Anzeige */}
@@ -695,10 +479,8 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.clearButtonText}>Log l{'\u00F6'}schen</Text>
</TouchableOpacity>
</View>
</>)}
{/* === Modus === */}
{currentSection === 'general' && (<>
<Text style={styles.sectionTitle}>Betriebsmodus</Text>
<View style={styles.card}>
<ModeSelector currentModeId={currentMode} onModeChange={handleModeChange} />
@@ -711,11 +493,7 @@ const SettingsScreen: React.FC = () => {
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS-Position mitsenden</Text>
<Text style={styles.toggleHint}>
Position (lat/lon) wird mit jeder Nachricht an ARIA mitgeschickt.
Sie sieht's nur intern und nutzt es bei standortbezogenen Fragen
("wo bin ich?", "Wetter hier?"), erwaehnt es sonst nicht.
Im Chat-Verlauf bleibt die Bubble unveraendert — nur ARIAs
Antwort kann darauf eingehen.
Standort wird automatisch an Nachrichten angehaengt
</Text>
</View>
<Switch
@@ -726,285 +504,8 @@ const SettingsScreen: React.FC = () => {
/>
</View>
</View>
</>)}
{/* === Spracheingabe (geraetelokal) === */}
{currentSection === 'voice_input' && (<>
<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>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
auch wenn keine Stille erkannt wurde. Nuetzlich fuer lange Erklaerungen
oder Diktate. Default: {Math.round(MAX_RECORDING_DEFAULT_SEC / 60)} Min, max {Math.round(MAX_RECORDING_MAX_SEC / 60)} Min.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(MAX_RECORDING_MIN_SEC, maxRecordingSec - 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec <= MAX_RECORDING_MIN_SEC}
>
<Text style={styles.prerollButtonText}>−1m</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{Math.round(maxRecordingSec / 60)} min</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(MAX_RECORDING_MAX_SEC, maxRecordingSec + 60);
setMaxRecordingSec(next);
AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next));
}}
disabled={maxRecordingSec >= MAX_RECORDING_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1m</Text>
</TouchableOpacity>
</View>
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
<Text style={styles.infoBtnText}>i</Text>
</TouchableOpacity>
</View>
<Text style={styles.toggleHint}>
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT - 1
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>−1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT + 1
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
{vadSilenceDb != null && (
<TouchableOpacity
onPress={() => {
setVadSilenceDb(null);
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
}}
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
>
<Text style={{color: '#0096FF', fontSize: 13}}>↻ Auf automatisch zuruecksetzen</Text>
</TouchableOpacity>
)}
</View>
<Modal
visible={showVadInfo}
transparent
animationType="fade"
onRequestClose={() => setShowVadInfo(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Stille-Pegel (dB)</Text>
<Text style={styles.modalText}>
Lautstaerken werden in Dezibel (dB) gemessen — negative Werte, je
hoeher (naeher an 0), desto lauter.{'\n\n'}
<Text style={{fontWeight: '700'}}>Standard:</Text> automatisch.
Die App misst die ersten 500ms Hintergrundpegel und setzt die
Stille-Schwelle auf Baseline + 6 dB. Funktioniert in den meisten
Umgebungen.{'\n\n'}
<Text style={{fontWeight: '700'}}>Manuell:</Text> Pegel unter dem
eingestellten Wert gilt als "Stille" → Aufnahme stoppt.{'\n\n'}
<Text style={{fontWeight: '700'}}>Faustregel:</Text>{'\n'}
• <Text style={{color: '#FFD60A'}}>−45 dB</Text> sehr empfindlich (stoppt schnell, auch bei Atmen){'\n'}
• <Text style={{color: '#34C759'}}>−38 dB</Text> ausgewogen (typische Bueroumgebung){'\n'}
• <Text style={{color: '#FF6B6B'}}>−25 dB</Text> unempfindlich (laute Umgebung, nur klare Sprache zaehlt){'\n\n'}
<Text style={{color: '#8888AA'}}>Niedrigere Zahl (z.B. −50) = sensibler.{'\n'}
Hoehere Zahl (z.B. −20) = robuster gegen Hintergrundlaerm,
braucht aber lautere Sprache.</Text>
</Text>
<TouchableOpacity
style={[styles.connectButton, {marginTop: 16, alignSelf: 'stretch'}]}
onPress={() => setShowVadInfo(false)}
>
<Text style={styles.connectButtonText}>OK</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</>)}
{/* === Wake-Word (komplett on-device, openWakeWord) === */}
{currentSection === 'wake_word' && (<>
<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 style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Bereit-Sound abspielen</Text>
<Text style={styles.toggleHint}>
Kurzer Ding-Dong wenn das Mikro nach Wake-Word offen ist —
akustische Bestaetigung dass du jetzt sprechen darfst.
</Text>
</View>
<Switch
value={wakeReadySound}
onValueChange={async (val) => {
setWakeReadySound(val);
await setWakeReadySoundEnabled(val);
if (val) {
// Direkt eine Vorschau abspielen damit der User weiss wie's klingt.
// playWakeReadySound checked das gerade gesetzte Flag — wenn val=true,
// wird abgespielt; bei false bleibt es still.
setTimeout(() => playWakeReadySound().catch(() => {}), 150);
}
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
</>)}
{/* === Sprachausgabe (geraetelokal) === */}
{currentSection === 'voice_output' && (<>
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
@@ -1026,85 +527,27 @@ const SettingsScreen: React.FC = () => {
/>
</View>
{ttsEnabled && (
<View style={{marginTop: 20}}>
<Text style={styles.toggleLabel}>Puffer vor Wiedergabestart</Text>
<Text style={styles.toggleHint}>
Wie viel Audio gesammelt wird bevor die Wiedergabe startet.
Hoeher = robuster gegen Render-Pausen, aber mehr Startverzoegerung.
Default: {TTS_PREROLL_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(TTS_PREROLL_MIN_SEC, Math.round((ttsPrerollSec - 0.5) * 10) / 10);
setTtsPrerollSec(next);
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
}}
disabled={ttsPrerollSec <= TTS_PREROLL_MIN_SEC}
>
<Text style={styles.prerollButtonText}>−0.5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{ttsPrerollSec.toFixed(1)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(TTS_PREROLL_MAX_SEC, Math.round((ttsPrerollSec + 0.5) * 10) / 10);
setTtsPrerollSec(next);
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
}}
disabled={ttsPrerollSec >= TTS_PREROLL_MAX_SEC}
>
<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>
)}
{ttsEnabled && (
<View style={{marginTop: 20}}>
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
<Text style={styles.toggleHint}>
Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz —
ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme.
Eigene Wahl fuer dieses Geraet. Ohne Auswahl gilt der Diagnostic-Default.
</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 geklonten Stimmen vorhanden — unten "Eigene Stimme aufnehmen".
Keine eigenen Stimmen auf dem XTTS-Server.
</Text>
) : (
availableVoices.map(v => (
@@ -1118,10 +561,7 @@ const SettingsScreen: React.FC = () => {
</Text>
<Text style={styles.voiceRowMeta}>{(v.size / 1024).toFixed(0)} KB</Text>
</TouchableOpacity>
{loadingVoice === v.name && (
<ActivityIndicator size="small" color="#0096FF" style={{marginRight: 8}} />
)}
{xttsVoice === v.name && loadingVoice !== v.name && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
{xttsVoice === v.name && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
<TouchableOpacity onPress={() => deleteVoice(v.name)} style={styles.voiceRowDelete}>
<Text style={styles.voiceRowDeleteIcon}>X</Text>
</TouchableOpacity>
@@ -1147,10 +587,7 @@ const SettingsScreen: React.FC = () => {
)}
</View>
</>)}
{/* === Speicher === */}
{currentSection === 'storage' && (<>
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
@@ -1225,96 +662,8 @@ const SettingsScreen: React.FC = () => {
)}
</View>
{/* === Update-Cache === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Update-Cache</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Heruntergeladene APK-Dateien fuer App-Updates. Werden automatisch
beim App-Start und vor jedem neuen Download geloescht — der Button
ist fuer den Notfall (z.B. wenn ein Download haengen geblieben ist).
</Text>
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
{apkCacheInfo === null ? '...' :
apkCacheInfo.count === 0 ? 'leer' :
`${apkCacheInfo.count} APK${apkCacheInfo.count === 1 ? '' : 's'} · ${apkCacheInfo.totalMB.toFixed(1)}MB`}
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={async () => {
const res = await updateService.cleanupOldApks();
ToastAndroid.show(
res.removed === 0
? 'Update-Cache war schon leer'
: `${res.removed} APK${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
ToastAndroid.SHORT,
);
const info = await updateService.getApkCacheSize();
setApkCacheInfo(info);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Update-Cache leeren</Text>
</TouchableOpacity>
</View>
{/* === TTS-Cache === */}
<Text style={[styles.sectionTitle, {marginTop: 16}]}>TTS-Cache</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
Gespeicherte Sprachausgaben (WAV pro Antwort) — werden fuer den
Play-Button und Auto-Resume nach Anrufen genutzt. Loeschen
unterbricht keine laufende Wiedergabe, alte Antworten lassen sich
danach nur nicht mehr abspielen.
</Text>
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
{ttsCacheInfo === null ? '...' :
ttsCacheInfo.count === 0 ? 'leer' :
`${ttsCacheInfo.count} WAV${ttsCacheInfo.count === 1 ? '' : 's'} · ${ttsCacheInfo.totalMB.toFixed(1)}MB`}
</Text>
<TouchableOpacity
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={async () => {
const res = await audioService.clearTtsCache();
ToastAndroid.show(
res.removed === 0
? 'TTS-Cache war schon leer'
: `${res.removed} WAV${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
ToastAndroid.SHORT,
);
const info = await audioService.getTtsCacheSize();
setTtsCacheInfo(info);
}}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>TTS-Cache leeren</Text>
</TouchableOpacity>
</View>
</>)}
{/* === Logs === */}
{currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text>
{/* Verbose-Logging-Toggle */}
<View style={styles.card}>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Verbose Logging</Text>
<Switch
value={verboseLogging}
onValueChange={(v) => {
setVerboseLogging(v);
setVerboseLoggingState(v);
}}
trackColor={{ false: '#3A3A52', true: '#0096FF' }}
thumbColor={verboseLogging ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={styles.toggleHint}>
Wenn aus: console.log wird global stummgeschaltet (Speicher schonen).
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
Debuggen via adb logcat.
</Text>
</View>
<View style={styles.card}>
{/* Tab-Umschalter */}
<View style={styles.tabRow}>
@@ -1392,10 +741,8 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.clearButtonText}>Protokoll l\u00F6schen</Text>
</TouchableOpacity>
</View>
</>)}
{/* === About === */}
{currentSection === 'about' && (<>
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
<View style={styles.card}>
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
@@ -1415,7 +762,6 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
</TouchableOpacity>
</View>
</>)}
{/* Platz am Ende */}
<View style={styles.bottomSpacer} />
@@ -1444,58 +790,6 @@ const styles = StyleSheet.create({
marginBottom: 8,
marginLeft: 4,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E1E2E',
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 14,
marginBottom: 8,
},
menuItemIcon: {
fontSize: 22,
marginRight: 14,
width: 28,
textAlign: 'center',
},
menuItemTextWrap: {
flex: 1,
},
menuItemLabel: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
menuItemDesc: {
color: '#8888AA',
fontSize: 12,
marginTop: 2,
},
menuItemChevron: {
color: '#8888AA',
fontSize: 24,
fontWeight: '300',
marginLeft: 8,
},
subScreenHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
marginBottom: 8,
},
subScreenBack: {
color: '#0096FF',
fontSize: 32,
fontWeight: '300',
marginRight: 12,
lineHeight: 36,
},
subScreenTitle: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
},
card: {
backgroundColor: '#12122A',
borderRadius: 14,
@@ -1824,98 +1118,6 @@ const styles = StyleSheet.create({
bottomSpacer: {
height: 40,
},
prerollRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
gap: 16,
},
prerollButton: {
backgroundColor: '#2A2A3E',
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 8,
minWidth: 72,
alignItems: 'center',
},
prerollButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
prerollValue: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
minWidth: 80,
textAlign: 'center',
},
infoBtn: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: '#0096FF',
alignItems: 'center',
justifyContent: 'center',
},
infoBtnText: {
color: '#0096FF',
fontSize: 13,
fontWeight: '700',
fontStyle: 'italic',
lineHeight: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalCard: {
backgroundColor: '#1E1E2E',
borderRadius: 14,
padding: 20,
maxWidth: 460,
width: '100%',
},
modalTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
marginBottom: 12,
},
modalText: {
color: '#E0E0F0',
fontSize: 14,
lineHeight: 20,
},
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;
File diff suppressed because it is too large Load Diff
-76
View File
@@ -1,76 +0,0 @@
/**
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
*
* Mehrere Komponenten koennen den Service unabhaengig "halten":
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
*
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
* den hoechstprioren Slot an (tts > rec > wake).
*/
import { NativeModules } from 'react-native';
interface BackgroundAudioNative {
start(reason: string): Promise<boolean>;
stop(): Promise<boolean>;
}
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
function topReason(): string {
for (const s of PRIORITY) {
if (slots.has(s)) return s;
}
return '';
}
async function applyState(): Promise<void> {
if (!BackgroundAudio) return;
if (slots.size === 0) {
try { await BackgroundAudio.stop(); } catch {}
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
return;
}
const reason = topReason();
try {
await BackgroundAudio.start(reason);
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
reason, [...slots].join('+'));
} catch (err: any) {
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
}
}
export async function acquireBackgroundAudio(slot: Slot): Promise<void> {
if (slots.has(slot)) return;
slots.add(slot);
await applyState();
}
export async function releaseBackgroundAudio(slot: Slot): Promise<void> {
if (!slots.has(slot)) return;
slots.delete(slot);
await applyState();
}
export function backgroundAudioActive(): boolean {
return slots.size > 0;
}
// --- Legacy API (nur tts-Slot) — fuer Aufruf-Sites die noch nichts vom Slot-
// system wissen. Mappt auf den 'tts'-Slot. ---
export const startBackgroundAudio = () => acquireBackgroundAudio('tts');
export const stopBackgroundAudio = () => releaseBackgroundAudio('tts');
-41
View File
@@ -1,41 +0,0 @@
/**
* Verbose-Logging-Toggle: console.log laesst sich global stummschalten.
* console.warn/console.error bleiben immer an — Fehler will man immer sehen.
*
* Default: an (true). Toggle ueber Settings → Protokoll → Verbose Logging.
* Beim Start wird der gespeicherte Wert geladen, vorher loggen wir normal.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
const originalLog = console.log.bind(console);
const noop = () => {};
let _verbose = true;
function applyState(): void {
console.log = _verbose ? originalLog : noop;
}
/** Wert aus AsyncStorage laden und anwenden. Beim App-Start aufrufen. */
export async function initLogger(): Promise<void> {
try {
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
_verbose = v !== 'false'; // default: true
} catch {}
applyState();
}
export function isVerboseLogging(): boolean {
return _verbose;
}
export function setVerboseLogging(verbose: boolean): void {
_verbose = verbose;
applyState();
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
}
-222
View File
@@ -1,222 +0,0 @@
/**
* PhoneCall-Service — pausiert ARIA bei Telefonaten:
*
* 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
* Status: idle / ringing / offhook
*
* 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
* AudioFocus-Loss-Event (AudioFocusModule.kt). Diese Apps requestn
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
* bekommen ein "loss" Event und reagieren genauso wie auf RINGING.
*
* In beiden Faellen wird audioService.haltAllPlayback() + wakeWordService.
* pauseForCall() gerufen. Bei call-end (idle / focus-gain) → resumeFromCall.
*
* Permission READ_PHONE_STATE ist nur fuer Pfad 1 noetig — Pfad 2 braucht
* keine extra Berechtigung weil unser eigener AudioFocus-Listener feuert.
*/
import {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
ToastAndroid,
} from 'react-native';
import audioService from './audio';
import wakeWordService from './wakeword';
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 focusSubscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle';
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
* TelephonyManager-IDLE-Event kommt. */
private interruptedByFocus: boolean = false;
async start(): Promise<boolean> {
if (this.started || Platform.OS !== 'android') return false;
// 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp,
// Signal, Discord etc.) abdecken, brauchen keine Permission.
try {
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
this.focusSubscription = focusEmitter.addListener(
'AudioFocusChanged',
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
);
console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
} catch (err: any) {
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
// 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
if (PhoneCall) {
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) {
const ok = await PhoneCall.start();
if (ok) {
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener(
'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state),
);
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
}
} else {
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
}
} catch (err: any) {
console.warn('[PhoneCall] TelephonyManager-Setup gescheitert:', err?.message || err);
}
}
this.started = true;
return true;
}
async stop(): Promise<void> {
if (!this.started) return;
try { this.subscription?.remove(); } catch {}
try { this.focusSubscription?.remove(); } catch {}
this.subscription = null;
this.focusSubscription = null;
if (PhoneCall) {
try { await PhoneCall.stop(); } catch {}
}
this.started = false;
this.lastState = 'idle';
this.interruptedByFocus = false;
}
private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return;
const prev = this.lastState;
console.log('[PhoneCall] State: %s → %s', prev, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
} else if (state === 'idle' && prev !== 'idle') {
// Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
// Der Focus-Gain-Event triggert das Resume.
if (!this.interruptedByFocus) {
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
}
}
/** AudioFocus-Loss = irgendeine andere App hat den Focus uebernommen.
* Das passiert bei VoIP-Anrufen (was wir wollen) ABER auch bei normalen
* Audio-Playern (anderer Player startet, Notification-Sound, sogar
* unsere eigenen Sound-Calls beim Play-Button). Daher checken wir den
* AudioMode — nur IN_CALL (2) oder IN_COMMUNICATION (3) zaehlt als Anruf. */
private async _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): Promise<void> {
if (type === 'loss' || type === 'loss_transient') {
// Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln.
if (this.lastState === 'ringing' || this.lastState === 'offhook') return;
// Mode pruefen — nur echte Anrufe behandeln.
let mode = -1;
try { mode = await (NativeModules.AudioFocus as any)?.getMode?.(); } catch {}
if (mode !== 2 && mode !== 3) {
// NORMAL-Mode → kein Anruf (Stefan hat z.B. Play-Button gedrueckt
// oder Spotify hat sich neu reingedraengelt). Keine Toasts.
console.log('[PhoneCall] FOCUS_LOSS ignoriert (AudioMode=%d, kein Call)', mode);
return;
}
this.interruptedByFocus = true;
this._haltForCall('Anruf erkannt (VoIP) — ARIA pausiert');
// Pollen, weil GAIN nicht zuverlaessig kommt (wir releasen den Focus
// selbst beim halt → kein automatischer GAIN). AudioMode != IN_COMMUNICATION
// = Call vorbei.
this._startVoipResumePoll();
} else if (type === 'gain') {
if (this.interruptedByFocus) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Audio frei — ARIA wieder aktiv');
}
}
}
/** Polling-Fallback: alle 3s checken ob AudioMode wieder NORMAL ist. */
private voipPollTimer: ReturnType<typeof setInterval> | null = null;
private _startVoipResumePoll(): void {
if (this.voipPollTimer) return;
this.voipPollTimer = setInterval(async () => {
if (!this.interruptedByFocus) {
this._stopVoipResumePoll();
return;
}
try {
const mode = await (NativeModules.AudioFocus as any)?.getMode?.();
// 0 = MODE_NORMAL — Call ist vorbei
if (typeof mode === 'number' && mode === 0) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
} catch {}
}, 3000);
}
private _stopVoipResumePoll(): void {
if (this.voipPollTimer) {
clearInterval(this.voipPollTimer);
this.voipPollTimer = null;
}
}
private _haltForCall(toast: string): void {
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
audioService.captureInterruption();
// pauseForCall (statt haltAllPlayback): pcmBuffer + messageId bleiben,
// weitere Chunks werden weiter gesammelt damit isFinal die WAV schreibt.
audioService.pauseForCall(toast);
wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
}
private _resumeAfterCall(toast: string): void {
// Anruf-Pause aufheben — neue Chunks duerfen wieder direkt abgespielt
// werden (falls die Bridge mid-Anruf isFinal noch nicht geschickt hat).
audioService.endCallPause();
wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
// 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
// Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
// dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
// Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
// Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
// gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
setTimeout(() => {
audioService.resumeFromInterruption(30000).then(ok => {
if (ok) {
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
}
}).catch(() => {});
}, 800);
}
}
const phoneCallService = new PhoneCallService();
export default phoneCallService;
-74
View File
@@ -29,11 +29,6 @@ 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) {
@@ -50,71 +45,6 @@ class UpdateService {
});
}
/** Sucht ueberall wo .apk-Dateien herumliegen koennten. */
private async _apkSearchDirs(): Promise<string[]> {
const dirs = [RNFS.CachesDirectoryPath, RNFS.DocumentDirectoryPath];
if ((RNFS as any).ExternalCachesDirectoryPath) {
dirs.push((RNFS as any).ExternalCachesDirectoryPath);
}
if (RNFS.ExternalDirectoryPath) {
dirs.push(RNFS.ExternalDirectoryPath);
}
return dirs;
}
/** Raeumt alte heruntergeladene APK-Dateien aus den App-Verzeichnissen auf.
* Public damit Settings den Button "Update-Cache leeren" benutzen kann. */
async cleanupOldApks(keepCurrentName?: string): Promise<{ removed: number; freedMB: number }> {
const dirs = await this._apkSearchDirs();
let removed = 0;
let freed = 0;
for (const dir of dirs) {
try {
if (!(await RNFS.exists(dir))) continue;
const files = await RNFS.readDir(dir);
const apks = files.filter(f => /\.apk$/i.test(f.name));
for (const f of apks) {
if (keepCurrentName && f.name === keepCurrentName) continue;
try {
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path);
removed += 1;
freed += size;
console.log(`[Update] APK geloescht: ${f.path} (${(size / 1024 / 1024).toFixed(1)}MB)`);
} catch (err: any) {
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.path} (${err?.message || err})`);
}
}
} catch (err: any) {
console.warn(`[Update] Cleanup-Fehler in ${dir}: ${err?.message || err}`);
}
}
const freedMB = freed / 1024 / 1024;
if (removed > 0) {
console.log(`[Update] Cleanup fertig: ${removed} APK${removed === 1 ? '' : 's'} entfernt, ${freedMB.toFixed(1)}MB freigegeben`);
}
return { removed, freedMB };
}
/** Aktuelle Groesse aller APK-Dateien in den App-Verzeichnissen (in MB). */
async getApkCacheSize(): Promise<{ count: number; totalMB: number }> {
const dirs = await this._apkSearchDirs();
let count = 0;
let total = 0;
for (const dir of dirs) {
try {
if (!(await RNFS.exists(dir))) continue;
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !/\.apk$/i.test(f.name)) continue;
count += 1;
total += parseInt(f.size as any, 10) || 0;
}
} catch {}
}
return { count, totalMB: total / 1024 / 1024 };
}
/** Bei App-Start Update pruefen */
checkForUpdate(): void {
if (this.checking) return;
@@ -181,10 +111,6 @@ 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');
-71
View File
@@ -1,71 +0,0 @@
/**
* Spielt einen kurzen "Bereit"-Sound (Airplane Ding-Dong) wenn das Mikrofon
* nach Wake-Word-Erkennung wirklich offen ist. Datei liegt in
* android/app/src/main/res/raw/wake_ready_sound.mp3 — wird ueber Android's
* Resource-System per react-native-sound abgespielt.
*
* Toggle: AsyncStorage-Key 'aria_wake_ready_sound_enabled' (default true).
*/
import Sound from 'react-native-sound';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const WAKE_READY_SOUND_STORAGE_KEY = 'aria_wake_ready_sound_enabled';
Sound.setCategory('Playback', false);
let cachedSound: Sound | null = null;
let cachedFailed = false;
function getSound(): Promise<Sound | null> {
if (cachedFailed) return Promise.resolve(null);
if (cachedSound) return Promise.resolve(cachedSound);
return new Promise(resolve => {
const s = new Sound('wake_ready_sound', Sound.MAIN_BUNDLE, (err) => {
if (err) {
console.warn('[WakeReadySound] Konnte nicht geladen werden:', err);
cachedFailed = true;
resolve(null);
return;
}
cachedSound = s;
resolve(s);
});
});
}
/** True wenn der User den "Bereit"-Sound aktiviert hat. Default: true. */
export async function isWakeReadySoundEnabled(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(WAKE_READY_SOUND_STORAGE_KEY);
if (raw === null) return true; // Default an
return raw === 'true';
} catch {
return true;
}
}
export async function setWakeReadySoundEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_READY_SOUND_STORAGE_KEY, String(enabled));
} catch {}
}
/** Spielt den Bereit-Sound einmal ab — non-blocking. Wenn der User ihn
* in den Settings deaktiviert hat oder die Datei nicht ladbar ist,
* passiert einfach nichts. */
export async function playWakeReadySound(): Promise<void> {
if (!(await isWakeReadySoundEnabled())) return;
const s = await getSound();
if (!s) return;
try {
s.stop(() => {
s.setCurrentTime(0);
s.play((success) => {
if (!success) console.warn('[WakeReadySound] Wiedergabe fehlgeschlagen');
});
});
} catch (e) {
console.warn('[WakeReadySound] play() Exception:', e);
}
}
+20 -349
View File
@@ -1,372 +1,56 @@
/**
* Gespraechsmodus / Wake Word Service
* Gespraechsmodus — "Ohr-Button"
*
* 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.
* 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 → ...
*
* 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).
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
*/
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio } from './backgroundAudio';
type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
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 };
export type WakeWordState = 'off' | 'listening' | 'detected';
class WakeWordService {
private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = [];
/** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
* wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
private bargeCallbacks: WakeWordCallback[] = [];
/** True solange Wake-Word parallel zu TTS aktiv ist. */
private bargeListening: boolean = false;
/** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */
private callPaused: boolean = false;
private preCallState: WakeWordState = 'off';
/** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections
* ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
* der openWakeWord faelschlich triggern kann. */
private cooldownUntilMs: number = 0;
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. */
/** Gespraechsmodus starten */
async start(): Promise<boolean> {
if (this.state !== 'off') return true;
// Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background-
// Lauschen funktioniert (Android braucht foregroundServiceType=microphone
// aktiv zum Zeitpunkt des AudioRecord.startRecording).
await acquireBackgroundAudio('wake');
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');
if (this.state === 'listening') return true;
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
this.setState('listening');
// Sofort erste Aufnahme starten
setTimeout(() => {
if (this.state === 'conversing') {
if (this.state === 'listening') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 500);
return true;
}
/** Komplett ausschalten (Ohr abschalten) */
async stop(): Promise<void> {
console.log('[WakeWord] Ohr deaktiviert');
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
/** Gespraechsmodus stoppen */
stop(): void {
console.log('[WakeWord] Gespraechsmodus deaktiviert');
this.setState('off');
}
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
* die openWakeWord faelschlich als Trigger interpretiert. */
setResumeCooldown(ms: number = 1500): void {
this.cooldownUntilMs = Date.now() + ms;
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
}
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> {
const now = Date.now();
if (now < this.cooldownUntilMs) {
const left = this.cooldownUntilMs - now;
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
return;
}
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening);
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
if (this.state === 'conversing') {
this.bargeCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
});
// Kein erneutes setState — wir bleiben in 'conversing'.
return;
}
this.setState('conversing');
setTimeout(() => {
if (this.state === 'conversing') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 200);
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
* Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
* AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
async startBargeListening(): Promise<void> {
if (!this.nativeReady || !OpenWakeWord) return;
if (this.state !== 'conversing') return;
if (this.bargeListening) return;
try {
await OpenWakeWord.start();
this.bargeListening = true;
console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
} catch (err) {
console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
}
}
/** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
* naechste Aufnahme das Mikro braucht. */
async stopBargeListening(): Promise<void> {
if (!this.bargeListening) return;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Barge-Listening aus');
}
/** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State
* merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA
* soll nicht in laufende Telefonate reinhoeren. */
async pauseForCall(): Promise<void> {
if (this.callPaused) return;
this.preCallState = this.state;
if (this.state === 'off') {
this.callPaused = true; // merken dass wir pausiert wurden
return;
}
this.callPaused = true;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState);
}
/** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation
* geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */
async resumeFromCall(): Promise<void> {
if (!this.callPaused) return;
const restoreTo = this.preCallState;
this.callPaused = false;
this.preCallState = 'off';
console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo);
if (restoreTo === 'off') return;
// Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen,
// sicher zu armed degraden.
if (restoreTo === 'conversing') this.setState('armed');
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (err) {
console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err);
}
}
}
/** 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 */
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
async resume(): Promise<void> {
if (this.state !== 'conversing') return;
if (this.state !== 'listening') return;
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
await new Promise(resolve => setTimeout(resolve, 800));
if (this.state === 'conversing') {
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
if (this.state === 'listening') {
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
this.wakeCallbacks.forEach(cb => cb());
}
}
/** True solange das Ohr aktiv ist (armed ODER conversing). */
isActive(): boolean {
return this.state !== 'off';
}
isConversing(): boolean {
return this.state === 'conversing';
}
hasWakeWord(): boolean {
return this.nativeReady;
}
getKeyword(): WakeKeyword {
return this.keyword;
return this.state === 'listening';
}
// --- Callbacks ---
@@ -378,19 +62,6 @@ class WakeWordService {
};
}
/** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
* spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
onBargeIn(callback: WakeWordCallback): () => void {
this.bargeCallbacks.push(callback);
return () => {
this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
};
}
isBargeListening(): boolean {
return this.bargeListening;
}
onStateChange(callback: StateCallback): () => void {
this.stateCallbacks.push(callback);
return () => {
+10 -3
View File
@@ -54,6 +54,13 @@ Fuer Web-Anfragen: **WebFetch** oder **Bash mit curl**. Niemals sagen "ich habe
## Stimme
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
Stimmen aus Audio-Samples klonen (Diagnostic → Stimmen → Stimme klonen)
und in App + Diagnostic auswaehlen.
| Stimme | Modell | Wann |
|--------|--------|------|
| **Ramona** (weiblich) | `de_DE-ramona-low` | Alltag, Antworten, Gespraeche (Standard) |
| **Thorsten** (maennlich, tief) | `de_DE-thorsten-high` | Epische Momente, Alarme, besondere Ereignisse |
**Thorsten spricht bei:**
- Build erfolgreich deployed
- Ticket geloest / Aufgabe abgeschlossen
- Kritischer Alarm (Server down, Sicherheitswarnung)
- Wenn Stefan sagt "So soll es sein"
+5 -3
View File
@@ -80,8 +80,10 @@ Wenn ein Tool nicht klappt, probiere die Alternative. Niemals sagen "ich habe ke
## Stimme
TTS laeuft ueber F5-TTS auf der Gamebox (Voice Cloning). Stefan kann
eigene Stimmen aus Audio-Samples klonen und in App/Diagnostic auswaehlen.
| Stimme | Modell | Wann |
|--------|--------|------|
| **Ramona** (weiblich) | `de_DE-ramona-low` | Alltag, Antworten, Gespraeche (Standard) |
| **Thorsten** (maennlich, tief) | `de_DE-thorsten-high` | Epische Momente, Alarme, besondere Ereignisse |
## Gedaechtnis (Memory)
@@ -145,4 +147,4 @@ Danach den Eintrag in `memory/MEMORY.md` (Index) verlinken.
### Netzwerk
- **aria-net:** Internes Docker-Netz (proxy, aria-core)
- **RVS:** Rendezvous-Server im Rechenzentrum — Relay fuer die Android-App
- **Bridge:** Voice Bridge (orchestriert STT/TTS via Gamebox-Bridges) — teilt Netzwerk mit aria-core
- **Bridge:** Voice Bridge (Whisper STT + Piper TTS) — teilt Netzwerk mit aria-core
+133 -507
View File
@@ -1,13 +1,17 @@
"""
ARIA Voice Bridge — Hauptmodul.
Verbindet die Android App (via RVS) mit ARIA-Core. Spracheingabe laeuft
ueber die whisper-bridge (Gamebox, faster-whisper auf CUDA), Sprachausgabe
ueber die f5tts-bridge (Voice Cloning, satzweises PCM-Streaming).
Verbindet die Android App (via RVS) mit ARIA-Core und bietet
lokale Spracheingabe (Wake-Word + Whisper STT) und Sprachausgabe (Piper TTS).
Nachrichtenfluss:
App → RVS → Bridge → aria-core
aria-core → Bridge → f5tts-bridge → PCM → RVS → App
aria-core → Bridge → RVS → App
→ Lautsprecher (TTS)
Stimmen:
- Ramona (de_DE-ramona-low) — Alltag, Gespraeche
- Thorsten (de_DE-thorsten-high) — epische Momente, Alarme
"""
from __future__ import annotations
@@ -253,12 +257,6 @@ def clean_text_for_tts(text: str) -> str:
for pat, repl in _UNIT_WORDS:
t = _re_tts.sub(pat, repl, t)
# Generisches Buchstabieren: alle verbleibenden 2-5-Zeichen-Grossbuchstaben-Woerter
# (XTTS, USB, DNS, JSON, HTML, ...) → "X T T S". Laeuft NACH der expliziten Liste,
# damit TTS/GPU/... schon aufgeloest sind. "WLAN"-artige, die als Wort gesprochen
# werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden.
t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t)
# Anfuehrungszeichen
t = _re_tts.sub(r'["""„`]', '', t)
@@ -321,16 +319,8 @@ class STTEngine:
Erkannter Text oder leerer String.
"""
if self.model is None:
# 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 ""
logger.error("Whisper-Modell nicht initialisiert")
return ""
try:
# Audio als float32 normalisieren
@@ -489,10 +479,9 @@ class ARIABridge:
self.current_mode = self._load_persisted_mode()
self.running = False
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
# 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:
@@ -502,16 +491,7 @@ class ARIABridge:
vc = json.load(f)
self.tts_enabled = vc.get("ttsEnabled", True)
self.xtts_voice = vc.get("xttsVoice", "")
# 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")
logger.info("Voice-Config geladen: tts=%s voice=%s", self.tts_enabled, self.xtts_voice or "default")
except Exception as e:
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
@@ -537,25 +517,6 @@ 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.
@@ -568,9 +529,8 @@ class ARIABridge:
logger.info("ARIA Voice Bridge startet...")
logger.info("=" * 50)
# 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.
# STT IMMER laden — verarbeitet Audio von der App (braucht kein Sounddevice)
self.stt_engine.initialize()
# Audio-Hardware pruefen (fuer lokales Mikro/Lautsprecher)
self.audio_available = False
@@ -677,10 +637,7 @@ class ARIABridge:
while self.running:
try:
logger.info("[core] Verbinde: %s", self.ws_url)
# max_size=50MB damit grosse Bilder/Voice-Uploads durchgehen.
# Python-websockets Default ist nur 1 MiB → 5MB JPEG sprengt
# das Limit, Connection wird silent gedroppt.
async with websockets.connect(self.ws_url, max_size=50 * 1024 * 1024) as ws:
async with websockets.connect(self.ws_url) as ws:
# OpenClaw Handshake durchfuehren
if not await self._openclaw_handshake(ws):
logger.error("[core] Handshake fehlgeschlagen — Reconnect")
@@ -786,29 +743,13 @@ class ARIABridge:
await self._emit_activity("idle", "")
if not text:
logger.warning("[core] chat final ohne Text: %s", json.dumps(payload)[:200])
# App+Diagnostic informieren statt stumm — sonst wartet die
# UI ewig auf eine Antwort die nicht kommt. Passiert z.B.
# wenn Claude-Vision das Bild ablehnt (leere Antwort)
# oder die Antwort nur aus Tool-Calls ohne Final-Text bestand.
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": "[Hinweis] Antwort ohne Text — moeglicherweise Bild zu gross fuer Vision-API oder reine Tool-Ausfuehrung.",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
logger.info("[core] Antwort: '%s'", text[:80])
await self._process_core_response(text, payload)
return
if state == "error":
# OpenClaw nutzt errorMessage statt error bei state=error.
error = (payload.get("error")
or payload.get("errorMessage")
or payload.get("message")
or "Unbekannt")
error = payload.get("error", "Unbekannt")
logger.error("[core] Chat-Fehler: %s", error)
self._last_chat_final_at = asyncio.get_event_loop().time()
await self._emit_activity("idle", "")
@@ -844,12 +785,7 @@ class ARIABridge:
return
if event_name == "chat:error":
# OpenClaw legt den echten Text manchmal in errorMessage ab
# (state=error). Vorher wurde nur error/message gechecked → "Unbekannt".
error = (payload.get("error")
or payload.get("errorMessage")
or payload.get("message")
or "Unbekannt")
error = payload.get("error", payload.get("message", "Unbekannt"))
logger.error("[core] Chat-Fehler (legacy): %s", error)
await self._send_to_rvs({
"type": "chat",
@@ -936,13 +872,12 @@ class ARIABridge:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
return
# 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.
# Voice bestimmen: App-Override fuer diesen Request > globale Default-Voice
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
xtts_speed = self._next_speed_override or 1.0
# 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
tts_text = tts_text_preview or text
if not tts_text:
@@ -959,15 +894,13 @@ 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 (voice=%s, speed=%.2fx): '%s'",
xtts_voice or "default", xtts_speed, tts_text[:60])
logger.info("[core] XTTS-Request gesendet (%s): '%s'", xtts_voice or "default", tts_text[:60])
except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
@@ -1012,29 +945,6 @@ 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:
@@ -1048,76 +958,6 @@ class ARIABridge:
except Exception as e:
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
def _build_core_text(self, text: str, interrupted: bool = False,
location: Optional[dict] = None) -> str:
"""Baut den Text fuer aria-core mit allen relevanten Hints (Barge-In,
GPS-Position). Hints sind in eckigen Klammern, der eigentliche User-
Text folgt unverandert."""
parts: list[str] = []
if interrupted:
parts.append(
"[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch "
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
)
if location and isinstance(location, dict):
lat = location.get("lat")
lon = location.get("lon") or location.get("lng")
if lat is not None and lon is not None:
parts.append(
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
)
if parts:
return " ".join(parts) + " " + text
return text
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:
@@ -1165,8 +1005,7 @@ class ARIABridge:
try:
url = f"{current_url}?token={self.rvs_token}"
logger.info("[rvs] Verbinde: %s", current_url)
# max_size=50MB (siehe core-Connect oben — gleicher Grund).
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws:
async with websockets.connect(url) as ws:
self.ws_rvs = ws
retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
@@ -1175,12 +1014,6 @@ 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())
@@ -1264,38 +1097,14 @@ class ARIABridge:
if sender in ("aria", "stt"):
return
text = payload.get("text", "")
# 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
# 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)
if text:
interrupted = bool(payload.get("interrupted", False))
location = payload.get("location") or None
# 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:
core_text = self._build_core_text(text, interrupted, location)
logger.info("[rvs] App-Chat%s%s: '%s'",
" [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "",
text[:80])
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
return
if msg_type == "cancel_request":
@@ -1345,14 +1154,8 @@ class ARIABridge:
if not text:
return
tts_text = clean_text_for_tts(text) or text
# Voice + Speed aus App-Payload gewinnen, sonst global/default
# Voice aus App-Payload gewinnt, sonst global
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:
@@ -1362,7 +1165,6 @@ class ARIABridge:
"payload": {
"text": tts_text,
"voice": xtts_voice,
"speed": xtts_speed,
"language": "de",
"requestId": xtts_request_id,
"messageId": message_id,
@@ -1375,10 +1177,7 @@ class ARIABridge:
return
elif msg_type == "config":
# 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.
# Konfiguration von App/Diagnostic empfangen + persistent speichern
changed = False
if "ttsEnabled" in payload:
self.tts_enabled = bool(payload["ttsEnabled"])
@@ -1390,21 +1189,11 @@ class ARIABridge:
changed = True
if "whisperModel" in payload:
new_model = payload["whisperModel"]
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
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
# Persistent speichern in Shared Volume
if changed:
try:
@@ -1414,7 +1203,6 @@ 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)
@@ -1454,79 +1242,70 @@ class ARIABridge:
await self.ws_core.send(raw_message)
elif msg_type == "file":
# 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.
# Datei von der App → als Text-Nachricht an aria-core
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 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/"):
if file_b64 and file_type.startswith("image/"):
# Bild in Shared Volume speichern
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)
else:
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
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)
# Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).
CLAUDE_VISION_FORMATS = ("image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif")
if file_type.lower() in CLAUDE_VISION_FORMATS:
file_size_bytes = os.path.getsize(file_path)
if file_size_bytes > 2 * 1024 * 1024:
try:
from PIL import Image
with Image.open(file_path) as img:
orig_w, orig_h = img.size
# Anthropic-Empfehlung: max 1568px lange Seite. RGB-Konvertierung
# falls RGBA/Palette (JPEG braucht RGB).
img.thumbnail((1568, 1568), Image.Resampling.LANCZOS)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(file_path, "JPEG", quality=85, optimize=True)
new_size_bytes = os.path.getsize(file_path)
logger.info("[rvs] Bild verkleinert: %dx%d → %dx%d, %.1fMB → %.1fMB",
orig_w, orig_h, img.size[0], img.size[1],
file_size_bytes / 1024 / 1024,
new_size_bytes / 1024 / 1024)
except Exception as e:
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
file_name, e)
# 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)
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")
elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
@@ -1565,203 +1344,31 @@ class ARIABridge:
if not audio_b64:
logger.warning("[rvs] Audio ohne Daten empfangen")
return
# 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
interrupted = bool(payload.get("interrupted", False))
audio_request_id = payload.get("audioRequestId", "") or ""
location = payload.get("location") or None
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s%s",
mime_type, duration_ms, len(audio_b64) // 1365,
" [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "",
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
asyncio.create_task(self._process_app_audio(
audio_b64, mime_type, interrupted, audio_request_id, location))
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
# 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)
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))
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,
interrupted: bool = False,
audio_request_id: str = "",
location: Optional[dict] = None) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
(Barge-In). Wird als Hinweis-Praefix an aria-core mitgegeben damit ARIA die
Korrektur/Unterbrechung in den Kontext einordnen kann statt als reine
Folgefrage zu behandeln.
audio_request_id: Korrelations-ID die die App im audio-Event mitschickt — wird
unveraendert ans STT-Result zurueckgegeben damit die App die EXAKT richtige
'wird verarbeitet'-Bubble ersetzen kann (auch bei mehreren parallelen Aufnahmen).
location: Optional GPS-Position {lat, lon} — wird als Hinweis-Praefix mitgegeben
damit ARIA bei standortbezogenen Fragen sie nutzen kann."""
# 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])
# Hints (Barge-In, GPS) als Praefix vorschalten — gemeinsamer Helper
# mit dem chat-Pfad damit das Verhalten konsistent ist.
core_text = self._build_core_text(text, interrupted, location)
# ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop)
try:
stt_payload = {
"text": text,
"sender": "stt",
}
if audio_request_id:
stt_payload["audioRequestId"] = audio_request_id
# GPS aus dem Original-Audio-Payload mitgeben — Diagnostic
# zeigt sie sonst nicht an (App sendet location nur einmal,
# die im audio-Payload). Reine Anzeige-Information.
if location:
stt_payload["location"] = location
ok = await self._send_to_rvs({
"type": "chat",
"payload": stt_payload,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
if ok:
logger.info("[rvs] STT-Text an RVS broadcastet (sender=stt)")
else:
logger.warning("[rvs] STT-Text NICHT broadcastet — _send_to_rvs lieferte False")
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."""
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."""
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()
@@ -1776,34 +1383,55 @@ class ARIABridge:
)
if result.returncode != 0:
logger.error("[rvs] FFmpeg Fehler: %s", result.stderr.decode()[:200])
return None
return
# 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 None
return
duration_s = len(audio_data) / 16000.0
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)
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")
except Exception:
logger.exception("[rvs] Lokales STT fehlgeschlagen")
return None
logger.exception("[rvs] Audio-Verarbeitung fehlgeschlagen")
finally:
for f in (tmp_in, tmp_out):
# Temp-Dateien aufraeumen
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) -> bool:
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check.
Rueckgabe: True wenn erfolgreich gesendet, False wenn Verbindung tot.
"""
async def _send_to_rvs(self, message: dict) -> None:
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check."""
if self.ws_rvs is None:
return False
return
# Ping-Check: Verbindung wirklich aktiv?
try:
@@ -1817,14 +1445,12 @@ class ARIABridge:
pass
self.ws_rvs = None
# Reconnect wird vom connect_to_rvs Loop uebernommen
return False
return
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 ─────────────────────────────
-3
View File
@@ -16,6 +16,3 @@ sounddevice
# Wake-Word Erkennung
openwakeword
# Bild-Resizing (zu grosse Pixel-Bilder shrinken bevor Claude-Vision sie sieht — 5MB-Limit)
Pillow
-16
View File
@@ -1,16 +0,0 @@
@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
View File
@@ -1,184 +0,0 @@
# ================================================================
# 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
+63 -355
View File
@@ -63,35 +63,24 @@
.log-entry.pipeline-sep { color: #333; margin: 6px 0 2px; }
.chat-box { background: #080810; border: 1px solid #1E1E2E; border-radius: 6px;
min-height: 120px; max-height: 250px; overflow-y: auto;
padding: 12px; margin-bottom: 8px; display: flex; flex-direction: column; gap: 8px; }
.chat-msg { padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.5;
word-wrap: break-word; max-width: 80%; white-space: pre-wrap;
box-shadow: 0 1px 2px rgba(0,0,0,0.4); }
.chat-msg.sent { background: #0096FF; color: #fff; align-self: flex-end;
border-bottom-right-radius: 4px; }
.chat-msg.received { background: #1E1E2E; color: #E8E8F0; align-self: flex-start;
border-bottom-left-radius: 4px; }
.chat-msg.error { background: #3B1010; color: #FF6B6B; align-self: flex-start; }
.chat-msg .meta { font-size: 10px; color: rgba(255,255,255,0.4); margin-top: 4px;
display: block; }
min-height: 120px; max-height: 250px; overflow-y: auto; padding: 8px; margin-bottom: 8px; }
.chat-msg { margin-bottom: 6px; padding: 6px 10px; border-radius: 6px; font-size: 13px; line-height: 1.5; word-wrap: break-word; }
.chat-msg.sent { background: #0096FF; color: #fff; margin-left: 20%; text-align: right; }
.chat-msg.received { background: #1E1E2E; margin-right: 20%; }
.chat-msg.error { background: #3B1010; color: #FF6B6B; }
.chat-msg .meta { font-size: 10px; color: rgba(255,255,255,0.4); margin-top: 2px; }
.chat-msg a { color: #66BBFF; text-decoration: underline; }
.chat-msg.sent a { color: #CCEEFF; }
.chat-msg .chat-media { max-width: 100%; max-height: 200px; border-radius: 8px;
margin-top: 6px; cursor: pointer; display: block; }
.chat-msg .chat-media { max-width: 100%; max-height: 200px; border-radius: 4px; margin-top: 4px; cursor: pointer; display: block; }
.chat-msg .chat-media:hover { opacity: 0.85; }
.lightbox-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.92);
z-index:2000; justify-content:center; align-items:center; cursor:pointer; }
.lightbox-overlay.open { display:flex; }
.lightbox-overlay img, .lightbox-overlay video { max-width:95vw; max-height:95vh; border-radius:8px; }
.input-row { display: flex; gap: 6px; align-items: flex-end; }
.input-row input, .input-row textarea {
flex: 1; background: #1E1E2E; border: 1px solid #333; border-radius: 6px;
padding: 8px 12px; color: #E0E0F0; font-family: inherit; font-size: 13px;
}
.input-row textarea { resize: none; min-height: 38px; max-height: 200px; line-height: 1.4;
overflow-y: auto; }
.input-row { display: flex; gap: 6px; }
.input-row input { flex: 1; background: #1E1E2E; border: 1px solid #333; border-radius: 6px;
padding: 8px 12px; color: #E0E0F0; font-family: inherit; font-size: 13px; }
/* Terminal Modal */
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85);
@@ -138,43 +127,6 @@
</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;">
@@ -278,10 +230,6 @@
<input type="checkbox" id="tts-debug-toggle" onchange="toggleTtsDebug()" style="margin-right:4px;vertical-align:middle;">
TTS-Text einblenden
</label>
<label style="color:#8888AA;font-size:11px;cursor:pointer;">
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
GPS-Position einblenden
</label>
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
</div>
</div>
@@ -297,7 +245,7 @@
&#x1F4CE;
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
</label>
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..." onpaste="handleDiagPaste(event)">
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
</div>
@@ -315,7 +263,7 @@
<span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text-fs">ARIA denkt...</span>
</div>
<div class="input-row" style="margin-top:8px;">
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
<input type="text" id="chat-input-fs" placeholder="Nachricht an ARIA..." onkeydown="if(event.key==='Enter'){testRVSFS();event.preventDefault();}">
<button class="btn" onclick="testGatewayFS()">Gateway senden</button>
<button class="btn" onclick="testRVSFS()">Via RVS senden</button>
</div>
@@ -489,73 +437,18 @@
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
</div>
<!-- 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;">F5-TTS Stimme:</label>
<!-- XTTS Stimme -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<label style="color:#8888AA;font-size:12px;">XTTS 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="" disabled>(keine Stimme gewaehlt)</option>
<option value="">Standard (XTTS Default)</option>
</select>
<button class="btn secondary" onclick="loadXTTSVoices()" style="padding:4px 10px;font-size:11px;">Laden</button>
</div>
<div id="voice-status" style="font-size:11px;min-height:14px;margin-bottom:12px;color:#8888AA;"></div>
<!-- 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>
@@ -669,6 +562,24 @@
</div>
</div>
<!-- Highlight-Trigger -->
<div class="settings-section">
<h2>Highlight-Trigger</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Woerter die automatisch die Highlight-Stimme (Thorsten) ausloesen.
Eines pro Zeile. Aenderungen werden in der Bridge gespeichert.
</div>
<div class="card" style="max-width:500px;">
<textarea id="highlight-triggers" rows="8" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:6px;padding:8px;color:#fff;font-size:13px;font-family:monospace;resize:vertical;"
placeholder="Lade..."></textarea>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn" onclick="saveHighlightTriggers()" style="flex:1;">Speichern</button>
<button class="btn secondary" onclick="loadHighlightTriggers()" style="flex:1;">Neu laden</button>
</div>
<div id="trigger-status" style="font-size:11px;color:#555570;margin-top:6px;"></div>
</div>
</div>
<!-- Tool-Berechtigungen -->
<div class="settings-section">
<h2>Tool-Berechtigungen</h2>
@@ -929,58 +840,14 @@
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;
}
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;
const ms = msg.payload?.loadMs;
const statusEl = document.getElementById('voice-status');
if (statusEl) {
if (err) {
statusEl.textContent = `⚠️ Stimme "${v}" Fehler: ${err}`;
statusEl.style.color = '#FF3B30';
} else {
statusEl.textContent = `✅ Stimme "${v || 'Standard'}" bereit${ms ? ` (${(ms/1000).toFixed(1)}s)` : ''}`;
statusEl.style.color = '#34C759';
}
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 5000);
}
addLog('info', 'xtts', err ? `Voice "${v}": ${err}` : `Voice "${v || 'Standard'}" bereit`);
if (msg.type === 'trigger_list') {
const textarea = document.getElementById('highlight-triggers');
textarea.value = (msg.triggers || []).join('\n');
document.getElementById('trigger-status').textContent = msg.triggers.length + ' Trigger geladen';
document.getElementById('trigger-status').style.color = '#8888AA';
return;
}
@@ -1008,7 +875,7 @@
if (sender === 'aria') return;
const chatType = 'sent';
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
addChat(chatType, p.text || '?', label, { location: p.location });
addChat(chatType, p.text || '?', label);
return;
}
if (msg.type === 'proxy_result') {
@@ -1399,16 +1266,6 @@
if (el) el.checked = showTtsDebug;
}
// Debug-Toggle: GPS-Position unter User-Nachrichten einblenden (nur Diagnostic).
// App zeigt's bewusst nicht — die Position geht nur an aria-core.
let showGpsDebug = localStorage.getItem('aria-show-gps-debug') === '1';
function toggleGpsDebug() {
showGpsDebug = !showGpsDebug;
localStorage.setItem('aria-show-gps-debug', showGpsDebug ? '1' : '0');
const el = document.getElementById('gps-debug-toggle');
if (el) el.checked = showGpsDebug;
}
// Minimal-JS-Port von clean_text_for_tts() (Bridge) — reine Anzeige
function previewTtsText(text) {
if (!text) return '';
@@ -1448,18 +1305,7 @@
ttsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(0,150,255,0.08);border-left:2px solid #0096FF;font-size:11px;color:#88AACC;"><span style="color:#0096FF;font-weight:bold;">TTS:</span> ${escapeHtml(ttsText)}</div>`;
}
}
// Optional: GPS-Position als Block unter User-Nachrichten (nur Diagnostic)
let gpsBlock = '';
if (showGpsDebug && options && options.location) {
const loc = options.location;
const lat = typeof loc.lat === 'number' ? loc.lat.toFixed(6) : '?';
const lon = typeof loc.lon === 'number' ? loc.lon.toFixed(6) : (typeof loc.lng === 'number' ? loc.lng.toFixed(6) : '?');
if (lat !== '?' && lon !== '?') {
const mapLink = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
gpsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(52,199,89,0.08);border-left:2px solid #34C759;font-size:11px;color:#88BB99;"><span style="color:#34C759;font-weight:bold;">📍 GPS:</span> <a href="${mapLink}" target="_blank" rel="noopener" style="color:#88BB99;text-decoration:underline;">${lat}, ${lon}</a></div>`;
}
}
const html = `${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
const html = `${linked}${ttsBlock}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
// Thinking-Indikator ausblenden bei neuer Nachricht
updateThinkingIndicator({ activity: 'idle' });
@@ -1524,68 +1370,6 @@
'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'),
@@ -1635,75 +1419,16 @@
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="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>`
+ `<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>`
+ `</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 });
@@ -1825,24 +1550,7 @@
const ttsEnabled = document.getElementById('diag-tts-enabled').checked;
const xttsVoice = document.getElementById('diag-xtts-voice').value;
const whisperModel = document.getElementById('diag-whisper-model').value;
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...`;
statusEl.style.color = '#FFD60A';
}
send({ action: 'send_voice_config', ttsEnabled, xttsVoice, whisperModel });
}
// ── Passwort-Feld Anzeigen/Verbergen ─────────────────────
@@ -1957,6 +1665,20 @@
}
}
// ── Highlight-Trigger ────────────────────────
function loadHighlightTriggers() {
send({ action: 'get_triggers' });
}
function saveHighlightTriggers() {
const text = document.getElementById('highlight-triggers').value;
const triggers = text.split('\n').map(t => t.trim()).filter(t => t.length > 0);
send({ action: 'save_triggers', triggers });
document.getElementById('trigger-status').textContent = 'Gespeichert (' + triggers.length + ' Trigger)';
document.getElementById('trigger-status').style.color = '#34C759';
}
// Beim Tab-Wechsel zu Einstellungen: Trigger laden
const origSwitchMainTab = typeof switchMainTab === 'function' ? switchMainTab : null;
// ── Modus-Wechsel ────────────────────────────
// Kanonische IDs (matchen bridge/modes.py canonical_id + android ModeSelector)
const MODE_LABELS = { normal: 'Normal', nicht_stoeren: 'Nicht stoeren', fluester: 'Fluestern', hangar: 'Hangar', gaming: 'Gaming' };
@@ -2065,23 +1787,10 @@
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Auto-Resize fuer Textarea — wuchst mit dem Inhalt bis zum max-height
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
// Enter sendet, Shift+Enter macht neue Zeile (chat-Standard).
function chatInputKeydown(e, sendFn) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendFn();
// Textarea zurueck auf 2 rows setzen
e.target.style.height = 'auto';
}
}
document.getElementById('chat-input').addEventListener('keydown', (e) => chatInputKeydown(e, testRVS));
document.getElementById('chat-input-fs').addEventListener('keydown', (e) => chatInputKeydown(e, testRVSFS));
// Enter-Taste sendet via Gateway
document.getElementById('chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') testRVS();
});
// Escape schliesst Lightbox
document.addEventListener('keydown', (e) => {
@@ -2441,8 +2150,9 @@
document.querySelectorAll('.main-nav-btn').forEach(b => {
if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active');
});
// Einstellungen: Config + QR laden
// Einstellungen: Config + Trigger + QR laden
if (tab === 'settings') {
loadHighlightTriggers();
send({ action: 'get_voice_config' });
loadRuntimeConfig();
loadOnboardingQR();
@@ -2476,8 +2186,6 @@
// Toggle-Checkbox initial korrekt setzen
const ttsToggleEl = document.getElementById('tts-debug-toggle');
if (ttsToggleEl) ttsToggleEl.checked = showTtsDebug;
const gpsToggleEl = document.getElementById('gps-debug-toggle');
if (gpsToggleEl) gpsToggleEl.checked = showGpsDebug;
// Disk-Space Banner aktualisieren (wird vom Server via disk_status gepusht)
function updateDiskBanner(status) {
+25 -147
View File
@@ -626,36 +626,6 @@ function connectRVS(forcePlain) {
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
broadcast({ type: "mode", payload: msg.payload });
} else if (msg.type === "voice_ready") {
// XTTS-Bridge meldet Stimme fertig geladen → an Browser durchreichen
const v = msg.payload?.voice || "";
const err = msg.payload?.error;
const ms = msg.payload?.loadMs;
if (err) {
log("warn", "rvs", `Voice-Ready Fehler fuer "${v}": ${err}`);
} else {
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)}`);
}
@@ -716,24 +686,11 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj);
// Persistente Connection bevorzugen — die ist garantiert connected
// und wird vom RVS direkt an alle anderen Clients (App, Bridge) broadcastet.
// Frische Connections hatten Race-Probleme: die WS war nach dem send manchmal
// schon zu, bevor RVS broadcasten konnte → App-Nachrichten verloren.
if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
try {
rvsWs.send(payload);
return;
} catch (err) {
log("warn", "rvs", `persistente Verbindung send failed (${err.message}) — Fallback frische WS`);
}
}
const proto = RVS_TLS === "true" ? "wss" : "ws";
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
const freshWs = new WebSocket(url);
freshWs.on("open", () => {
freshWs.send(payload);
freshWs.send(JSON.stringify(msgObj));
setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000);
});
freshWs.on("error", () => {});
@@ -1455,30 +1412,18 @@ 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));
} catch {}
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
log("info", "server", `Voice-Config gespeichert: xttsVoice=${voiceConfig.xttsVoice || "default"}, whisper=${voiceConfig.whisperModel || "-"}`);
} else if (msg.action === "get_triggers") {
handleGetTriggers(ws);
} else if (msg.action === "save_triggers") {
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") {
@@ -1625,99 +1570,32 @@ function handleGetVoiceConfig(clientWs) {
}
}
// ── 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();
// ── Highlight-Trigger (legacy UI — wird nicht mehr ausgewertet seit Piper raus) ─
const TRIGGERS_FILE = "/shared/config/highlight_triggers.json";
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) {
async function handleGetTriggers(clientWs) {
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(),
});
const triggers = fs.existsSync(TRIGGERS_FILE)
? JSON.parse(fs.readFileSync(TRIGGERS_FILE, "utf-8"))
: [];
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
} catch (err) {
clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message }));
clientWs.send(JSON.stringify({ type: "trigger_list", triggers: [], error: err.message }));
}
}
async function handleSaveTriggers(clientWs, triggers) {
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync(TRIGGERS_FILE, JSON.stringify(triggers, null, 2));
log("info", "server", `${triggers.length} Highlight-Trigger gespeichert`);
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
} catch (err) {
log("error", "server", `Trigger speichern fehlgeschlagen: ${err.message}`);
}
}
// ── TTS Diagnose (XTTS) ───────────────────────────────
async function handleTestTTS(clientWs, text) {
try {
log("info", "server", `TTS-Test via XTTS: "${text}"`);
+1 -1
View File
@@ -9,7 +9,7 @@ services:
command: >-
sh -c "apk add --no-cache openssh-client bash curl &&
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
DIST=$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/if (model\.includes/if ((model||\"claude-sonnet-4\").includes/g' $$DIST/adapter/cli-to-openai.js &&
sed -i '1i\\function _t(c){return typeof c===\"string\"?c:Array.isArray(c)?c.filter(function(b){return b.type===\"text\"}).map(function(b){return b.text||\"\"}).join(\"\"):String(c)}' $$DIST/adapter/openai-to-cli.js &&
+45 -170
View File
@@ -1,204 +1,79 @@
# ARIA Issues & Features
## Audio-Verhalten in der App
So sollte die App in den verschiedenen Phasen mit fremden Audio-Apps
(Spotify, YouTube, Podcasts etc.) und dem eigenen Mikro umgehen.
Wenn was anders ist, ist's ein Bug.
| Phase | Andere App (Spotify) | ARIA-Mikro | Hintergrund-Service |
|------------------------------|----------------------|---------------------|---------------------|
| Idle / Ohr aus | spielt frei | aus | aus |
| Wake-Word lauscht (armed) | spielt frei | passiv (openWakeWord) | aktiv ('wake') |
| User-Aufnahme laeuft | pausiert (EXCLUSIVE) | Recording | aktiv ('rec') |
| Aufnahme zu Ende | resumed | aus | (rec released) |
| ARIA denkt/schreibt (~20s) | spielt frei | aus | (kein Slot) |
| TTS startet | pausiert (DUCK) | aus (oder barge) | aktiv ('tts') |
| TTS spielt (auch GPU-Pausen) | bleibt pausiert | barge wenn Wake-Word| aktiv |
| TTS zu Ende | nach 800ms resumed | (Conversation-Window)| (tts released) |
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert | aus |
| Anruf vorbei | — | Mikro wieder armed | aktiv ('wake') |
| Anruf vorbei (Auto-Resume) | pausiert wieder | aus | aktiv ('tts') |
| Neue Frage waehrend Anruf | — | Mikro pausiert | (rec waehrend Anruf nicht) |
| Anruf vorbei nach neuer Frage | (siehe TTS-Phasen) | (siehe TTS-Phasen) | (tts gewinnt, alter Resume verworfen) |
Wichtige Mechanismen:
- **Underrun-Schutz** im PcmStreamPlayer fuettert Stille rein wenn die
Bridge in Render-Pausen liefert — Spotify bleibt durchgehend pausiert,
auch zwischen den Saetzen einer langen Antwort.
- **Conversation-Focus** (nur bei Wake-Word 'conversing') haelt den
AudioFocus dauerhaft. Bei reinem Tap-to-Talk oder Text-Chat greift's
nicht — Spotify darf in der Denk-Phase ruhig weiterspielen.
- **Foreground-Service** (mediaPlayback|microphone) haelt App-Prozess
am Leben damit TTS/Mikro/Wake-Word auch bei minimierter App weiter-
laufen. Notification zeigt aktuellen Status ("ARIA spricht/hoert
zu/bereit").
- **Anruf-Erkennung** ueber TelephonyManager (klassisch) + AudioFocus-
Loss-Listener mit Polling-Fallback (VoIP wie WhatsApp/Signal/Discord).
- **Auto-Resume nach Anruf**: beim Halt wird die Wiedergabe-Position
gemerkt (Date.now() - playbackStart - leadingSilence). Nach Auflegen
wartet die App bis zu 30s auf den WAV-Cache und spielt dann ab der
gemerkten Position weiter. Wenn das Telefonat länger als die Antwort
dauerte, ist der Cache schon fertig — instant Resume.
- **Neue Frage waehrend Anruf** (Text-Chat geht trotz Telefonat): die
neue Antwort ueberschreibt den pending Resume. _handlePcmChunkImpl
stoppt einen ggf. laufenden resumeSound und setzt pausedMessageId
zurueck wenn die neue Stream-messageId abweicht. Die letzte Antwort
gewinnt immer.
- **Audio-Ausgabe trotz aktivem Telefonat**: ARIA antwortet auch waehrend
eines Telefonats per Lautsprecher (Telefon-Audio geht ueber separaten
Stream zur Gegenseite). haltAllPlayback wird nur beim STATE-WECHSEL
ringing/offhook gerufen — wenn der Anruf schon laeuft (offhook→offhook),
triggert eine neue Frage keinen Halt mehr.
## Erledigt
### Bugs / Fixes
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
- [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] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
- [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] 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] Wake-Word Embedding rank-4 Fix (Pipeline-Bug der das Triggern verhinderte) + Frame-Count aus Modell-Metadaten lesen
- [x] PCM-Underrun-Schutz: Stille-Fill in Render-Pausen verhindert Spotify-Auto-Resume nach 10s Stillstand
- [x] Conversation-Focus-Lifecycle: AudioFocus haengt am Wake-Word-State 'conversing' statt an einzelnen Streams — Spotify bleibt durchgehend gepaust, auch zwischen mehreren Antworten
- [x] Voice-Override behaelt Stimme ueber alle TTS-Calls einer Antwort (vorher: nach erstem TTS-Call zurueck auf Default)
- [x] Sprachnachricht-Bubble defensiv: STT-Result fuegt neue Bubble hinzu wenn Placeholder fehlt (Race-Schutz)
- [x] Bild + Text als EINE Anfrage: Bridge buffert files 800ms, merged mit folgendem chat-Text zu einem send_to_core (statt zwei getrennten ARIA-Antworten)
- [x] Diagnostic→App: persistente RVS-Connection statt frische pro Send (Race-Probleme mit Zombie-WS geloest)
- [x] Textauswahl in Bubbles wieder funktional (nested Text+onPress raus, dataDetectorType="all" macht Links automatisch klickbar)
- [x] **Placeholder-Race bei parallelen Sprachnachrichten geloest**: jede Aufnahme bekommt eine eindeutige audioRequestId, Bridge gibt sie ans STT-Result zurueck — App matcht jetzt punktgenau die richtige Bubble statt per Substring
- [x] Mikro-Offen-Toast "🎤 sprich jetzt" erscheint erst wenn audioService.startRecording wirklich erfolgreich war (statt ~400ms vorher beim Wake-Word-Detect)
- [x] Sprachnachrichten ohne STT-Result werden nach 60s+Aufnahmedauer automatisch entfernt (sicher genug fuer 5-30min-Aufnahmen, schnell genug fuer leere Wake-Word-Echos)
- [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo
- [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme)
- [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn
- [x] **Wake-Word pausiert bei Anruf**: phoneCall ruft pauseForCall (openWakeWord.stop) bei RINGING/OFFHOOK, resumeFromCall bei IDLE. Pre-Call-State wird gemerkt — armed bleibt armed, conversing degraded zu armed (User soll nicht in halbem Dialog landen)
- [x] **App-Resume-Cooldown**: Wechsel von Background → Foreground triggert keinen falschen Wake-Word-Trigger mehr. AppState-Listener setzt 1.5s Cooldown in dem onWakeDetected-Events ignoriert werden (Audio-Pegel-Spike beim AudioFocus-Switch sonst als Wake-Word interpretiert)
- [x] Background-Mikro robust: acquireBackgroundAudio('rec'/'wake') wird jetzt VOR AudioRecord.startRecording gerufen — Foreground-Service mit foregroundServiceType=microphone muss aktiv sein bevor das Mikro greift, sonst blockiert Android ab 11+ den Background-Zugriff
- [x] **Stille-Pegel manuell setzbar** (Settings → Spracheingabe): Override-Wert in dB von -55 bis -15, default "automatisch". Info-Button mit Modal erklaert die Skala (niedriger = sensibler, hoeher = robuster gegen Hintergrundlaerm). Bei manuell gesetztem Wert wird die adaptive Baseline ignoriert
- [x] **Kurze TTS-Texte (1-3 Worte) spielen jetzt ab** — auf OnePlus A12 stallte AudioTrack mit `pos=0` weil der Default-Start-Threshold `bufferSize/2` (= 2s) bei kurzen Streams nie ueberschritten wurde. Fix: `setStartThresholdInFrames(100ms)` direkt nach dem Track-Build (API 31+). Buffer auf 4s entkoppelt von Pre-Roll, `play()` wird beim allerersten data-chunk gerufen
- [x] **Mute-Button stoppt jetzt auch laufenden PCM-Stream** — `pcmStreamActive` wurde beim isFinal-Chunk schon false gesetzt, der AudioTrack spielte aber noch sekundenlang aus seinem Buffer. `stopPlayback()` uebersprang darum `PcmStreamPlayer.stop()`. Fix: stop() immer rufen (ist idempotent), kein Flag-Check mehr
- [x] **GPS-Permission im Manifest + Runtime-Request** beim Settings-Toggle — vorher fehlten ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION komplett. `Geolocation.getCurrentPosition` schlug lautlos fehl, App sendete nie ein location-Feld
- [x] **GPS-Position auch im STT-Payload an Diagnostic** — die App sendet location einmal im audio-Payload. Die Bridge nutzte sie zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-broadcast an Diagnostic durch. Diagnostic zeigte darum bei Spracheingaben nie den GPS-Block, obwohl der "GPS einblenden"-Toggle aktiv war
### App Features
- [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, später ersetzt)
- [x] ARIA liest Nachrichten vor (TTS via Piper)
- [x] Autoscroll zur letzten Nachricht (inverted FlatList)
- [x] Bilder im Chat groesser + Vollbild-Vorschau
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
- [x] Play-Button in ARIA-Nachrichten fuer Sprachwiedergabe
- [x] Chat-Suche in der App (Lupe in Statusleiste)
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
- [x] Abbrechen-Button im Diagnostic Chat
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme — durch XTTS/F5-TTS ersetzt)
- [x] Highlight-Trigger konfigurierbar in Diagnostic (spaeter komplett entfernt — war Piper-Relikt)
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning) — durch F5-TTS ersetzt
- [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] Highlight-Trigger konfigurierbar in Diagnostic
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning)
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
- [x] TTS Engine waehlbar (Piper/XTTS) — Piper raus, XTTS raus, jetzt nur F5-TTS
- [x] TTS Engine waehlbar (Piper/XTTS) in Diagnostic + App
- [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
- [x] Audio-Queue (sequentielle Wiedergabe, kein Ueberlappen)
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
- [x] Mehrere Anhaenge + Text vor dem Senden (Pending-Vorschau)
- [x] Paste-Support fuer Bilder in Diagnostic Chat
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten
- [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: 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] 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_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] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s)
- [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich
- [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] 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 (durch openWakeWord ersetzt)
- [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 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)
- [x] **Wake-Word komplett on-device via openWakeWord (ONNX Runtime)** — Porcupine raus, kein API-Key/keine Lizenzgebuehren mehr. Mitgelieferte Keywords: hey_jarvis, computer, alexa, hey_mycroft, hey_rhasspy
- [x] APK ABI-Split auf arm64-v8a — von ~136 MB auf ~35 MB, Auto-Update-Downloads aufs Phone deutlich kleiner
- [x] PhoneStateListener: TTS pausiert bei eingehendem Anruf (READ_PHONE_STATE Permission)
- [x] **VoIP-Anrufe** (WhatsApp/Signal/Discord/Teams) erkannt via AudioFocus-Loss-Listener + getMode-Polling-Fallback (alle 3s)
- [x] **Auto-Resume nach Anruf**: ARIAs unterbrochene Antwort spielt nach dem Auflegen ab der gemerkten Position weiter (Date.now()-Tracking + WAV-Cache, 30s-Wartezeit auf final-Marker bei kurzem Telefonat)
- [x] **Neue Frage waehrend Telefonat** ueberschreibt pending Auto-Resume — letzte Antwort gewinnt, alter resumeSound wird gestoppt
- [x] **Audio-Ausgabe waehrend aktivem Telefonat** funktioniert (haltAllPlayback nur bei state-Wechsel idle→ringing/offhook, nicht bei offhook→offhook)
- [x] **PcmPlaybackFinished-Event** im Native: AudioFocus wird erst released wenn AudioTrack wirklich durch ist (vorher: end()-Cap nach 0.5s → Spotify spielte 32s parallel zu ARIA)
- [x] **APK-Cache-Cleanup robuster**: durchsucht jetzt CachesDirectoryPath + DocumentDirectoryPath + ExternalCachesDirectoryPath + ExternalDirectoryPath statt nur Caches. Plus manueller Button "Update-Cache leeren" in Settings → Speicher mit Live-Anzeige der aktuellen Groesse
- [x] Diagnostic-Chat: bubblige Formatierung, mehrzeiliges Eingabefeld (textarea, Enter sendet, Shift+Enter neue Zeile)
- [x] Adaptive VAD-Schwelle: Baseline aus den ersten 500ms Mic-Pegel, Stille = baseline+6dB / Sprache = baseline+12dB
- [x] Max-Aufnahmedauer konfigurierbar in Settings (1-30 min, Default 5 min) — laengere Diktate moeglich
- [x] Barge-In: User kann ARIA waehrend Antwort/Tool-Use unterbrechen, alte Aktivitaet wird abgebrochen, Bridge gibt aria-core einen Kontext-Hint dass es eine Korrektur ist
- [x] Settings-Sub-Screens: 8 Kategorien (Verbindung, Allgemein, Spracheingabe, Wake-Word, Sprachausgabe, Speicher, Protokoll, Ueber) statt langer Liste
- [x] **Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen** — akustische Bestaetigung statt nur Toast. Toggle in Settings → Wake-Word, default aktiv
- [x] **Wake-Word parallel zu TTS** mit AcousticEchoCanceler: User sagt "Computer" waehrend ARIA spricht → TTS verstummt sofort, neue Aufnahme startet
- [x] **GPS-Position mitsenden**: Toggle in Settings → Allgemein → Standort, persistiert in AsyncStorage. Wenn aktiv wird lat/lon mit jeder chat/audio-Message mitgegeben. Bridge prefixed den Text fuer aria-core mit GPS-Hint (mit Anweisung dass die Position nur bei Bedarf erwaehnt wird)
- [x] **Background Audio Service**: TTS, Wake-Word-Lauschen UND Aufnahme laufen auch bei minimierter App weiter. Foreground-Service mit foregroundServiceType=mediaPlayback|microphone, persistente Notification mit dynamischem Text ("ARIA spricht" / "ARIA hoert zu" / "ARIA bereit")
### Infrastruktur
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
- [x] RVS Nachrichten vom Smartphone gehen durch
- [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] 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] 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)
## Offen
### Bugs
### 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)
### App Features
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
- [ ] 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 (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
- [ ] 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)
+1 -8
View File
@@ -19,10 +19,6 @@ const ALLOWED_TYPES = new Set([
"agent_activity", "cancel_request",
"audio_pcm",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",
"service_status",
"config_request",
]);
// Token-Raum: token -> { clients: Set<ws> }
@@ -55,10 +51,7 @@ function cleanupRooms() {
// ── WebSocket-Server starten ────────────────────────────────────────
// 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 });
const wss = new WebSocketServer({ port: PORT });
wss.on("listening", () => {
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
+1 -1
View File
@@ -4,7 +4,7 @@
# ════════════════════════════════════════════════
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM)
RVS_HOST=example.com
RVS_HOST=mobil.hacker-net.de
RVS_PORT=444
RVS_TLS=true
RVS_TLS_FALLBACK=true
-9
View File
@@ -1,9 +0,0 @@
# 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
+5
View File
@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY bridge.js package.json ./
RUN npm install --production
CMD ["node", "bridge.js"]
+486
View File
@@ -0,0 +1,486 @@
/**
* 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);
}
} 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 ─────────────────────────────
// ── 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();
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 {
const voiceSample = voice ? path.join(VOICES_DIR, `${voice}.wav`) : null;
const hasCustomVoice = voiceSample && fs.existsSync(voiceSample);
let chunkIndex = 0;
let pcmMeta = null;
const onChunk = (pcmBase64, meta) => {
if (!pcmMeta) pcmMeta = meta;
sendToRVS({
type: "audio_pcm",
payload: {
requestId: requestId || "",
messageId: messageId || "",
base64: pcmBase64,
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",
hasCustomVoice ? voiceSample : null,
onChunk,
);
} catch (streamErr) {
log(`/tts_stream fehlgeschlagen (${streamErr.message.slice(0, 100)}) — Fallback /tts_to_audio/`);
await streamXTTSBatch(
cleanText,
language || "de",
hasCustomVoice ? voiceSample : null,
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 ──────────────────────────────
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
+30 -53
View File
@@ -1,7 +1,7 @@
# ════════════════════════════════════════════════
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
# ARIA XTTS v2 — GPU TTS Server
# Laeuft auf dem Gaming-PC (RTX 3060)
# Verbindet sich zum RVS fuer TTS/STT-Requests
# Verbindet sich zum RVS fuer TTS-Requests
# ════════════════════════════════════════════════
#
# Voraussetzungen:
@@ -10,18 +10,15 @@
# - .env mit RVS-Verbindungsdaten
#
# Start: docker compose up -d
# Test: curl http://localhost:8000/docs
# ════════════════════════════════════════════════
services:
# ─── 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
# ─── XTTS v2 API Server (GPU) ─────────────────
xtts:
image: daswer123/xtts-api-server:latest
container_name: aria-xtts
deploy:
resources:
reservations:
@@ -29,57 +26,37 @@ services:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "8000:8020"
volumes:
- ./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.
- xtts-models:/app/xtts_models # Model-Cache (~2GB)
- ./voices:/voices # Custom Voice Samples
environment:
# 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
- 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
# ─── 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]
# ─── 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
- 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
volumes:
xtts-models:
-21
View File
@@ -1,21 +0,0 @@
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
View File
@@ -1,856 +0,0 @@
#!/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)
-5
View File
@@ -1,5 +0,0 @@
f5-tts>=1.0.0
websockets>=12.0
numpy>=1.24
soundfile>=0.12
requests>=2.31
+8
View File
@@ -0,0 +1,8 @@
{
"name": "aria-xtts-bridge",
"version": "1.0.0",
"private": true,
"dependencies": {
"ws": "^8.16.0"
}
}
-14
View File
@@ -1,14 +0,0 @@
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
View File
@@ -1,309 +0,0 @@
#!/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)
-4
View File
@@ -1,4 +0,0 @@
faster-whisper==1.0.3
websockets>=12.0
numpy>=1.24
requests>=2.31