Compare commits

..
158 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.7 a361015ff4 fix: WebSocket max_size hochgedreht — voice_upload sprengte Default 1MB
Symptom: aria-whisper-bridge bekam beim ersten internen stt_request
(via voice_upload mit WAV als base64, ~2.4MB) den Frame zu Gesicht,
default ws-max ist 1MB → mit Close-Code 1009 abgewiesen → Verbindung
tot → naechster stt_request lief in Timeout → lokales Fallback.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ChatScreen: ruft wakeWordService.loadFromStorage() beim Mount.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

RVS: stt_request/stt_response zur ALLOWED_TYPES-Liste.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:42:07 +02:00
duffyduck 97a1a3089a release: bump version to 0.0.5.1 2026-04-23 22:02:17 +02:00
duffyduck 64f18e97a0 release: bump version to 0.0.5.0 2026-04-23 15:31:18 +02:00
duffyduckandClaude Opus 4.7 9cbea27455 feat: voice_preload/voice_ready — Feedback wenn neue Stimme geladen ist
XTTS-Bridge:
  - empfaengt neuen voice_preload Type, rendert stumm "ja." fuer die Stimme
    via TTS-Queue (damit kein Konflikt mit echtem TTS)
  - horcht zusaetzlich auf config-Broadcasts: wenn Diagnostic global die
    Stimme wechselt, wird auto-preloaded
  - broadcastet voice_ready mit Dauer (loadMs) oder error

RVS: voice_preload + voice_ready zur ALLOWED_TYPES-Liste.

App (SettingsScreen): beim Wechsel senden wir voice_preload, zeigen einen
Spinner in der Voice-Row und einen Toast mit "Stimme X bereit (Ns)".
App (ChatScreen): Toast auch hier — falls User gerade nicht in Settings ist.

Diagnostic (server+UI): voice_ready wird an Browser durchgereicht, ein
Status-Text unter dem Voice-Dropdown zeigt "wird geladen" → "bereit".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:24:08 +02:00
duffyduck c8881f9e4d release: bump version to 0.0.4.9 2026-04-22 23:02:28 +02:00
duffyduckandClaude Opus 4.7 028e3b2240 fix: Voice-Auswahl funktioniert endlich + Diagnostic setzt alle Apps zurueck
XTTS-Bridge: im daswer123 local-Mode erwartet der Server speaker_wav als
Basename (z.B. "Maia"), nicht als Pfad. Wir haben bisher "/voices/Maia.wav"
geschickt, was der Server stumm verwirft und Default nimmt. Jetzt: speaker
name pur senden + Warnlog wenn File fehlt.

App: ChatScreen + SettingsScreen horchen auf type "config" vom RVS —
wenn in Diagnostic die globale XTTS-Voice gewechselt wird, werden alle
Apps auf den neuen Wert zurueckgesetzt (wie vom User gewuenscht).
Lokale App-Wahl bleibt sonst intakt und gewinnt pro Request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:32:40 +02:00
duffyduckandClaude Opus 4.7 c042f27106 feat: generisches Buchstabieren fuer unbekannte Akronyme
Nach der expliziten _UNIT_WORDS-Liste greift eine Fallback-Regel:
alle verbleibenden 2-5-Zeichen-Grossbuchstaben-Woerter werden
buchstabiert. XTTS → X T T S, USB → U S B, DNS → D N S, JSON → J S O N.

Spezielle Faelle (WLAN, NATO — als Wort gesprochen) koennen bei
Bedarf in _UNIT_WORDS explizit ueberschrieben werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:04 +02:00
duffyduck 4ceadf8be5 release: bump version to 0.0.4.8 2026-04-22 19:08:00 +02:00
duffyduckandClaude Opus 4.7 ddd30b3059 feat: Pre-Roll-Buffer fuer TTS einstellbar in App-Settings
- Kotlin start() nimmt jetzt prerollSeconds als dritten Parameter
  (1.0-6.0s geclampt, Fallback 3.5s bei ungueltigem Wert)
- audio.ts liest Wert aus AsyncStorage vor jedem Stream-Start,
  exportiert Default/Min/Max/Key als Konstanten
- SettingsScreen: +/- Buttons direkt unter dem TTS-Toggle,
  Default auf 3.5s (von 2.5s) angehoben

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:06:55 +02:00
duffyduckandClaude Opus 4.7 6c8ba5fe2d fix: Fade-In auf ersten PCM-Chunk — maskiert XTTS-Warmup-Glitches
XTTS daswer123 hat am Anfang jedes Renders Warmup-Artefakte — die
ersten autoregressiv generierten Tokens haben wenig Kontext und klingen
verzerrt. Ein 120ms Linear-Fade-In auf den ersten ausgehenden PCM-Chunk
blendet das sanft auf und versteckt die Glitches, ohne dass das echte
Audio danach leiser klingt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:01:36 +02:00
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
32 changed files with 303 additions and 1602 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. # App, Bridge, Diagnostic und XTTS-Bridge verbinden sich hierueber.
# Alle muessen den gleichen Host, Port und Token nutzen. # 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 RVS_HOST=rvs.example.de
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen) # Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
+3 -72
View File
@@ -380,7 +380,6 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- Text-Chat mit ARIA - Text-Chat mit ARIA
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille) - **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 - **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
- **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s. - **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s.
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt - **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. - **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
@@ -399,44 +398,6 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- GPS-Position (optional) - GPS-Position (optional)
- QR-Code Scanner fuer Token-Pairing - 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)
- `Alexa`
- `Hey Mycroft`
- `Hey Rhasspy`
**Bedienung:**
- App → **Einstellungen** → **Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren**
- **Ohr-Button (👂)** in der Statusleiste tippen → Wake-Word ist scharf, App hoert passiv mit
- Wake-Word sagen → Symbol wechselt auf 🎙️, Konversation laeuft
- Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂
- Erneut tippen → Ohr aus (🔇)
**Eigene Wake-Words trainieren** (gratis, ~30 Min):
1. openWakeWord Trainings-Notebook auf Colab oeffnen (Link im
[openWakeWord Repo](https://github.com/dscripka/openWakeWord) unter "Training Custom Models")
2. Wake-Word-Phrase eingeben (z.B. "ARIA", "Hey Stefan"), Notebook ausfuehren —
das Notebook generiert synthetische Trainings-Beispiele und trainiert das Modell.
3. Resultierende `.onnx`-Datei runterladen
4. Datei in `android/android/app/src/main/assets/openwakeword/` ablegen
5. In `android/src/services/wakeword.ts` den Dateinamen (ohne `.onnx`) zur
`WAKE_KEYWORDS`-Liste hinzufuegen
6. APK neu bauen
*(Diagnostic-Upload fuer Custom-`.onnx` ohne Rebuild kommt spaeter.)*
**Tuning** (in [wakeword.ts](android/src/services/wakeword.ts)):
- `DEFAULT_THRESHOLD = 0.5` — Score-Schwelle (raise auf 0.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) ### Ersteinrichtung (Dev-Maschine, einmalig)
```bash ```bash
@@ -689,33 +650,6 @@ In der Diagnostic unter Einstellungen → Sprachausgabe:
> **Tipp:** Fuer beste Ergebnisse: saubere Aufnahme, eine Stimme, kein Hintergrund, > **Tipp:** Fuer beste Ergebnisse: saubere Aufnahme, eine Stimme, kein Hintergrund,
> 10-30 Sekunden Gesamtlaenge. Mehrere kurze Dateien werden zusammengefuegt. > 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 ## Docker Volumes
@@ -783,10 +717,8 @@ docker exec aria-core ssh aria-wohnung hostname
- **Proxy Cold Start**: Jede Nachricht spawnt einen neuen `claude --print` Prozess. - **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). 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. - **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`, - **Wake Word nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM.
`Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als In der App gibt es Energy-basierte Erkennung (Phase 1). On-device "ARIA"-Keyword (Porcupine) ist Phase 2.
`.onnx`-Datei ins App-Bundle gelegt + zur Liste in `wakeword.ts` hinzugefuegt werden.
Die Diagnostic-Upload-UI ist Phase 2.
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM. - **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung. - **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request. Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
@@ -841,7 +773,6 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix - [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s) - [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen - [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 ### Phase 2 — ARIA wird produktiv
@@ -857,5 +788,5 @@ docker exec aria-core ssh aria-wohnung hostname
- [ ] STARFACE Telefonie-Skill - [ ] STARFACE Telefonie-Skill
- [ ] Desktop Client (Tauri) - [ ] Desktop Client (Tauri)
- [ ] bKVM Remote IT-Support - [ ] 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) - [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)
+2 -18
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 607 versionCode 506
versionName "0.0.6.7" versionName "0.0.5.6"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -104,19 +104,6 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" 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 { dependencies {
@@ -124,9 +111,6 @@ dependencies {
implementation("com.facebook.react:react-android") implementation("com.facebook.react:react-android")
implementation("com.facebook.react:flipper-integration") 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()) { if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android") implementation("com.facebook.react:hermes-android")
} else { } else {
@@ -21,7 +21,6 @@ class MainApplication : Application(), ReactApplication {
add(ApkInstallerPackage()) add(ApkInstallerPackage())
add(AudioFocusPackage()) add(AudioFocusPackage())
add(PcmStreamPlayerPackage()) add(PcmStreamPlayerPackage())
add(OpenWakeWordPackage())
} }
override fun getJSMainModuleName(): String = "index" override fun getJSMainModuleName(): String = "index"
@@ -1,357 +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.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 WW_INPUT_FRAMES = 16 // 16 Embeddings = ~1.28s
private const val MEL_BINS = 32
}
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"
// Konfiguration
private var threshold: Float = 0.5f
private var patience: Int = 2
private var debounceMs: Long = 1500
private var modelName: String = "hey_jarvis"
// Audio-Capture-Thread
private var audioRecord: AudioRecord? = null
private val running = AtomicBoolean(false)
private var captureThread: Thread? = null
// Inferenz-State
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
private var melProcessedIdx: Int = 0
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L
/**
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
*/
@ReactMethod
fun init(modelName: String, threshold: Double, patience: Int, debounceMs: Int, promise: Promise) {
try {
disposeSessions()
this.modelName = modelName
this.threshold = threshold.toFloat()
this.patience = patience.coerceAtLeast(1)
this.debounceMs = debounceMs.toLong()
val ctx = reactApplicationContext
val melBytes = ctx.assets.open("openwakeword/melspectrogram.onnx").use { it.readBytes() }
val embBytes = ctx.assets.open("openwakeword/embedding_model.onnx").use { it.readBytes() }
val wwBytes = ctx.assets.open("openwakeword/$modelName.onnx").use { it.readBytes() }
val opts = OrtSession.SessionOptions()
melSession = env.createSession(melBytes, opts)
embSession = env.createSession(embBytes, opts)
wwSession = env.createSession(wwBytes, opts)
melInputName = melSession!!.inputNames.first()
embInputName = embSession!!.inputNames.first()
wwInputName = wwSession!!.inputNames.first()
Log.i(TAG, "Init OK: model=$modelName threshold=$threshold patience=$patience " +
"debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "Init fehlgeschlagen: ${e.message}", e)
disposeSessions()
promise.reject("INIT_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun start(promise: Promise) {
if (running.get()) {
promise.resolve(true)
return
}
if (melSession == null || embSession == null || wwSession == null) {
promise.reject("NOT_INITIALIZED", "init() muss vor start() aufgerufen werden")
return
}
// Berechtigung pruefen — der App-Code holt die ueblicherweise schon vorher,
// aber wir bestehen hier explizit darauf damit AudioRecord nicht stumm
// failt.
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.RECORD_AUDIO)
if (perm != PackageManager.PERMISSION_GRANTED) {
promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
return
}
try {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
val record = AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf,
)
if (record.state != AudioRecord.STATE_INITIALIZED) {
record.release()
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)")
return
}
audioRecord = record
resetInferenceState()
running.set(true)
record.startRecording()
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
}
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
running.set(false)
audioRecord?.release()
audioRecord = null
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
running.set(false)
try {
captureThread?.join(1500)
} catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
}
@ReactMethod
fun dispose(promise: Promise) {
running.set(false)
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
disposeSessions()
promise.resolve(true)
}
@ReactMethod
fun isAvailable(promise: Promise) {
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
promise.resolve(true)
}
// RN-Event-Subscriptions — RN-Konvention, sonst Warnung im Debug-Build
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
private fun disposeSessions() {
try { melSession?.close() } catch (_: Exception) {}
try { embSession?.close() } catch (_: Exception) {}
try { wwSession?.close() } catch (_: Exception) {}
melSession = null
embSession = null
wwSession = null
}
private fun resetInferenceState() {
melBuffer.clear()
melProcessedIdx = 0
embBuffer.clear()
consecutiveAboveThreshold = 0
lastDetectionMs = 0L
}
private fun emitDetected() {
val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName)
}
try {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("WakeWordDetected", params)
} catch (e: Exception) {
Log.w(TAG, "emit fehlgeschlagen: ${e.message}")
}
}
private fun captureLoop() {
val buf = ShortArray(CHUNK_SAMPLES)
val record = audioRecord ?: return
Log.i(TAG, "Capture-Loop gestartet")
while (running.get()) {
var read = 0
while (read < CHUNK_SAMPLES && running.get()) {
val n = record.read(buf, read, CHUNK_SAMPLES - read)
if (n <= 0) {
Log.w(TAG, "AudioRecord.read returned $n — Loop ende")
running.set(false)
return
}
read += n
}
if (!running.get()) break
try {
processChunk(buf)
} catch (e: Exception) {
Log.w(TAG, "processChunk: ${e.message}")
}
}
Log.i(TAG, "Capture-Loop beendet")
}
/** Verarbeitet einen 1280-Sample int16 Audio-Chunk. */
private fun processChunk(audio: ShortArray) {
// 1) Audio → mel (output (1, 1, frames, 32))
val floats = FloatArray(audio.size) { audio[it].toFloat() }
val melTensor = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(floats),
longArrayOf(1L, audio.size.toLong()),
)
val melResult = melSession!!.run(mapOf(melInputName to melTensor))
val melOut = melResult.get(0).value
melTensor.close()
@Suppress("UNCHECKED_CAST")
val mel4 = melOut as Array<Array<Array<FloatArray>>>
val frames = mel4[0][0]
// openWakeWord wendet `mel/10 + 2` an, bevor es ans Embedding-Modell geht
for (frame in frames) {
val scaled = FloatArray(frame.size) { frame[it] / 10f + 2f }
melBuffer.add(scaled)
}
melResult.close()
// 2) Sliding window: alle vollstaendigen 76-Frame-Fenster verarbeiten
while (melBuffer.size >= melProcessedIdx + MEL_FRAMES_PER_EMBEDDING) {
val flat = FloatArray(MEL_FRAMES_PER_EMBEDDING * MEL_BINS)
var pos = 0
for (i in 0 until MEL_FRAMES_PER_EMBEDDING) {
val src = melBuffer[melProcessedIdx + i]
System.arraycopy(src, 0, flat, pos, MEL_BINS)
pos += MEL_BINS
}
val embIn = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(flat),
longArrayOf(1L, MEL_FRAMES_PER_EMBEDDING.toLong(), MEL_BINS.toLong(), 1L),
)
val embRes = embSession!!.run(mapOf(embInputName to embIn))
val embOut = embRes.get(0).value
embIn.close()
// Erwartete Output-Form: (1, 96) → Array<FloatArray>
@Suppress("UNCHECKED_CAST")
val embArr = embOut as Array<FloatArray>
embBuffer.addLast(embArr[0].copyOf())
while (embBuffer.size > WW_INPUT_FRAMES) 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 < WW_INPUT_FRAMES) return
val flatEmb = FloatArray(WW_INPUT_FRAMES * EMBEDDING_DIM)
var p = 0
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, WW_INPUT_FRAMES.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()
}
}
@@ -32,17 +32,11 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
private const val TAG = "PcmStreamPlayer" private const val TAG = "PcmStreamPlayer"
// Fallback wenn JS keinen Wert uebergibt. // Fallback wenn JS keinen Wert uebergibt.
private const val DEFAULT_PREROLL_SECONDS = 3.5 private const val DEFAULT_PREROLL_SECONDS = 3.5
// 0.0 = sofortige Wiedergabe — play() direkt beim ersten Chunk. private const val MIN_PREROLL_SECONDS = 0.5
// 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 private const val MAX_PREROLL_SECONDS = 10.0
// Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die // Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die
// ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz). // ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz).
private const val LEADING_SILENCE_SECONDS = 0.3 private const val LEADING_SILENCE_SECONDS = 0.2
// Stille am Ende — puffert das Hardware-Flushen damit die letzten
// echten Samples garantiert ausgespielt werden bevor stop() kommt.
private const val TRAILING_SILENCE_SECONDS = 0.3
} }
override fun getName() = "PcmStreamPlayer" override fun getName() = "PcmStreamPlayer"
@@ -65,12 +59,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
// Alte Session beenden falls vorhanden // Alte Session beenden falls vorhanden
stopInternal() stopInternal()
// Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe). val prerollSec = prerollSeconds
val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) { .coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
prerollSeconds.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS) .let { if (it.isFinite() && it > 0) it else DEFAULT_PREROLL_SECONDS }
} else {
DEFAULT_PREROLL_SECONDS
}
val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT val encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -112,9 +103,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val t = track ?: return@Thread val t = track ?: return@Thread
try { try {
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren. // Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE val silenceBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (leadingBytes > 0) { if (silenceBytes > 0) {
val silence = ByteArray(leadingBytes) val silence = ByteArray(silenceBytes)
var silOff = 0 var silOff = 0
while (silOff < silence.size && !writerShouldStop) { while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff) val w = t.write(silence, silOff, silence.size - silOff)
@@ -123,38 +114,18 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
bytesBuffered += silence.size bytesBuffered += silence.size
} }
// Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen, while (!writerShouldStop) {
// nicht erst bei Ankunft des ersten echten Chunks. Android's val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run {
// 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}")
}
}
mainLoop@ while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) {
if (endRequested) { if (endRequested) {
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen // Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
if (!playbackStarted) { if (!playbackStarted) {
try { try { t.play() } catch (_: Exception) {}
t.play() playbackStarted = true
playbackStarted = true
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
} catch (e: Exception) {
Log.w(TAG, "play() fallback failed: ${e.message}")
}
} }
break@mainLoop return@Thread
} }
continue@mainLoop null
} } ?: continue
// Pre-Roll Check: play() erst wenn genug gepuffert // Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) { if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
@@ -175,19 +146,6 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
bytesBuffered += data.size 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) { } catch (e: Exception) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}") Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally { } finally {
+2 -15
View File
@@ -167,23 +167,10 @@ export CI=true
if [ "$MODE" = "debug" ]; then if [ "$MODE" = "debug" ]; then
./gradlew assembleDebug ./gradlew assembleDebug
OUT_DIR="app/build/outputs/apk/debug" APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
else else
./gradlew assembleRelease ./gradlew assembleRelease
OUT_DIR="app/build/outputs/apk/release" APK_PATH="app/build/outputs/apk/release/app-release.apk"
fi
# Mit ABI-Splits heisst die APK z.B. app-arm64-v8a-release.apk statt
# app-release.apk. arm64-v8a-Variante zuerst probieren (das ist unser
# Standard), Universal-APK als Fallback falls Splits deaktiviert sind.
if [ -f "$OUT_DIR/app-arm64-v8a-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-arm64-v8a-${MODE}.apk"
elif [ -f "$OUT_DIR/app-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-${MODE}.apk"
else
echo -e "${RED}Keine passende APK in $OUT_DIR gefunden${NC}"
cd ..
exit 1
fi fi
cd .. cd ..
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.6.7", "version": "0.0.5.6",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
@@ -24,7 +24,9 @@
"react-native-camera-kit": "^13.0.0", "react-native-camera-kit": "^13.0.0",
"@react-native-async-storage/async-storage": "^1.21.0", "@react-native-async-storage/async-storage": "^1.21.0",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
"react-native-audio-recorder-player": "^3.6.7" "react-native-audio-recorder-player": "^3.6.7",
"@picovoice/porcupine-react-native": "3.0.5",
"@picovoice/react-native-voice-processor": "1.2.3"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.3.3", "typescript": "^5.3.3",
-105
View File
@@ -1,105 +0,0 @@
/**
* MessageText — rendert Chat-Text mit Auto-Linkifizierung:
* - http(s)://... → tippbar, oeffnet im Browser
* - mailto: oder plain E-Mail → tippbar, oeffnet Mail-App
* - Telefonnummern → tippbar, oeffnet Android-Dialer
*
* Text ist durchgaengig markierbar/kopierbar (selectable).
*/
import React from 'react';
import { Text, Linking, TextStyle, StyleProp } from 'react-native';
// Regex kombiniert URL | Email | Telefonnummer.
// Gruppenreihenfolge ist wichtig fuer die Erkennung unten.
//
// URL: http://... oder https://... bis zum ersten Whitespace / Anfuehrungszeichen.
// Email: simpler Standard-Match (kein RFC-kompatibel aber gut genug).
// Telefon: internationale Form (+49..., 0049..., 0176...), darf Leerzeichen
// / Bindestriche / Schraegstriche / Klammern enthalten, mindestens 7
// Ziffern insgesamt. Vermeidet banale Zahlen (Uhrzeiten, Datum).
const LINK_REGEX = new RegExp(
'(https?:\\/\\/[^\\s<>"]+)' + // 1: URL
'|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})' + // 2: Email
'|((?:\\+|00)\\d[\\d\\s()\\-\\/]{6,}\\d|0\\d{2,4}[\\s\\/\\-]?[\\d\\s\\-\\/]{5,}\\d)', // 3: Telefon
'g',
);
const LINK_STYLE = { color: '#0096FF', textDecorationLine: 'underline' } as TextStyle;
interface Segment {
text: string;
kind: 'text' | 'url' | 'email' | 'phone';
}
function tokenize(raw: string): Segment[] {
const out: Segment[] = [];
let lastEnd = 0;
LINK_REGEX.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = LINK_REGEX.exec(raw)) !== null) {
if (m.index > lastEnd) {
out.push({ text: raw.slice(lastEnd, m.index), kind: 'text' });
}
if (m[1]) out.push({ text: m[1], kind: 'url' });
else if (m[2]) out.push({ text: m[2], kind: 'email' });
else if (m[3]) out.push({ text: m[3], kind: 'phone' });
lastEnd = LINK_REGEX.lastIndex;
}
if (lastEnd < raw.length) out.push({ text: raw.slice(lastEnd), kind: 'text' });
return out;
}
function onPress(seg: Segment) {
try {
if (seg.kind === 'url') {
Linking.openURL(seg.text);
} else if (seg.kind === 'email') {
Linking.openURL(`mailto:${seg.text}`);
} else if (seg.kind === 'phone') {
// Android-Dialer erwartet tel:-Schema ohne Leerzeichen/Bindestriche
const clean = seg.text.replace(/[\s\-\/()]/g, '');
Linking.openURL(`tel:${clean}`);
}
} catch {}
}
interface Props {
text: string;
style?: StyleProp<TextStyle>;
}
const MessageText: React.FC<Props> = ({ text, style }) => {
const segments = React.useMemo(() => tokenize(text), [text]);
return (
<Text
style={style}
selectable
// dataDetectorType ist Android-only und macht Phone/URL/Email zusaetzlich
// ueber System-Detection klickbar — als Fallback falls unsere Regex-
// Tokens nicht passen.
dataDetectorType="all"
>
{segments.map((seg, i) => {
if (seg.kind === 'text') {
return <Text key={i} selectable>{seg.text}</Text>;
}
return (
<Text
key={i}
selectable
style={LINK_STYLE}
onPress={() => onPress(seg)}
// Long-Press soll an den Parent durch fuer Selection
onLongPress={undefined}
suppressHighlighting={false}
>
{seg.text}
</Text>
);
})}
</Text>
);
};
export default MessageText;
+20 -38
View File
@@ -93,24 +93,18 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
} }
}, [isRecording]); }, [isRecording]);
// VAD Silence Callback — Auto-Stop. // VAD Silence Callback — Auto-Stop
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
// feuert (silenceFired-Latch).
const onCompleteRef = useRef(onRecordingComplete);
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
useEffect(() => { useEffect(() => {
const unsubSilence = audioService.onSilenceDetected(async () => { const unsubSilence = audioService.onSilenceDetected(async () => {
if (audioService.getRecordingState() !== 'recording') return; if (!isRecording) return;
const result = await audioService.stopRecording();
setIsRecording(false); setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 500) { if (result && result.durationMs > 500) {
onCompleteRef.current(result); onRecordingComplete(result);
} }
}); });
return unsubSilence; return unsubSilence;
}, []); }, [isRecording, onRecordingComplete]);
// Auto-Start fuer Wake Word (extern getriggert) // Auto-Start fuer Wake Word (extern getriggert)
const startAutoRecording = useCallback(async () => { const startAutoRecording = useCallback(async () => {
@@ -142,35 +136,23 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
} }
}; };
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop. // Tap-to-Talk: Einmal tippen startet mit Auto-Stop
// Guard gegen Doppel-Tap während asyncer Start/Stop.
const tapBusy = useRef(false);
const handleTap = async () => { const handleTap = async () => {
if (disabled || tapBusy.current) return; if (disabled) return;
tapBusy.current = true; if (isRecording) {
try { // Aufnahme manuell stoppen
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein) setIsRecording(false);
const svcState = audioService.getRecordingState(); const result = await audioService.stopRecording();
if (svcState === 'recording') { if (result && result.durationMs > 300) {
// Aufnahme manuell stoppen onRecordingComplete(result);
const result = await audioService.stopRecording(); }
setIsRecording(false); } else {
if (result && result.durationMs > 300) { // Aufnahme mit Auto-Stop starten
onRecordingComplete(result); const started = await audioService.startRecording(true);
} if (started) {
} else if (svcState === 'idle') { isLongPress.current = false;
// Aufnahme mit Auto-Stop starten setIsRecording(true);
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;
} }
}; };
+13 -62
View File
@@ -29,8 +29,7 @@ import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton'; import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload'; import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText'; import { RecordingResult, loadConvWindowMs } from '../services/audio';
import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import Geolocation from '@react-native-community/geolocation'; import Geolocation from '@react-native-community/geolocation';
// --- Typen --- // --- Typen ---
@@ -104,8 +103,6 @@ const ChatScreen: React.FC = () => {
const [showCameraUpload, setShowCameraUpload] = useState(false); const [showCameraUpload, setShowCameraUpload] = useState(false);
const [gpsEnabled, setGpsEnabled] = useState(false); const [gpsEnabled, setGpsEnabled] = useState(false);
const [wakeWordActive, setWakeWordActive] = 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 [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false);
@@ -119,13 +116,6 @@ const ChatScreen: React.FC = () => {
const [ttsMuted, setTtsMuted] = useState(false); const [ttsMuted, setTtsMuted] = useState(false);
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default) // Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
const localXttsVoiceRef = useRef<string>(''); 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 flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
@@ -145,7 +135,6 @@ const ChatScreen: React.FC = () => {
setTtsMuted(muted === 'true'); // default false setTtsMuted(muted === 'true'); // default false
const voice = await AsyncStorage.getItem('aria_xtts_voice'); const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || ''; localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed();
}; };
loadTtsSettings(); loadTtsSettings();
// Poll alle 2s um Settings-Aenderung mitzubekommen (einfache Loesung ohne Context) // Poll alle 2s um Settings-Aenderung mitzubekommen (einfache Loesung ohne Context)
@@ -156,19 +145,8 @@ const ChatScreen: React.FC = () => {
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt) // Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => { useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {}); wakeWordService.loadFromStorage().catch(() => {});
const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s);
setWakeWordActive(s !== 'off');
});
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(() => { const toggleMute = useCallback(() => {
setTtsMuted(prev => { setTtsMuted(prev => {
const next = !prev; const next = !prev;
@@ -270,22 +248,15 @@ const ChatScreen: React.FC = () => {
if (message.type === 'chat') { if (message.type === 'chat') {
const sender = (message.payload.sender as string) || ''; const sender = (message.payload.sender as string) || '';
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben. // STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben
// WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst
// wuerde bei zwei kurz hintereinander gesendeten Audios beide Bubbles
// den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste).
if (sender === 'stt') { if (sender === 'stt') {
const sttText = (message.payload.text as string) || ''; const sttText = (message.payload.text as string) || '';
if (sttText) { if (sttText) {
setMessages(prev => { setMessages(prev => prev.map(m =>
const idx = prev.findIndex(m => m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') ? { ...m, text: `\uD83C\uDFA4 ${sttText}` }
); : m
if (idx < 0) return prev; ));
const next = prev.slice();
next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` };
return next;
});
} }
return; return;
} }
@@ -328,12 +299,7 @@ const ChatScreen: React.FC = () => {
} }
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
// WICHTIG: via Ref statt direkt state lesen, sonst ist's stale (Closure-Bug). const canPlay = ttsDeviceEnabled && !ttsMuted;
const canPlay = ttsCanPlayRef.current;
if (message.type === 'audio_pcm' || (message.type === 'audio' && message.payload.base64)) {
console.log('[Chat] audio-msg canPlay=%s (enabled=%s muted=%s)',
canPlay, ttsDeviceEnabled, ttsMuted);
}
if (message.type === 'audio' && message.payload.base64) { if (message.type === 'audio' && message.payload.base64) {
const b64 = message.payload.base64 as string; const b64 = message.payload.base64 as string;
const refId = (message.payload.messageId as string) || ''; const refId = (message.payload.messageId as string) || '';
@@ -473,7 +439,6 @@ const ChatScreen: React.FC = () => {
durationMs: result.durationMs, durationMs: result.durationMs,
mimeType: result.mimeType, mimeType: result.mimeType,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }), ...(location && { location }),
}); });
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert. // resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
@@ -495,12 +460,7 @@ const ChatScreen: React.FC = () => {
// Wake Word Toggle Handler // Wake Word Toggle Handler
const toggleWakeWord = useCallback(async () => { const toggleWakeWord = useCallback(async () => {
if (wakeWordActive) { if (wakeWordActive) {
// Vor Porcupine-Stop: eventuelle laufende Aufnahme abbrechen. Sonst wakeWordService.stop();
// bleibt audioService.recordingState=='recording' haengen und der
// normale Aufnahme-Button wirkt nicht mehr (startRecording lehnt
// ab weil "Aufnahme laeuft bereits").
try { await audioService.stopRecording(); } catch {}
await wakeWordService.stop();
setWakeWordActive(false); setWakeWordActive(false);
} else { } else {
const started = await wakeWordService.start(); const started = await wakeWordService.start();
@@ -586,13 +546,10 @@ const ChatScreen: React.FC = () => {
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort) // An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
rvs.send('chat', { rvs.send('chat', {
text, text,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }), ...(location && { location }),
}); });
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]); }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
@@ -702,7 +659,6 @@ const ChatScreen: React.FC = () => {
rvs.send('chat', { rvs.send('chat', {
text: messageText, text: messageText,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
...(location && { location }), ...(location && { location }),
}); });
} }
@@ -777,10 +733,9 @@ const ChatScreen: React.FC = () => {
))} ))}
{/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */} {/* 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)) && ( {!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
<MessageText <Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
text={item.text} {item.text}
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]} </Text>
/>
)} )}
{/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Bridge-TTS mit aktueller Engine */} {/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Bridge-TTS mit aktueller Engine */}
{!isUser && item.text.length > 0 && ( {!isUser && item.text.length > 0 && (
@@ -795,7 +750,6 @@ const ChatScreen: React.FC = () => {
rvs.send('tts_request' as any, { rvs.send('tts_request' as any, {
text: item.text, text: item.text,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
messageId: item.messageId || '', messageId: item.messageId || '',
}); });
} }
@@ -1016,10 +970,7 @@ const ChatScreen: React.FC = () => {
style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]} style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
onPress={toggleWakeWord} onPress={toggleWakeWord}
> >
<Text style={styles.wakeWordIcon}> <Text style={styles.wakeWordIcon}>{wakeWordActive ? '👂' : '🔇'}</Text>
{wakeWordState === 'conversing' ? '🎙️' :
wakeWordState === 'armed' ? '👂' : '🔇'}
</Text>
</TouchableOpacity> </TouchableOpacity>
</> </>
)} )}
+39 -56
View File
@@ -35,15 +35,11 @@ import {
CONV_WINDOW_MIN_SEC, CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC, CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY, CONV_WINDOW_STORAGE_KEY,
TTS_SPEED_DEFAULT,
TTS_SPEED_MIN,
TTS_SPEED_MAX,
TTS_SPEED_STORAGE_KEY,
} from '../services/audio'; } from '../services/audio';
import wakeWordService, { import wakeWordService, {
WAKE_KEYWORDS, BUILTIN_KEYWORDS,
KEYWORD_LABELS,
DEFAULT_KEYWORD, DEFAULT_KEYWORD,
WAKE_ACCESS_KEY_STORAGE,
WAKE_KEYWORD_STORAGE, WAKE_KEYWORD_STORAGE,
} from '../services/wakeword'; } from '../services/wakeword';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
@@ -102,7 +98,8 @@ const SettingsScreen: React.FC = () => {
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC); const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT); const [wakeAccessKey, setWakeAccessKey] = useState<string>('');
const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD); const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
@@ -156,14 +153,11 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => { AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE).then(saved => {
if (saved != null) { if (saved) setWakeAccessKey(saved);
const n = parseFloat(saved);
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) setTtsSpeed(n);
}
}); });
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => { AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved); if (saved) setWakeKeyword(saved);
}); });
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved); if (saved) setXttsVoice(saved);
@@ -673,23 +667,44 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* === Wake-Word (komplett on-device, openWakeWord) === */} {/* === Wake-Word (geraetelokal) === */}
<Text style={styles.sectionTitle}>Wake-Word</Text> <Text style={styles.sectionTitle}>Wake-Word</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Lokale Erkennung via openWakeWord (ONNX, on-device). Kein API-Key, Wenn ein Picovoice-Access-Key eingetragen ist, hoert die App passiv
kein Cloud-Roundtrip — Audio verlaesst das Geraet nicht. Wenn das Ohr auf das gewaehlte Wake-Word — du kannst dich mit anderen unterhalten,
aktiv ist, hoerst du normal mit; sagst du das Wake-Word, startet eine Musik laufen lassen und mit "{wakeKeyword}" eine Konversation mit
Konversation mit ARIA. ARIA starten. Ohne Key oder bei Fehlschlag startet das Ohr direkt
eine Konversation (klassischer Modus).
</Text> </Text>
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Picovoice Access Key</Text>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 6}}>
<TextInput
style={[styles.input, {flex: 1}]}
value={wakeAccessKey}
onChangeText={setWakeAccessKey}
placeholder="kostenlos auf console.picovoice.ai"
placeholderTextColor="#666680"
secureTextEntry={!wakeAccessKeyVisible}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
onPress={() => setWakeAccessKeyVisible(v => !v)}
style={{padding: 8}}
>
<Text style={{fontSize: 18}}>{wakeAccessKeyVisible ? '🙈' : '👁'}</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text> <Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Eigene Wake-Words via openWakeWord-Notebook trainierbar (gratis). Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter
Custom-Upload ueber Diagnostic kommt in einer spaeteren Version. ueber Diagnostic-Upload.
</Text> </Text>
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}> <View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
{WAKE_KEYWORDS.map(kw => ( {BUILTIN_KEYWORDS.map(kw => (
<TouchableOpacity <TouchableOpacity
key={kw} key={kw}
style={[ style={[
@@ -702,7 +717,7 @@ const SettingsScreen: React.FC = () => {
styles.keywordChipText, styles.keywordChipText,
wakeKeyword === kw && styles.keywordChipTextActive, wakeKeyword === kw && styles.keywordChipTextActive,
]}> ]}>
{KEYWORD_LABELS[kw]} {kw}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@@ -714,8 +729,8 @@ const SettingsScreen: React.FC = () => {
onPress={async () => { onPress={async () => {
setWakeStatus('Initialisiere...'); setWakeStatus('Initialisiere...');
try { try {
const ok = await wakeWordService.configure(wakeKeyword); const ok = await wakeWordService.configure(wakeAccessKey, wakeKeyword);
setWakeStatus(ok ? `✅ "${KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS]}" bereit` : '❌ Init-Fehler — Logs pruefen'); setWakeStatus(ok ? `✅ "${wakeKeyword}" bereit` : '❌ Fehlgeschlagen — Access Key pruefen');
} catch (err: any) { } catch (err: any) {
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80)); setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
} }
@@ -785,38 +800,6 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.prerollButtonText}>+0.5</Text> <Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </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> </View>
)} )}
+21 -118
View File
@@ -92,24 +92,6 @@ export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0; export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec'; export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
export const TTS_SPEED_DEFAULT = 1.0;
export const TTS_SPEED_MIN = 0.1;
export const TTS_SPEED_MAX = 5.0;
export const TTS_SPEED_STORAGE_KEY = 'aria_tts_speed';
export async function loadTtsSpeed(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) return n;
}
} catch {}
return TTS_SPEED_DEFAULT;
}
export async function loadConvWindowMs(): Promise<number> { export async function loadConvWindowMs(): Promise<number> {
try { try {
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY); const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
@@ -143,7 +125,7 @@ const MAX_RECORDING_MS = 120000;
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet. // Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec). // Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
export const TTS_PREROLL_DEFAULT_SEC = 3.5; export const TTS_PREROLL_DEFAULT_SEC = 3.5;
export const TTS_PREROLL_MIN_SEC = 0; // 0 = sofort abspielen (F5-TTS ist schnell genug) export const TTS_PREROLL_MIN_SEC = 1.0;
export const TTS_PREROLL_MAX_SEC = 6.0; export const TTS_PREROLL_MAX_SEC = 6.0;
export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec'; export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec';
@@ -191,20 +173,11 @@ class AudioService {
private pcmBytesCollected: number = 0; private pcmBytesCollected: number = 0;
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
// AudioFocus wird verzoegert freigegeben — wenn ARIA eine zweite Antwort
// direkt hinterherschickt (oder ein neuer Stream startet), bleibt Spotify
// pausiert. Ohne diese Verzoegerung springt Spotify im Mikro-Sekunden-Gap
// zwischen zwei Streams kurz wieder an.
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
// VAD State // VAD State
private vadEnabled: boolean = false; private vadEnabled: boolean = false;
private lastSpeechTime: number = 0; private lastSpeechTime: number = 0;
private vadTimer: ReturnType<typeof setInterval> | null = null; private vadTimer: ReturnType<typeof setInterval> | null = null;
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null; private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
private silenceFired: boolean = false;
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null; private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
@@ -212,24 +185,6 @@ class AudioService {
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
} }
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an. */
private _releaseFocusDeferred(): void {
this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
private _cancelDeferredFocusRelease(): void {
if (this.focusReleaseTimer) {
clearTimeout(this.focusReleaseTimer);
this.focusReleaseTimer = null;
}
}
// --- Berechtigungen --- // --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> { async requestMicrophonePermission(): Promise<boolean> {
@@ -330,50 +285,35 @@ class AudioService {
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
// VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar). // VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar)
// WICHTIG: jeder Trigger (VAD-Stille / Max-Dauer / No-Speech-Window)
// disable SOFORT den VAD-Flag und clear den Timer, BEVOR die Listener
// gefeuert werden. Sonst feuert das setInterval weiter alle 200ms und
// ruft stopRecording parallel auf → audio-recorder-player crasht.
this.vadEnabled = autoStop; this.vadEnabled = autoStop;
this.silenceFired = false;
const fireSilenceOnce = (reason: string) => {
if (this.silenceFired) return;
this.silenceFired = true;
this.vadEnabled = false;
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
console.log('[Audio] Silence-Fire: %s', reason);
this.silenceListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] silence listener err:', e); }
});
};
if (autoStop) { if (autoStop) {
const vadSilenceMs = await loadVadSilenceMs(); const vadSilenceMs = await loadVadSilenceMs();
console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms', console.log('[Audio] VAD-Stille:', vadSilenceMs, 'ms');
vadSilenceMs, MAX_RECORDING_MS);
this.vadTimer = setInterval(() => { this.vadTimer = setInterval(() => {
const silenceDuration = Date.now() - this.lastSpeechTime; const silenceDuration = Date.now() - this.lastSpeechTime;
if (silenceDuration >= vadSilenceMs) { if (silenceDuration >= vadSilenceMs) {
fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`); console.log(`[Audio] VAD: ${silenceDuration}ms Stille — Auto-Stop`);
this.silenceListeners.forEach(cb => cb());
} }
}, 200); }, 200);
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen // Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
this.maxDurationTimer = setTimeout(() => { this.maxDurationTimer = setTimeout(() => {
fireSilenceOnce(`Max-Dauer ${MAX_RECORDING_MS}ms`); console.warn(`[Audio] Max-Dauer ${MAX_RECORDING_MS}ms erreicht — Zwangs-Stop`);
this.silenceListeners.forEach(cb => cb());
}, MAX_RECORDING_MS); }, MAX_RECORDING_MS);
} }
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht // Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
// anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie). // anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie),
// ChatScreen erkennt das und beendet die Konversation.
if (noSpeechTimeoutMs > 0) { if (noSpeechTimeoutMs > 0) {
this.noSpeechTimer = setTimeout(() => { this.noSpeechTimer = setTimeout(() => {
if (!this.speechDetected && this.recordingState === 'recording') { if (!this.speechDetected && this.recordingState === 'recording') {
fireSilenceOnce(`Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache`); console.log(`[Audio] Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache — Stop`);
this.silenceListeners.forEach(cb => cb());
} }
}, noSpeechTimeoutMs); }, noSpeechTimeoutMs);
} }
@@ -413,9 +353,8 @@ class AudioService {
await this.recorder.stopRecorder(); await this.recorder.stopRecorder();
this.recorder.removeRecordBackListener(); this.recorder.removeRecordBackListener();
// Audio-Focus verzoegert freigeben — gleich kommt die TTS-Antwort, // Audio-Focus freigeben — andere Apps duerfen wieder
// im Gap soll Spotify nicht hochkommen. AudioFocus?.release().catch(() => {});
this._releaseFocusDeferred();
const durationMs = Date.now() - this.recordingStartTime; const durationMs = Date.now() - this.recordingStartTime;
const hadSpeech = this.speechDetected; const hadSpeech = this.speechDetected;
@@ -487,13 +426,7 @@ class AudioService {
/** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen. /** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen.
* silent=true → nur cachen, nicht abspielen (z.B. wenn TTS geraetelokal gemutet). * silent=true → nur cachen, nicht abspielen (z.B. wenn TTS geraetelokal gemutet).
* Gibt bei final=true den Cache-Pfad zurueck (file://) oder '' wenn nicht gecached. * Gibt bei final=true den Cache-Pfad zurueck (file://) oder '' wenn nicht gecached. */
*
* Wrapper serialisiert aufeinanderfolgende Chunk-Calls via Promise-Queue —
* sonst gabs bei kurzen Streams einen Race: final-Chunk konnte `end()` rufen
* BEVOR der vorherige `start()` im Native-Modul fertig war. Der Writer-
* Thread sah dann endRequested=true ohne jemals Chunks zu verarbeiten. */
private _pcmChunkQueue: Promise<any> = Promise.resolve();
async handlePcmChunk(payload: { async handlePcmChunk(payload: {
base64: string; base64: string;
sampleRate?: number; sampleRate?: number;
@@ -502,37 +435,12 @@ class AudioService {
chunk?: number; chunk?: number;
final?: boolean; final?: boolean;
silent?: boolean; silent?: boolean;
}): Promise<string> {
const p = this._pcmChunkQueue.then(() => this._handlePcmChunkImpl(payload)).catch(err => {
console.warn('[Audio] handlePcmChunk queued err:', err);
return '';
});
// Chain only on the side effect — callers still get the per-call result
this._pcmChunkQueue = p;
return p;
}
private async _handlePcmChunkImpl(payload: {
base64: string;
sampleRate?: number;
channels?: number;
messageId?: string;
chunk?: number;
final?: boolean;
silent?: boolean;
}): Promise<string> { }): Promise<string> {
const silent = !!payload.silent; const silent = !!payload.silent;
if (!silent && !PcmStreamPlayer) { if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return ''; return '';
} }
// Debug-Log bei Chunk 0 eines neuen Streams — damit man im adb logcat
// sieht warum der Auto-Playback greift oder nicht.
if ((payload.chunk ?? 0) === 0 && !this.pcmStreamActive) {
console.log('[Audio] PCM-Stream start: silent=%s messageId=%s sr=%s ch=%s',
silent, payload.messageId || '(none)',
payload.sampleRate, payload.channels);
}
const messageId = payload.messageId || ''; const messageId = payload.messageId || '';
const sampleRate = payload.sampleRate || 24000; const sampleRate = payload.sampleRate || 24000;
@@ -562,7 +470,6 @@ class AudioService {
this.pcmStreamActive = false; this.pcmStreamActive = false;
return ''; return '';
} }
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
} }
} }
@@ -581,12 +488,11 @@ class AudioService {
if (isFinal) { if (isFinal) {
if (!silent) { if (!silent) {
// end() resolved jetzt erst wenn der native Writer-Thread fertig // end() resolved jetzt erst wenn der native Writer-Thread fertig
// ist (alle Samples ausgespielt) — danach AudioFocus verzoegert // ist (alle Samples ausgespielt) — danach erst AudioFocus freigeben,
// freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei // damit Spotify/YouTube nicht waehrend des Pre-Roll-Ausklangs
// ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb // wieder aufdrehen.
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen.
try { await PcmStreamPlayer!.end(); } catch {} try { await PcmStreamPlayer!.end(); } catch {}
this._releaseFocusDeferred(); AudioFocus?.release().catch(() => {});
} }
this.pcmStreamActive = false; this.pcmStreamActive = false;
@@ -690,9 +596,8 @@ class AudioService {
private async _playNext(): Promise<void> { private async _playNext(): Promise<void> {
if (this.audioQueue.length === 0) { if (this.audioQueue.length === 0) {
this.isPlaying = false; this.isPlaying = false;
// Audio-Focus verzoegert abgeben → wenn gleich noch eine Antwort kommt, // Audio-Focus abgeben → andere Apps volle Lautstaerke
// bleibt Spotify pausiert. AudioFocus?.release().catch(() => {});
this._releaseFocusDeferred();
// Alle Audio-Teile abgespielt → Listener benachrichtigen // Alle Audio-Teile abgespielt → Listener benachrichtigen
this.playbackFinishedListeners.forEach(cb => cb()); this.playbackFinishedListeners.forEach(cb => cb());
return; return;
@@ -700,7 +605,6 @@ class AudioService {
// Beim ersten Playback-Start: andere Apps ducken // Beim ersten Playback-Start: andere Apps ducken
if (!this.isPlaying) { if (!this.isPlaying) {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
} }
this.isPlaying = true; this.isPlaying = true;
@@ -786,8 +690,7 @@ class AudioService {
this.pcmBytesCollected = 0; this.pcmBytesCollected = 0;
this.pcmMessageId = ''; this.pcmMessageId = '';
} }
// Audio-Focus sofort freigeben — User hat explizit abgebrochen // Audio-Focus freigeben
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
} }
-33
View File
@@ -29,11 +29,6 @@ class UpdateService {
private downloading = false; private downloading = false;
constructor() { 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 // Auf update_available Nachrichten lauschen
rvs.onMessage((msg: RVSMessage) => { rvs.onMessage((msg: RVSMessage) => {
if (msg.type === 'update_available' as any) { if (msg.type === 'update_available' as any) {
@@ -50,30 +45,6 @@ class UpdateService {
}); });
} }
/** Raeumt alte heruntergeladene APK-Dateien aus dem Cache auf. */
private async cleanupOldApks(): Promise<void> {
try {
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
const apks = files.filter(f => /\.apk$/i.test(f.name));
let freed = 0;
for (const f of apks) {
try {
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path);
freed += size;
console.log(`[Update] Alte APK geloescht: ${f.name} (${(size / 1024 / 1024).toFixed(1)}MB)`);
} catch (err: any) {
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.name} (${err?.message || err})`);
}
}
if (apks.length > 0) {
console.log(`[Update] Cleanup fertig: ${apks.length} APKs entfernt, ${(freed / 1024 / 1024).toFixed(1)}MB freigegeben`);
}
} catch (err: any) {
console.warn(`[Update] Cleanup-Fehler: ${err?.message || err}`);
}
}
/** Bei App-Start Update pruefen */ /** Bei App-Start Update pruefen */
checkForUpdate(): void { checkForUpdate(): void {
if (this.checking) return; if (this.checking) return;
@@ -140,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 // Base64 als APK-Datei speichern
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`; const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
await RNFS.writeFile(destPath, apkData.base64, 'base64'); await RNFS.writeFile(destPath, apkData.base64, 'base64');
+84 -126
View File
@@ -1,26 +1,21 @@
/** /**
* Gespraechsmodus / Wake Word Service * Gespraechsmodus / Wake Word Service
* *
* 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.
*
* Drei Zustaende: * Drei Zustaende:
* off — Ohr aus, nichts laeuft * off — Ohr aus, nichts laeuft
* armed — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word. * armed — Ohr aktiv, Porcupine hoert passiv auf das Wake-Word.
* Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus. * Das Mikro ist von Porcupine belegt; AudioRecorder ist aus.
* conversing — Wake-Word getriggert (oder Ohr-Tap manuell): * conversing — Wake-Word getriggert (oder Ohr-Tap ohne Wake-Word):
* aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei), * aktive Konversation. Porcupine pausiert (gibt Mikro frei),
* AudioRecorder uebernimmt fuer die Aufnahme. * AudioRecorder uebernimmt fuer die Aufnahme.
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden * Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
* (Conversation-Window). Stille im Fenster → zurueck zu armed. * (Conversation-Window). Stille im Fenster → zurueck zu armed.
* *
* Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht * Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start'
* 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus). * direkt in 'conversing' (klassischer Gespraechsmodus). 'endConversation'
* geht dann nach 'off' statt 'armed'.
*/ */
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
type WakeWordCallback = () => void; type WakeWordCallback = () => void;
@@ -28,111 +23,85 @@ type StateCallback = (state: WakeWordState) => void;
export type WakeWordState = 'off' | 'armed' | 'conversing'; export type WakeWordState = 'off' | 'armed' | 'conversing';
export const WAKE_ACCESS_KEY_STORAGE = 'aria_wake_access_key';
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword'; export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in /** Built-In Keywords von Picovoice — pre-trained, sofort einsetzbar.
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes * Custom Keywords (z.B. "ARIA") brauchen ein .ppn File aus der Picovoice
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut * Console — wird spaeter ueber Diagnostic uploadbar. */
* werden — Diagnostic-Upload ist Phase 2. */ export const BUILTIN_KEYWORDS = [
export const WAKE_KEYWORDS = [ 'jarvis',
'hey_jarvis', 'computer',
'picovoice',
'porcupine',
'bumblebee',
'terminator',
'alexa', 'alexa',
'hey_mycroft', 'hey google',
'hey_rhasspy', 'ok google',
'hey siri',
] as const; ] as const;
export type WakeKeyword = typeof WAKE_KEYWORDS[number]; export type BuiltinKeyword = typeof BUILTIN_KEYWORDS[number];
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis'; export const DEFAULT_KEYWORD: BuiltinKeyword = 'jarvis';
/** Hilfs-Mapping fuer die Anzeige im UI. */
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
hey_jarvis: 'Hey Jarvis',
alexa: 'Alexa',
hey_mycroft: 'Hey Mycroft',
hey_rhasspy: 'Hey Rhasspy',
};
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
const DEFAULT_THRESHOLD = 0.5;
const DEFAULT_PATIENCE = 2;
const DEFAULT_DEBOUNCE_MS = 1500;
interface OpenWakeWordModule {
init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise<boolean>;
start(): Promise<boolean>;
stop(): Promise<boolean>;
dispose(): Promise<boolean>;
isAvailable(): Promise<boolean>;
}
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
class WakeWordService { class WakeWordService {
private state: WakeWordState = 'off'; private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = []; private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = []; private stateCallbacks: StateCallback[] = [];
private keyword: WakeKeyword = DEFAULT_KEYWORD; // Picovoice Manager (lazy, da Native Module nicht in jedem Build verfuegbar ist)
private nativeReady: boolean = false; private porcupine: any = null;
private accessKey: string = '';
private keyword: string = DEFAULT_KEYWORD;
private initInProgress: Promise<boolean> | null = null; private initInProgress: Promise<boolean> | null = null;
private eventSub: { remove: () => void } | null = null;
/** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */ /** Beim App-Start aufrufen — laedt Settings, baut Porcupine wenn Key da ist. */
async loadFromStorage(): Promise<void> { async loadFromStorage(): Promise<void> {
try { try {
const k = await AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE);
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE); const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword; this.accessKey = (k || '').trim();
this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD; this.keyword = (w || DEFAULT_KEYWORD).trim();
await this.initNative(); if (this.accessKey) {
// Vorinitialisieren — wirft sich nicht durch wenn etwas fehlt
await this.initPorcupine();
}
} catch (err) { } catch (err) {
console.warn('[WakeWord] loadFromStorage', err); console.warn('[WakeWord] loadFromStorage', err);
} }
} }
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */ /** Settings-Wechsel — neuer Key oder Keyword. Re-Init Porcupine. */
async configure(keyword: string): Promise<boolean> { async configure(accessKey: string, keyword: string): Promise<boolean> {
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword) this.accessKey = (accessKey || '').trim();
? (keyword as WakeKeyword) this.keyword = (keyword || DEFAULT_KEYWORD).trim();
: DEFAULT_KEYWORD; await AsyncStorage.setItem(WAKE_ACCESS_KEY_STORAGE, this.accessKey);
this.keyword = next; await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, this.keyword);
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);
// Laufende Instanz stoppen + neu initialisieren // Laufende Instanz stoppen
await this.disposeNative(); await this.disposePorcupine();
const ok = await this.initNative(); if (!this.accessKey) return false;
if (!ok) {
ToastAndroid.show( // Neu initialisieren
`Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`, return this.initPorcupine();
ToastAndroid.LONG,
);
}
return ok;
} }
private async initNative(): Promise<boolean> { private async initPorcupine(): 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; if (this.initInProgress) return this.initInProgress;
this.initInProgress = (async () => { this.initInProgress = (async () => {
try { try {
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS); const { PorcupineManager } = require('@picovoice/porcupine-react-native');
// Subscribe nur einmal // Built-In Keyword-Identifier sind lower-case strings im SDK
if (!this.eventSub) { this.porcupine = await PorcupineManager.fromBuiltInKeywords(
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord); this.accessKey,
this.eventSub = emitter.addListener('WakeWordDetected', () => { [this.keyword],
console.log('[WakeWord] Native Detection-Event empfangen'); (_keywordIndex: number) => this.onWakeDetected(),
this.onWakeDetected().catch(err => );
console.warn('[WakeWord] onWakeDetected crashed:', err)); console.log('[WakeWord] Porcupine init OK (keyword=%s)', this.keyword);
});
}
this.nativeReady = true;
console.log('[WakeWord] Init OK (model=%s)', this.keyword);
return true; return true;
} catch (err: any) { } catch (err) {
console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err); console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err);
this.nativeReady = false; this.porcupine = null;
return false; return false;
} finally { } finally {
this.initInProgress = null; this.initInProgress = null;
@@ -141,39 +110,30 @@ class WakeWordService {
return this.initInProgress; return this.initInProgress;
} }
private async disposeNative(): Promise<void> { private async disposePorcupine() {
if (!OpenWakeWord) return; if (this.porcupine) {
try { await OpenWakeWord.dispose(); } catch {} try { await this.porcupine.stop(); } catch {}
this.nativeReady = false; try { await this.porcupine.delete(); } catch {}
this.porcupine = null;
}
} }
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */ /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
async start(): Promise<boolean> { async start(): Promise<boolean> {
if (this.state !== 'off') return true; if (this.state !== 'off') return true;
if (this.nativeReady && OpenWakeWord) { if (this.porcupine) {
// Passives Lauschen via Porcupine
try { try {
await OpenWakeWord.start(); await this.porcupine.start();
console.log('[WakeWord] armed — warte auf "%s"', this.keyword); console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword);
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed'); this.setState('armed');
return true; return true;
} catch (err: any) { } catch (err) {
console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:', console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:', err);
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 // Fallback: direkt in die Konversation
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)'); console.log('[WakeWord] Konversation startet sofort (kein Wake-Word)');
this.setState('conversing'); this.setState('conversing');
setTimeout(() => { setTimeout(() => {
if (this.state === 'conversing') { if (this.state === 'conversing') {
@@ -186,20 +146,20 @@ class WakeWordService {
/** Komplett ausschalten (Ohr abschalten) */ /** Komplett ausschalten (Ohr abschalten) */
async stop(): Promise<void> { async stop(): Promise<void> {
console.log('[WakeWord] Ohr deaktiviert'); console.log('[WakeWord] Ohr deaktiviert');
if (this.nativeReady && OpenWakeWord) { if (this.porcupine) {
try { await OpenWakeWord.stop(); } catch {} try { await this.porcupine.stop(); } catch {}
} }
this.setState('off'); this.setState('off');
} }
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ /** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> { private async onWakeDetected(): Promise<void> {
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword); console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT); if (this.porcupine) {
if (this.nativeReady && OpenWakeWord) { try { await this.porcupine.stop(); } catch {}
try { await OpenWakeWord.stop(); } catch {}
} }
this.setState('conversing'); this.setState('conversing');
// kurz warten damit Mikrofon frei ist
setTimeout(() => { setTimeout(() => {
if (this.state === 'conversing') { if (this.state === 'conversing') {
this.wakeCallbacks.forEach(cb => cb()); this.wakeCallbacks.forEach(cb => cb());
@@ -208,16 +168,15 @@ class WakeWordService {
} }
/** Konversation beenden — User hat im Window nichts gesagt. /** Konversation beenden — User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Mit Wake-Word: zurueck zu 'armed' (Porcupine wieder an).
* Ohne: zurueck zu 'off'. * Ohne: zurueck zu 'off'.
*/ */
async endConversation(): Promise<void> { async endConversation(): Promise<void> {
if (this.state !== 'conversing') return; if (this.state !== 'conversing') return;
if (this.nativeReady && OpenWakeWord) { if (this.porcupine && this.accessKey) {
try { try {
await OpenWakeWord.start(); await this.porcupine.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed'); console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed'); this.setState('armed');
return; return;
} catch (err) { } catch (err) {
@@ -225,7 +184,6 @@ class WakeWordService {
} }
} }
console.log('[WakeWord] Konversation zu Ende — Ohr aus'); console.log('[WakeWord] Konversation zu Ende — Ohr aus');
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
this.setState('off'); this.setState('off');
} }
@@ -250,10 +208,10 @@ class WakeWordService {
} }
hasWakeWord(): boolean { hasWakeWord(): boolean {
return this.nativeReady; return !!this.porcupine;
} }
getKeyword(): WakeKeyword { getKeyword(): string {
return this.keyword; return this.keyword;
} }
+6 -74
View File
@@ -541,16 +541,9 @@ class ARIABridge:
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt. # Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request). # So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
self._next_voice_override: Optional[str] = None 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. # STT-Requests die aktuell auf Antwort von der whisper-bridge (Gamebox) warten.
# requestId → Future mit dem Text (oder None bei Fehler). # requestId → Future mit dem Text (oder None bei Fehler).
self._pending_stt: dict[str, asyncio.Future] = {} 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
def initialize(self) -> None: def initialize(self) -> None:
"""Initialisiert alle Komponenten. """Initialisiert alle Komponenten.
@@ -914,12 +907,6 @@ class ARIABridge:
logger.info("[core] Nutze Voice-Override: %s", self._next_voice_override) logger.info("[core] Nutze Voice-Override: %s", self._next_voice_override)
self._next_voice_override = None self._next_voice_override = None
# Speed ebenfalls aus App-Override nehmen (fallback 1.0)
xtts_speed = self._next_speed_override or 1.0
if self._next_speed_override:
logger.info("[core] Nutze Speed-Override: %.2fx", self._next_speed_override)
self._next_speed_override = None
tts_text = tts_text_preview or text tts_text = tts_text_preview or text
if not tts_text: if not tts_text:
logger.info("[core] TTS-Text leer nach Cleanup — uebersprungen") logger.info("[core] TTS-Text leer nach Cleanup — uebersprungen")
@@ -935,15 +922,13 @@ class ARIABridge:
"payload": { "payload": {
"text": tts_text, "text": tts_text,
"voice": xtts_voice, "voice": xtts_voice,
"speed": xtts_speed,
"language": "de", "language": "de",
"requestId": xtts_request_id, "requestId": xtts_request_id,
"messageId": message_id, "messageId": message_id,
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx): '%s'", logger.info("[core] XTTS-Request gesendet (%s): '%s'", xtts_voice or "default", tts_text[:60])
xtts_voice or "default", xtts_speed, tts_text[:60])
except Exception as e: except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e) logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
@@ -1174,13 +1159,6 @@ class ARIABridge:
if voice_override: if voice_override:
self._next_voice_override = voice_override self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override fuer naechste Antwort: %s", voice_override) logger.info("[rvs] Voice-Override fuer naechste Antwort: %s", voice_override)
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
try:
speed = float(payload.get("speed", 0) or 0)
if 0.1 <= speed <= 5.0:
self._next_speed_override = speed
except (TypeError, ValueError):
pass
if text: if text:
logger.info("[rvs] App-Chat: '%s'", text[:80]) logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app") await self.send_to_core(text, source="app")
@@ -1233,14 +1211,8 @@ class ARIABridge:
if not text: if not text:
return return
tts_text = clean_text_for_tts(text) or text 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', '') 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: try:
xtts_request_id = str(uuid.uuid4()) xtts_request_id = str(uuid.uuid4())
if message_id: if message_id:
@@ -1250,7 +1222,6 @@ class ARIABridge:
"payload": { "payload": {
"text": tts_text, "text": tts_text,
"voice": xtts_voice, "voice": xtts_voice,
"speed": xtts_speed,
"language": "de", "language": "de",
"requestId": xtts_request_id, "requestId": xtts_request_id,
"messageId": message_id, "messageId": message_id,
@@ -1449,12 +1420,6 @@ class ARIABridge:
if voice_override: if voice_override:
self._next_voice_override = voice_override self._next_voice_override = voice_override
logger.info("[rvs] Voice-Override (via Audio): %s", voice_override) logger.info("[rvs] Voice-Override (via Audio): %s", voice_override)
try:
speed = float(payload.get("speed", 0) or 0)
if 0.1 <= speed <= 5.0:
self._next_speed_override = speed
except (TypeError, ValueError):
pass
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB", logger.info("[rvs] Audio empfangen: %s, %dms, %dKB",
mime_type, duration_ms, len(audio_b64) // 1365) mime_type, duration_ms, len(audio_b64) // 1365)
asyncio.create_task(self._process_app_audio(audio_b64, mime_type)) asyncio.create_task(self._process_app_audio(audio_b64, mime_type))
@@ -1477,41 +1442,13 @@ class ARIABridge:
future.set_result(text) future.set_result(text)
return return
elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
svc = payload.get("service", "")
state = payload.get("state", "")
if svc == "whisper":
was_ready = self._remote_stt_ready
self._remote_stt_ready = (state == "ready")
if self._remote_stt_ready != was_ready:
logger.info("[rvs] whisper-bridge -> %s", state)
return
elif msg_type == "config_request":
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
# Config — passiert wenn sie sich connected, weil sie sonst die
# Diagnostic-Settings nicht kennt. Wir broadcasten die persistierte
# Config (auch beim normalen Connect von aria-bridge selber, aber
# da war eventuell die andere Bridge noch nicht connected).
requester = payload.get("service", "?")
logger.info("[rvs] config_request von %s — broadcaste Voice-Config", requester)
asyncio.create_task(self._broadcast_persisted_config())
return
else: else:
logger.debug("[rvs] Unbekannter Typ: %s", msg_type) logger.debug("[rvs] Unbekannter Typ: %s", msg_type)
# STT-Orchestrierung: zuerst Remote (Gamebox), Fallback lokal. # STT-Orchestrierung: zuerst Remote (Gamebox), Fallback lokal.
# Zwei Timeouts: # Timeout grosszuegig gewaehlt, damit auch ein erstmaliger Modell-Load
# ready=True → 45s reicht selbst fuer lange Audios # auf der Gamebox (bis ~30s bei large-v3) durchgeht.
# ready=False → 300s, weil das Modell evtl. noch heruntergeladen wird _STT_REMOTE_TIMEOUT_S = 45.0
# (large-v3 ~3GB, kann auf der Gamebox 1-2 Min dauern).
_STT_REMOTE_TIMEOUT_READY_S = 45.0
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0
async def _process_app_audio(self, audio_b64: str, mime_type: str) -> None: async def _process_app_audio(self, audio_b64: str, mime_type: str) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.""" """App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal."""
@@ -1577,12 +1514,7 @@ class ARIABridge:
if not ok: if not ok:
logger.warning("[rvs] stt_request konnte nicht gesendet werden — skip Remote") logger.warning("[rvs] stt_request konnte nicht gesendet werden — skip Remote")
return None return None
timeout_s = (self._STT_REMOTE_TIMEOUT_READY_S return await asyncio.wait_for(future, timeout=self._STT_REMOTE_TIMEOUT_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: except asyncio.TimeoutError:
logger.warning("[rvs] Remote-STT Timeout (%.0fs)", self._STT_REMOTE_TIMEOUT_S) logger.warning("[rvs] Remote-STT Timeout (%.0fs)", self._STT_REMOTE_TIMEOUT_S)
return None return None
+6 -113
View File
@@ -136,34 +136,6 @@
</div> </div>
</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) --> <!-- 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 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;"> <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
@@ -497,25 +469,23 @@
Hardcoded Defaults: F5TTS_v1_Base, cfg_strength=2.5, nfe_step=32. Hardcoded Defaults: F5TTS_v1_Base, cfg_strength=2.5, nfe_step=32.
</div> </div>
<label style="color:#8888AA;font-size:12px;"> <label style="color:#8888AA;font-size:12px;">Modell-ID:</label>
Modell-Architektur (F5TTS_v1_Base = Default multilingual, F5TTS_Base = fuer die meisten Fine-Tunes):
</label>
<input type="text" id="diag-f5tts-model" <input type="text" id="diag-f5tts-model"
placeholder="F5TTS_v1_Base" placeholder="F5TTS_v1_Base"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;"> 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;"> <label style="color:#8888AA;font-size:12px;">
Custom Checkpoint — HF-Pfad (hf://user/repo/file) oder lokaler Container-Pfad. Leer = Default. Custom Checkpoint (HF-Repo "user/repo" oder Container-Pfad, leer = Default):
</label> </label>
<input type="text" id="diag-f5tts-ckpt" <input type="text" id="diag-f5tts-ckpt"
placeholder="z.B. hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors" placeholder="z.B. aoxo/F5-TTS-German"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;"> 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;"> <label style="color:#8888AA;font-size:12px;">
Custom Vocab — muss zum Checkpoint passen. Leer = Default. Custom Vocab (passend zum Checkpoint, optional):
</label> </label>
<input type="text" id="diag-f5tts-vocab" <input type="text" id="diag-f5tts-vocab"
placeholder="z.B. hf://aihpi/F5-TTS-German/vocab.txt" placeholder="leer = Default"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;"> 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="display:flex;gap:12px;">
@@ -958,24 +928,6 @@
return; 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') { if (msg.type === 'voice_ready') {
const v = msg.payload?.voice || ''; const v = msg.payload?.voice || '';
const err = msg.payload?.error; const err = msg.payload?.error;
@@ -1625,75 +1577,16 @@
html += '<div style="display:flex;flex-direction:column;gap:4px;">'; html += '<div style="display:flex;flex-direction:column;gap:4px;">';
for (const v of voices) { for (const v of voices) {
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[c])); 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;">` 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="flex:1;color:#E0E0F0;">${esc(v.name)}</span>`
+ `<span style="color:#555570;font-size:10px;">${(v.size/1024).toFixed(0)}KB</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('${esc(v.name).replace(/'/g, "\\'")}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;" title="Stimme loeschen">X</button>`
+ `<button class="btn secondary" onclick="deleteXttsVoice('${jsName}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;" title="Stimme loeschen">X</button>`
+ `</div>`; + `</div>`;
} }
html += '</div>'; html += '</div>';
box.innerHTML = html; 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) { function deleteXttsVoice(name) {
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return; if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
send({ action: 'xtts_delete_voice', name }); send({ action: 'xtts_delete_voice', name });
+13 -105
View File
@@ -653,9 +653,6 @@ function connectRVS(forcePlain) {
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`); log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
} }
broadcast({ type: "service_status", payload: msg.payload }); 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 { } else {
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`); log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
} }
@@ -1442,14 +1439,19 @@ wss.on("connection", (ws) => {
xttsVoice: msg.xttsVoice || "", xttsVoice: msg.xttsVoice || "",
}; };
if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel; if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel;
// F5-TTS Tuning-Felder — immer mit dem vom User gesendeten Wert setzen, // F5-TTS Tuning-Felder — leere Strings entfernen damit der Default greift
// auch leeren String. Leer = "reset auf Hard-Default". Sonst merkt die if (msg.f5ttsModel !== undefined) {
// Bridge nicht dass der User den Wert loeschen wollte (absent key war if (msg.f5ttsModel) voiceConfig.f5ttsModel = msg.f5ttsModel;
// vorher 'keep current' semantik → BigVGAN blieb drin obwohl User else delete voiceConfig.f5ttsModel;
// leer eingetragen hatte). }
if (msg.f5ttsModel !== undefined) voiceConfig.f5ttsModel = msg.f5ttsModel || ""; if (msg.f5ttsCkptFile !== undefined) {
if (msg.f5ttsCkptFile !== undefined) voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile || ""; if (msg.f5ttsCkptFile) voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile;
if (msg.f5ttsVocabFile !== undefined) voiceConfig.f5ttsVocabFile = msg.f5ttsVocabFile || ""; else delete voiceConfig.f5ttsCkptFile;
}
if (msg.f5ttsVocabFile !== undefined) {
if (msg.f5ttsVocabFile) voiceConfig.f5ttsVocabFile = msg.f5ttsVocabFile;
else delete voiceConfig.f5ttsVocabFile;
}
if (msg.f5ttsCfgStrength !== undefined && !isNaN(msg.f5ttsCfgStrength)) { if (msg.f5ttsCfgStrength !== undefined && !isNaN(msg.f5ttsCfgStrength)) {
voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength; voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength;
} }
@@ -1468,8 +1470,6 @@ wss.on("connection", (ws) => {
handleSaveTriggers(ws, msg.triggers || []); handleSaveTriggers(ws, msg.triggers || []);
} else if (msg.action === "test_tts") { } else if (msg.action === "test_tts") {
handleTestTTS(ws, msg.text || "Test"); 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") { } else if (msg.action === "check_tts") {
handleCheckTTS(ws); handleCheckTTS(ws);
} else if (msg.action === "check_desktop") { } else if (msg.action === "check_desktop") {
@@ -1642,98 +1642,6 @@ async function handleSaveTriggers(clientWs, triggers) {
} }
// ── TTS Diagnose (XTTS) ─────────────────────────────── // ── TTS Diagnose (XTTS) ───────────────────────────────
// ── Voice Preview ────────────────────────────────────────
// Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV
// und schickt sie base64-kodiert an den Browser-Client.
//
// Map requestId → { clientWs, chunks: [Buffer], sampleRate, channels }
const _previewPending = new Map();
function _buildWavFromPcm(pcmBuf, sampleRate, channels) {
const bitsPerSample = 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const dataSize = pcmBuf.length;
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + dataSize, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16); // subchunk1 size
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write("data", 36);
header.writeUInt32LE(dataSize, 40);
return Buffer.concat([header, pcmBuf]);
}
function _handlePreviewChunk(payload) {
const reqId = payload?.requestId || "";
const entry = _previewPending.get(reqId);
if (!entry) return;
if (payload.base64) {
try { entry.chunks.push(Buffer.from(payload.base64, "base64")); } catch {}
}
if (!entry.sampleRate && payload.sampleRate) entry.sampleRate = payload.sampleRate;
if (!entry.channels && payload.channels) entry.channels = payload.channels;
if (payload.final) {
_previewPending.delete(reqId);
try {
const pcm = Buffer.concat(entry.chunks);
const wav = _buildWavFromPcm(pcm, entry.sampleRate || 24000, entry.channels || 1);
const b64 = wav.toString("base64");
if (entry.clientWs && entry.clientWs.readyState === 1) {
entry.clientWs.send(JSON.stringify({
type: "voice_preview_audio",
base64: b64,
size: wav.length,
}));
}
} catch (err) {
if (entry.clientWs && entry.clientWs.readyState === 1) {
entry.clientWs.send(JSON.stringify({
type: "voice_preview_audio",
error: err.message,
}));
}
}
}
}
async function handleVoicePreview(clientWs, voice, text, speed) {
try {
// Speed clampen — Browser-Slider ist 0.1-5.0
let spd = parseFloat(speed);
if (!isFinite(spd) || spd < 0.1 || spd > 5.0) spd = 1.0;
const requestId = crypto.randomUUID();
_previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 });
// Timeout safety net
setTimeout(() => {
if (_previewPending.has(requestId)) {
_previewPending.delete(requestId);
if (clientWs && clientWs.readyState === 1) {
clientWs.send(JSON.stringify({
type: "voice_preview_audio",
error: "Timeout (60s) — keine Antwort vom f5tts-bridge",
}));
}
}
}, 60000);
log("info", "server", `Voice-Preview: voice="${voice}" speed=${spd.toFixed(1)}x text="${text.slice(0, 60)}"`);
sendToRVS_raw({
type: "xtts_request",
payload: { text, language: "de", requestId, voice, speed: spd },
timestamp: Date.now(),
});
} catch (err) {
clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message }));
}
}
async function handleTestTTS(clientWs, text) { async function handleTestTTS(clientWs, text) {
try { try {
log("info", "server", `TTS-Test via XTTS: "${text}"`); log("info", "server", `TTS-Test via XTTS: "${text}"`);
+7 -19
View File
@@ -70,34 +70,22 @@
- [x] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s) - [x] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s)
- [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich - [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix) - [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
- [x] F5-TTS: Referenz-WAV-Preprocessing — Loudness-Normalisierung -16 LUFS + Silence-Trim + 10s Clip fuer konsistente Cloning-Quali
- [x] F5-TTS: deutsches Fine-Tune (aihpi/F5-TTS-German, Vocos-Variante) via hf:// Pfad in Diagnostic konfigurierbar
- [x] Whisper transkribiert Voice-Uploads nicht mehr mit hardcoded "small" — aktuelles Modell wird behalten, kein unnoetiger Modell-Swap
- [x] RVS/WebSocket maxPayload 50MB: voice_upload mit WAV als base64 sprengt kein Frame-Limit mehr
- [x] Dynamischer STT-Timeout in aria-bridge: 300s waehrend whisper-bridge 'loading', 45s wenn 'ready'
- [x] service_status Broadcasts: f5tts/whisper melden Lade-Status, Banner in Diagnostic (unten rechts) + App (oben)
- [x] config_request Pattern: Bridges fragen beim Connect die aktuelle Voice-Config an, aria-bridge antwortet
- [x] F5-TTS Tuning via Diagnostic (Modell-ID, Checkpoint, cfg_strength, nfe_step) statt ENV-Vars — Hot-Reload bei Modell-Wechsel
- [x] Conversation-Window: Gespraechsmodus endet nach X Sekunden Stille (1.0-20.0s, Default 8s, einstellbar in Settings)
- [x] Porcupine Wake-Word-Integration in der App (Built-In Keywords + Custom spaeter, per Geraet einstellbar)
- [x] HF-Cache als Bind-Mount statt Docker Volume — kein .vhdx-Bloat auf Docker Desktop / Windows
- [x] cleanup-windows.ps1 / .bat: VHDX-Cleanup via diskpart (ohne Hyper-V) mit Self-Elevation
- [x] App Mute-/Auto-Playback-Bug: Closure-Bug geloest (ttsCanPlayRef live-gespiegelt, nicht mehr stale)
- [x] App Zombie-Recording: Ohr-aus kill laufende Aufnahme damit der Aufnahme-Button weiter funktioniert
- [x] App Text-Rendering: Nachrichten selektierbar + Autolink fuer URLs/E-Mails/Telefonnummern (Browser/Mail/Dialer)
- [x] TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings → 0.5-2.0x in 0.1-Schritten, Default 1.0)
- [x] Diagnostic: Voice-Preview-Modal (Play-Icon vor Delete-X, Textfeld mit Default, WAV im Browser abspielen)
## Offen ## Offen
### Bugs ### Bugs
- [ ] App: Wake-Word "jarvis" triggert nicht zuverlaessig (Porcupine-Debugging via ADB-Logcat ausstehend) - [ ] NO_REPLY wird als "NO" im Chat angezeigt — sollte still verworfen werden (Token nicht gesaeubert)
- [ ] App: Stuerzt beim Lauschen ab, eventuell bei Nebengeraeuschen (Porcupine + Mic-Race, errorCallback haelt's jetzt zurueck — Dauertest ausstehend)
### App Features ### App Features
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition) - [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Background Audio Service (TTS auch bei minimierter App) - [ ] Background Audio Service (TTS auch bei minimierter App)
### TTS / Audio
- [ ] Audio-Normalisierung (Lautstaerke zwischen Saetzen/Chunks angleichen)
- [ ] F5-TTS: Streaming-Inferenz testen (nativ statt satzweise) wenn ein passendes Backend kommt
- [ ] F5-TTS: Optional Deepspeed-Beschleunigung pruefen
### Architektur ### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA) - [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
- [ ] Auto-Compacting und Memory/Brain Verwaltung (SQLite?) - [ ] Auto-Compacting und Memory/Brain Verwaltung (SQLite?)
-1
View File
@@ -22,7 +22,6 @@ const ALLOWED_TYPES = new Set([
"voice_preload", "voice_ready", "voice_preload", "voice_ready",
"stt_request", "stt_response", "stt_request", "stt_response",
"service_status", "service_status",
"config_request",
]); ]);
// Token-Raum: token -> { clients: Set<ws> } // Token-Raum: token -> { clients: Set<ws> }
+1 -1
View File
@@ -4,7 +4,7 @@
# ════════════════════════════════════════════════ # ════════════════════════════════════════════════
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM) # RVS Verbindung (gleiche Daten wie auf der ARIA-VM)
RVS_HOST=example.com RVS_HOST=mobil.hacker-net.de
RVS_PORT=444 RVS_PORT=444
RVS_TLS=true RVS_TLS=true
RVS_TLS_FALLBACK=true RVS_TLS_FALLBACK=true
-4
View File
@@ -1,7 +1,3 @@
# 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) # Voice-Samples (lokal, gehoert nicht ins Repo)
voices/ voices/
+7 -10
View File
@@ -31,11 +31,11 @@ services:
capabilities: [gpu] capabilities: [gpu]
volumes: volumes:
- ./voices:/voices # WAV + TXT Referenz - ./voices:/voices # WAV + TXT Referenz
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount. # KEIN HF-Cache-Mount mehr —
# Direkt sichtbar im xtts/hf-cache/, # Modell wird beim Start neu
# einfach manuell zu loeschen, kein # gezogen. Diagnostic zeigt
# Docker-Desktop .vhdx Bloat. # "TTS laedt..." Banner bis
# Wird mit whisper-bridge geteilt. # service_status: ready kommt.
environment: environment:
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength, # Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config. # nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
@@ -77,9 +77,6 @@ services:
- WHISPER_DEVICE=${WHISPER_DEVICE:-cuda} - WHISPER_DEVICE=${WHISPER_DEVICE:-cuda}
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16} - WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
- WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de} - WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de}
volumes: # KEIN HF-Cache-Mount — Whisper-Modell wird beim Start neu gezogen.
- ./hf-cache:/root/.cache/huggingface # gleicher Cache wie f5tts-bridge — # Wechsel via Diagnostic triggert ebenso Re-Download.
# ein Modell muss nur einmal pro
# Maschine geladen werden, kein
# Re-Download bei Container-Restart.
restart: unless-stopped restart: unless-stopped
+49 -173
View File
@@ -73,12 +73,6 @@ VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16 PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
TARGET_SR = 24000 # F5-TTS native 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, # Wird in einer Uebergangsphase als "ungueltige Referenz" erkannt (alte voices,
# die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung # die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung
@@ -99,33 +93,6 @@ def _get_f5tts_cls():
return _F5TTS_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: class F5Runner:
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking). """Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking).
@@ -149,16 +116,14 @@ class F5Runner:
def _load_blocking(self) -> None: def _load_blocking(self) -> None:
cls = _get_f5tts_cls() 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)...", logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...",
self.model_id, F5TTS_DEVICE, ckpt_resolved or "default") self.model_id, F5TTS_DEVICE, self.ckpt_file or "default")
self._load_started_at = time.time() self._load_started_at = time.time()
kwargs = {"model": self.model_id, "device": F5TTS_DEVICE} kwargs = {"model": self.model_id, "device": F5TTS_DEVICE}
if ckpt_resolved: if self.ckpt_file:
kwargs["ckpt_file"] = ckpt_resolved kwargs["ckpt_file"] = self.ckpt_file
if vocab_resolved: if self.vocab_file:
kwargs["vocab_file"] = vocab_resolved kwargs["vocab_file"] = self.vocab_file
self.model = cls(**kwargs) self.model = cls(**kwargs)
elapsed = time.time() - self._load_started_at elapsed = time.time() - self._load_started_at
logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)", logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)",
@@ -175,31 +140,10 @@ class F5Runner:
async def update_config(self, payload: dict) -> None: async def update_config(self, payload: dict) -> None:
"""Liest f5tts*-Felder aus einem config-Broadcast. """Liest f5tts*-Felder aus einem config-Broadcast.
Bei Modell-relevantem Wechsel wird neu geladen. Bei Modell-relevantem Wechsel wird neu geladen."""
new_model = (payload.get("f5ttsModel") or "").strip() or self.model_id
Semantik: new_ckpt = payload.get("f5ttsCkptFile", self.ckpt_file) or ""
- key fehlt in payload → aktuellen Wert behalten new_vocab = payload.get("f5ttsVocabFile", self.vocab_file) or ""
- 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: try:
new_cfg = float(payload.get("f5ttsCfgStrength", self.cfg_strength)) new_cfg = float(payload.get("f5ttsCfgStrength", self.cfg_strength))
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -237,10 +181,7 @@ class F5Runner:
else: else:
logger.info("F5-TTS Live-Config: cfg_strength=%.2f nfe=%d", new_cfg, new_nfe) 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, def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str) -> tuple[np.ndarray, int]:
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( wav, sr, _ = self.model.infer(
ref_file=ref_wav, ref_file=ref_wav,
ref_text=ref_text, ref_text=ref_text,
@@ -249,7 +190,6 @@ class F5Runner:
seed=-1, seed=-1,
cfg_strength=self.cfg_strength, cfg_strength=self.cfg_strength,
nfe_step=self.nfe_step, nfe_step=self.nfe_step,
speed=speed,
) )
# F5-TTS gibt float32 1D-Array — auf 24kHz sample-rate standard # F5-TTS gibt float32 1D-Array — auf 24kHz sample-rate standard
if not isinstance(wav, np.ndarray): if not isinstance(wav, np.ndarray):
@@ -258,11 +198,10 @@ class F5Runner:
wav = wav.squeeze() wav = wav.squeeze()
return wav.astype(np.float32), int(sr) return wav.astype(np.float32), int(sr)
async def synthesize(self, gen_text: str, ref_wav: str, ref_text: str, async def synthesize(self, gen_text: str, ref_wav: str, ref_text: str) -> tuple[np.ndarray, int]:
speed: float = 1.0) -> tuple[np.ndarray, int]:
await self.ensure_loaded() await self.ensure_loaded()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._infer_blocking, gen_text, ref_wav, ref_text, speed) return await loop.run_in_executor(None, self._infer_blocking, gen_text, ref_wav, ref_text)
# ── Helpers ───────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────
@@ -294,15 +233,7 @@ def split_sentences(text: str, max_len: int = 350) -> list[str]:
def float_to_pcm16(wav: np.ndarray) -> bytes: def float_to_pcm16(wav: np.ndarray) -> bytes:
"""Float32 (-1..+1) → int16 little-endian 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) wav = np.clip(wav, -1.0, 1.0)
pcm = (wav * 32767.0).astype(np.int16) pcm = (wav * 32767.0).astype(np.int16)
return pcm.tobytes() return pcm.tobytes()
@@ -317,51 +248,32 @@ def voice_paths(name: str) -> tuple[Path, Path]:
return VOICES_DIR / f"{safe}.wav", VOICES_DIR / f"{safe}.txt" 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]: def ensure_24k_mono_wav(src_wav: Path) -> Path:
"""Bringt die Referenz-WAV in F5-TTS-freundliche Form: """F5-TTS moechte 24kHz mono als Referenz — ffmpeg konvertiert inplace.
* 24kHz mono Wenn das File schon passt, wird nichts geaendert. Sonst wird es
* max max_seconds Dauer reingeschrieben (Original wird ueberschrieben).
* 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).
""" """
try:
info = sf.info(str(src_wav))
if info.samplerate == TARGET_SR and info.channels == 1:
return src_wav
except Exception:
pass
tmp_out = src_wav.with_suffix(".conv.wav") 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), cmd = ["ffmpeg", "-y", "-i", str(src_wav),
"-af", af, "-ar", str(TARGET_SR), "-ac", "1", "-f", "wav", str(tmp_out)]
"-ar", str(TARGET_SR), "-ac", "1",
"-t", str(max_seconds),
"-f", "wav", str(tmp_out)]
r = subprocess.run(cmd, capture_output=True, timeout=30) r = subprocess.run(cmd, capture_output=True, timeout=30)
if r.returncode != 0: if r.returncode != 0:
logger.warning("ffmpeg-Normalisierung von %s fehlgeschlagen: %s", logger.warning("ffmpeg-Konvertierung von %s fehlgeschlagen: %s",
src_wav, r.stderr.decode(errors="replace")[:300]) src_wav, r.stderr.decode(errors="replace")[:200])
try: try:
tmp_out.unlink() tmp_out.unlink()
except OSError: except OSError:
pass pass
return src_wav, False return src_wav
os.replace(tmp_out, src_wav) os.replace(tmp_out, src_wav)
try: return src_wav
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: async def _send(ws, mtype: str, payload: dict) -> None:
@@ -400,10 +312,7 @@ async def request_transcription(ws, wav_path: Path, language: str = "de") -> Opt
"requestId": request_id, "requestId": request_id,
"audio": audio_b64, "audio": audio_b64,
"mimeType": "audio/wav", "mimeType": "audio/wav",
# KEIN hardcoded model — whisper-bridge nimmt das bereits "model": "small", # klein reicht fuer Voice-Referenz
# 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, "language": language,
}) })
return await asyncio.wait_for(fut, timeout=_STT_TIMEOUT_S) return await asyncio.wait_for(fut, timeout=_STT_TIMEOUT_S)
@@ -426,9 +335,9 @@ _tts_queue: asyncio.Queue[tuple] = asyncio.Queue()
async def _tts_worker(ws, runner: F5Runner) -> None: async def _tts_worker(ws, runner: F5Runner) -> None:
"""Serialisiert Synthesen — GPU kann sonst OOM gehen.""" """Serialisiert Synthesen — GPU kann sonst OOM gehen."""
while True: while True:
text, voice, request_id, message_id, language, speed = await _tts_queue.get() text, voice, request_id, message_id, language = await _tts_queue.get()
try: try:
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed) await _do_tts(ws, runner, text, voice, request_id, message_id, language)
except Exception: except Exception:
logger.exception("TTS-Worker Fehler") logger.exception("TTS-Worker Fehler")
finally: finally:
@@ -436,26 +345,10 @@ async def _tts_worker(ws, runner: F5Runner) -> None:
async def _do_tts(ws, runner: F5Runner, text: str, voice: str, async def _do_tts(ws, runner: F5Runner, text: str, voice: str,
request_id: str, message_id: str, language: str, request_id: str, message_id: str, language: str) -> None:
speed: float = 1.0) -> None:
t0 = time.time() t0 = time.time()
ref_wav_path, ref_txt_path = voice_paths(voice) if voice else (None, None) 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 # Legacy-Platzhalter erkennen → behandeln als "kein txt" und neu transkribieren
if voice and ref_txt_path and ref_txt_path.exists(): if voice and ref_txt_path and ref_txt_path.exists():
try: try:
@@ -509,14 +402,13 @@ async def _do_tts(ws, runner: F5Runner, text: str, voice: str,
ref_wav_str, ref_text = str(pair[0]), pair[1].read_text(encoding="utf-8").strip() ref_wav_str, ref_text = str(pair[0]), pair[1].read_text(encoding="utf-8").strip()
sentences = split_sentences(text) sentences = split_sentences(text)
logger.info("F5-TTS: %d Satz(e), voice=%s, speed=%.2fx (%s)", logger.info("F5-TTS: %d Satz(e), voice=%s (%s)", len(sentences), voice or "default", ref_wav_str)
len(sentences), voice or "default", speed, ref_wav_str)
chunk_index = 0 chunk_index = 0
pcm_sr = TARGET_SR pcm_sr = TARGET_SR
for i, sent in enumerate(sentences): for i, sent in enumerate(sentences):
try: try:
wav, sr = await runner.synthesize(sent, ref_wav_str, ref_text, speed) wav, sr = await runner.synthesize(sent, ref_wav_str, ref_text)
pcm_sr = sr pcm_sr = sr
pcm_bytes = float_to_pcm16(wav) pcm_bytes = float_to_pcm16(wav)
# Erste PCM-Chunk des allerersten Satzes bekommt Fade-In (maskiert # Erste PCM-Chunk des allerersten Satzes bekommt Fade-In (maskiert
@@ -599,9 +491,8 @@ async def handle_voice_upload(ws, payload: dict) -> None:
size_kb = wav_path.stat().st_size / 1024 size_kb = wav_path.stat().st_size / 1024
logger.info("Voice WAV gespeichert: %s (%.0fKB)", wav_path, size_kb) logger.info("Voice WAV gespeichert: %s (%.0fKB)", wav_path, size_kb)
# Auf 24kHz mono clippen auf 10s (F5-TTS Hard-Limit ist 12s, # Auf 24kHz mono normalisieren (falls App in anderem Format liefert)
# kuerzer = schnellerer Warmup + Text+Audio bleiben aligned) ensure_24k_mono_wav(wav_path)
normalize_ref_wav(wav_path)
# Transkription ueber whisper-bridge anfragen # Transkription ueber whisper-bridge anfragen
logger.info("Transkribiere '%s' via whisper-bridge...", name) logger.info("Transkribiere '%s' via whisper-bridge...", name)
@@ -722,30 +613,22 @@ async def run_loop(runner: F5Runner) -> None:
tls_fallback_tried = False tls_fallback_tried = False
# Status-Broadcast: erst loading, dann ready nach erfolgreichem Load. # Status-Broadcast: erst loading, dann ready nach erfolgreichem Load.
# Plus: config_request damit wir die persistierte Diagnostic-Config # Modell wird hier (nicht ausserhalb der Schleife) gestartet damit
# bekommen, falls aria-bridge ihre nicht von alleine sendet. # der Loading-Status auch wirklich uebertragen werden kann.
async def _load_with_status(): async def _load_with_status():
if runner.model is not None:
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
return
await _broadcast_status(ws, "loading", model=runner.model_id)
try: try:
if runner.model is not None: await runner.ensure_loaded()
logger.info("Initial: broadcaste ready (Modell schon im RAM: %s)", runner.model_id) await _broadcast_status(ws, "ready",
await _broadcast_status(ws, "ready", model=runner.model_id,
model=runner.model_id, loadSeconds=runner.last_load_seconds)
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: except Exception as e:
logger.exception("Initial-Load crashed: %s", e) await _broadcast_status(ws, "error", error=str(e)[:200])
try:
await _broadcast_status(ws, "error", error=str(e)[:200])
except Exception:
pass
asyncio.create_task(_load_with_status()) asyncio.create_task(_load_with_status())
# TTS-Worker fuer diese Verbindung starten # TTS-Worker fuer diese Verbindung starten
@@ -761,19 +644,12 @@ async def run_loop(runner: F5Runner) -> None:
payload = msg.get("payload", {}) or {} payload = msg.get("payload", {}) or {}
if mtype == "xtts_request": 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(( await _tts_queue.put((
payload.get("text", ""), payload.get("text", ""),
payload.get("voice", "") or "", payload.get("voice", "") or "",
payload.get("requestId", ""), payload.get("requestId", ""),
payload.get("messageId", ""), payload.get("messageId", ""),
payload.get("language", "de"), payload.get("language", "de"),
speed,
)) ))
elif mtype == "voice_upload": elif mtype == "voice_upload":
asyncio.create_task(handle_voice_upload(ws, payload)) asyncio.create_task(handle_voice_upload(ws, payload))
+10 -25
View File
@@ -143,11 +143,7 @@ async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
request_id = payload.get("requestId", "") request_id = payload.get("requestId", "")
audio_b64 = payload.get("audio", "") audio_b64 = payload.get("audio", "")
mime_type = payload.get("mimeType", "audio/mp4") mime_type = payload.get("mimeType", "audio/mp4")
# Modell-Auswahl: model = payload.get("model") or WHISPER_MODEL
# 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 language = payload.get("language") or WHISPER_LANGUAGE
if not audio_b64: if not audio_b64:
@@ -224,26 +220,15 @@ async def run_loop(runner: WhisperRunner) -> None:
retry_s = 2 retry_s = 2
tls_fallback_tried = False tls_fallback_tried = False
# Initialer Status-Broadcast — uebertont alten "ready"-State # KEIN initialer Preload. Der aria-bridge broadcastet kurz nach
# im App/Diagnostic Banner (sonst denkt der User noch alles ist # RVS-Connect die persistierte Config (whisperModel) — wir laden
# gut von vorher). Wenn Modell schon geladen → ready, sonst # erst wenn der drinsteht, sonst wuerde 2x geladen werden
# loading mit aktuellem (Default-)Namen. # (small als ENV-Default + dann das echte Modell).
# Plus: config_request an aria-bridge — wir wissen nicht ob # Wenn ein stt_request schneller kommt als die Config: ensure_loaded
# sie auch grad reconnected hat oder schon laenger online ist. # im Handler greift dann ein und laedt das angeforderte Modell.
async def _initial_handshake(): if runner.model is not None:
try: # Wir sind reconnectet — Modell schon im RAM, einfach 'ready'
if runner.model is not None: asyncio.create_task(_broadcast_status(ws, "ready", model=runner.model_size))
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: async for raw in ws:
try: try: