Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a029267d9d | |||
| 8ba6a71a49 | |||
| 2f625572fc | |||
| ac56916eb0 | |||
| ae08a5051c | |||
| d372cd638e | |||
| 60c5cb7e59 | |||
| 607a4c9ff8 | |||
| 4ea16cfa8f | |||
| 6ce9880bc0 | |||
| 187ffad7ee | |||
| 467f95424e | |||
| c1a5518fb7 | |||
| 22fa4b3ccf | |||
| 1b8a51aad0 | |||
| 578ade3544 |
@@ -34,13 +34,21 @@ ARIA hat zwei Rollen:
|
|||||||
└───────────┬───────────────────────────┬─────────────────┘
|
└───────────┬───────────────────────────┬─────────────────┘
|
||||||
│ WebSocket Tunnel │ WebSocket Tunnel
|
│ WebSocket Tunnel │ WebSocket Tunnel
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌───────────────────────────┐
|
┌─────────────────────────────────┐
|
||||||
│ Gaming-PC (optional) │
|
│ Gamebox (Windows + WSL2) │
|
||||||
│ RTX 3060, Docker+WSL2 │
|
│ RTX 3060, Docker Desktop │
|
||||||
│ XTTS v2 (natuerliche │
|
│ ┌──────────────────────────┐ │
|
||||||
│ Stimmen, Voice Cloning) │
|
│ │ aria-f5tts-bridge │ │
|
||||||
│ xtts/docker-compose.yml │
|
│ │ F5-TTS Voice Cloning │ │
|
||||||
└───────────────────────────┘
|
│ │ PCM-Streaming an die App │ │
|
||||||
|
│ ├──────────────────────────┤ │
|
||||||
|
│ │ aria-whisper-bridge │ │
|
||||||
|
│ │ Faster-Whisper CUDA │ │
|
||||||
|
│ │ STT in fast-Echtzeit │ │
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
│ Beide teilen ./voices Volume │
|
||||||
|
│ xtts/docker-compose.yml │
|
||||||
|
└─────────────────────────────────┘
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ ARIA-VM (Proxmox, Debian 13) — ARIAs Wohnung │
|
│ ARIA-VM (Proxmox, Debian 13) — ARIAs Wohnung │
|
||||||
│ Basissystem + Docker. Rest richtet ARIA selbst ein. │
|
│ Basissystem + Docker. Rest richtet ARIA selbst ein. │
|
||||||
@@ -57,8 +65,10 @@ ARIA hat zwei Rollen:
|
|||||||
│ │ Liest BOOTSTRAP.md + AGENT.md │ │
|
│ │ Liest BOOTSTRAP.md + AGENT.md │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ [bridge] ARIA Voice Bridge Container │ │
|
│ │ [bridge] ARIA Voice Bridge Container │ │
|
||||||
│ │ Whisper STT · Wake-Word │ │
|
│ │ Wake-Word (lokales Mikro auf VM) │ │
|
||||||
│ │ TTS remote via XTTS v2 auf Gaming-PC │ │
|
│ │ STT primaer remote (Gamebox-Whisper) │ │
|
||||||
|
│ │ Fallback: lokales faster-whisper (CPU) │ │
|
||||||
|
│ │ TTS via F5-TTS auf Gamebox │ │
|
||||||
│ │ Bruecke: App <> RVS <> Bridge <> ARIA │ │
|
│ │ Bruecke: App <> RVS <> Bridge <> ARIA │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ [diagnostic] Selbstcheck-UI + Einstellungen │ │
|
│ │ [diagnostic] Selbstcheck-UI + Einstellungen │ │
|
||||||
@@ -79,9 +89,12 @@ ARIA hat zwei Rollen:
|
|||||||
|-----|----|-----|
|
|-----|----|-----|
|
||||||
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
||||||
| ARIA Core | Debian 13 VM | `docker compose up -d && ./aria-setup.sh` |
|
| ARIA Core | Debian 13 VM | `docker compose up -d && ./aria-setup.sh` |
|
||||||
| XTTS v2 (optional) | Gaming-PC (GPU) | `cd xtts && docker compose up -d` |
|
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
|
||||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||||
|
|
||||||
|
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
||||||
|
> langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation — Schritt fuer Schritt
|
## Installation — Schritt fuer Schritt
|
||||||
@@ -147,11 +160,12 @@ in den Proxy gemountet. Die Credentials ueberleben Container-Restarts.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp aria-data/config/aria.env.example aria-data/config/aria.env
|
cp aria-data/config/aria.env.example aria-data/config/aria.env
|
||||||
# Bei Bedarf anpassen (Whisper-Modell, Sprache, Wake-Word)
|
# Bei Bedarf anpassen (Whisper-Modell als Fallback, Sprache, Wake-Word)
|
||||||
```
|
```
|
||||||
|
|
||||||
TTS laeuft ausschliesslich ueber XTTS v2 auf dem Gaming-PC — siehe Abschnitt
|
STT laeuft primaer auf der Gamebox (faster-whisper auf GPU), TTS ausschliesslich
|
||||||
"XTTS v2 — High-Quality TTS" weiter unten.
|
ueber F5-TTS auf der Gamebox — siehe Abschnitt "Gamebox-Stack — F5-TTS + Whisper"
|
||||||
|
weiter unten.
|
||||||
|
|
||||||
### 5. RVS-Token generieren & Container starten
|
### 5. RVS-Token generieren & Container starten
|
||||||
|
|
||||||
@@ -284,25 +298,34 @@ braucht ARIA mehrere API-Roundtrips.
|
|||||||
|
|
||||||
## Voice Bridge
|
## Voice Bridge
|
||||||
|
|
||||||
Die Bridge verbindet die Android App mit ARIA und bietet lokale Sprachverarbeitung.
|
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
|
||||||
|
auf der Gamebox.
|
||||||
|
|
||||||
**Nachrichtenfluss:**
|
**Nachrichtenfluss:**
|
||||||
```
|
```
|
||||||
Text: App → RVS → Bridge → chat.send → aria-core
|
Text: App → RVS → Bridge → chat.send → aria-core
|
||||||
Audio: App → RVS → Bridge → FFmpeg → Whisper STT → chat.send → aria-core
|
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox)
|
||||||
|
→ stt_response → Bridge → chat.send → aria-core
|
||||||
|
Fallback bei Timeout: lokales faster-whisper (CPU)
|
||||||
Datei: App → RVS → Bridge → /shared/uploads/ → chat.send (mit Pfad) → aria-core
|
Datei: App → RVS → Bridge → /shared/uploads/ → chat.send (mit Pfad) → aria-core
|
||||||
|
|
||||||
aria-core → Antwort → Gateway → Diagnostic → RVS → App
|
aria-core → Antwort → Gateway → Diagnostic → RVS → App
|
||||||
→ Bridge → XTTS (PCM-Stream) → RVS → App AudioTrack
|
→ Bridge → xtts_request (RVS) → f5tts-bridge
|
||||||
|
→ audio_pcm Stream → RVS → App AudioTrack
|
||||||
```
|
```
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **STT**: faster-whisper (lokal, offline, 16kHz mono)
|
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper
|
||||||
- **TTS**: XTTS v2 (remote auf Gaming-PC, GPU, Voice Cloning) — Streaming ueber PCM-Chunks
|
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales
|
||||||
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt, Markdown/Code/Einheiten/URLs werden TTS-gerecht aufbereitet
|
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast.
|
||||||
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM)
|
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit
|
||||||
- **App-Audio**: Base64 Audio von App → FFmpeg → Whisper STT → Text an aria-core
|
Referenz-Audio + automatisch transkribiertem Referenz-Text.
|
||||||
|
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
|
||||||
|
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
|
||||||
|
ausgeschrieben (`0,1` → "null komma eins"). Acronyme bis 5 Buchstaben werden
|
||||||
|
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
|
||||||
|
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
|
||||||
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
||||||
|
|
||||||
### Betriebsmodi
|
### Betriebsmodi
|
||||||
@@ -324,14 +347,16 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit aria-core.
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Status-Karten**: Gateway (Handshake), RVS (TLS-Fallback), Proxy (Auth)
|
- **Status-Karten**: Gateway (Handshake), RVS (TLS-Fallback), Proxy (Auth)
|
||||||
|
- **Disk-Voll Banner**: Rotes Overlay wenn die VM-Disk knapp wird, mit copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||||
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS), Vollbild-Modus
|
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS), Vollbild-Modus
|
||||||
- **"ARIA denkt..." Indikator**: Zeigt live was ARIA gerade tut (Denken, Tool, Schreiben)
|
- **"ARIA denkt..." Indikator**: Zeigt live was ARIA gerade tut (Denken, Tool, Schreiben)
|
||||||
- **Abbrechen-Button**: Stoppt laufende Anfragen + doctor --fix
|
- **Abbrechen-Button**: Stoppt laufende Anfragen + doctor --fix
|
||||||
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen, als Markdown exportieren (⬇ Button)
|
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen, als Markdown exportieren (⬇ Button)
|
||||||
- **Chat-History**: Wird beim Laden und Session-Wechsel angezeigt (read-only aus JSONL)
|
- **Chat-History**: Wird beim Laden und Session-Wechsel angezeigt (read-only aus JSONL)
|
||||||
- **TTS-Diagnose Tab**: Stimmen testen, Status pruefen, Fehler anzeigen
|
- **TTS-Diagnose Tab**: Stimmen testen, Status pruefen, Fehler anzeigen
|
||||||
- **Einstellungen**: TTS aktiv-Toggle, XTTS-Voice (gecloned), Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload)
|
- **Einstellungen**: TTS aktiv-Toggle, F5-TTS-Voice (gecloned), Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload auf der Gamebox)
|
||||||
- **XTTS Voice Cloning**: Audio-Samples hochladen, eigene Stimme erstellen
|
- **Voice-Status**: Beim Wechsel der globalen Stimme zeigt ein Status-Text "Lade…" → "bereit (X.Ys)" — getriggert ueber `voice_preload`/`voice_ready`
|
||||||
|
- **Voice Cloning**: Audio-Samples hochladen, Referenz-Text wird automatisch via Whisper transkribiert
|
||||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||||
- **Core Terminal**: Shell in aria-core (openclaw CLI)
|
- **Core Terminal**: Shell in aria-core (openclaw CLI)
|
||||||
- **Container-Logs**: Echtzeit-Logs aller Container (gefiltert nach Tab + Pipeline)
|
- **Container-Logs**: Echtzeit-Logs aller Container (gefiltert nach Tab + Pipeline)
|
||||||
@@ -354,18 +379,21 @@ 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, ohne Buttons druecken
|
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
|
||||||
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
|
- **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 (kein Rauschen an Whisper)
|
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||||
- **STT (Speech-to-Text)**: Audio wird als 16kHz mono aufgenommen und in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
|
- **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
|
||||||
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||||
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher — XTTS v2 PCM-Streaming direkt in AudioTrack, keine Wait-Gaps
|
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
||||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden
|
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
||||||
|
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||||
|
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||||
|
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
||||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||||
- **Einstellungen**: TTS aktiv, XTTS-Voice, Speicherort, Auto-Download, GPS
|
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS
|
||||||
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||||
- GPS-Position (optional)
|
- GPS-Position (optional)
|
||||||
- QR-Code Scanner fuer Token-Pairing
|
- QR-Code Scanner fuer Token-Pairing
|
||||||
@@ -540,7 +568,7 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## XTTS v2 — GPU TTS Server (optional)
|
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
|
||||||
|
|
||||||
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060).
|
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060).
|
||||||
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
|
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
|
||||||
@@ -549,22 +577,27 @@ ueber verschiedene Netze hinweg.
|
|||||||
### Architektur
|
### Architektur
|
||||||
|
|
||||||
```
|
```
|
||||||
Gaming-PC (Windows, RTX 3060, Docker Desktop + WSL2)
|
Gamebox (Windows, RTX 3060, Docker Desktop + WSL2)
|
||||||
├── aria-xtts XTTS v2 GPU Server (Port 8020 intern)
|
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay
|
||||||
└── aria-xtts-bridge RVS-Relay (empfaengt Requests, sendet Audio)
|
│ Hoert auf xtts_request, streamt audio_pcm
|
||||||
└── Beide teilen ./voices/ Volume fuer Voice Cloning
|
├── aria-whisper-bridge faster-whisper auf CUDA (float16)
|
||||||
|
│ Hoert auf stt_request, antwortet mit stt_response
|
||||||
|
└── ./voices/ Geteilt zwischen beiden:
|
||||||
|
{name}.wav — Referenz-Audio (~6-10s)
|
||||||
|
{name}.txt — Referenz-Text (auto via Whisper)
|
||||||
|
|
||||||
↕ RVS (Rechenzentrum, WebSocket Relay)
|
↕ RVS (Rechenzentrum, WebSocket Relay)
|
||||||
|
|
||||||
ARIA-VM
|
ARIA-VM
|
||||||
└── aria-bridge: tts_engine="xtts" → xtts_request via RVS → wartet auf xtts_response
|
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
|
||||||
|
TTS via xtts_request → audio_pcm Stream
|
||||||
```
|
```
|
||||||
|
|
||||||
### Voraussetzungen
|
### Voraussetzungen
|
||||||
|
|
||||||
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
|
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
|
||||||
- NVIDIA Container Toolkit
|
- NVIDIA Container Toolkit
|
||||||
- GPU mit mindestens 4GB VRAM (6GB+ empfohlen)
|
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
|
||||||
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
|
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@@ -574,34 +607,45 @@ cd xtts
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
|
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
# Erster Start laedt ~2GB Model herunter (danach gecacht)
|
# Erster Start laedt die Modelle (Whisper ~1-3GB je nach Groesse, F5-TTS ~1GB)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Wichtig:** Der XTTS-Server laeuft intern auf Port **8020** (nicht 8000).
|
Die Modelle werden in den Volumes `f5tts-models` und `whisper-models` gecacht
|
||||||
Das Model wird im Volume `xtts-models` gecacht und muss nur einmal geladen werden.
|
und muessen nur einmal geladen werden.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Natuerliche Stimmen**: Deutlich bessere Qualitaet als TTS der alten Generation
|
**F5-TTS (Sprachausgabe):**
|
||||||
- **Voice Cloning**: Eigene Stimme mit 6-10s Audio-Sample (~2s Latenz auf RTX 3060)
|
- Hochqualitatives Voice Cloning auf Basis von 6-10s Referenz-Audio
|
||||||
- **Streaming**: PCM-Chunks alle ~170ms → App spielt ohne Warten nahtlos
|
- Renderzeit ~0.3x Realtime auf RTX 3060 (RTF ≈ 0.3)
|
||||||
- **16 Sprachen**: Deutsch, Englisch, Franzoesisch, etc.
|
- Satzweises Streaming, fade-in auf erstem Chunk gegen Warmup-Glitches
|
||||||
|
- Sequentielle Queue gegen GPU-OOM bei parallelen Requests
|
||||||
|
|
||||||
|
**Whisper (Spracherkennung):**
|
||||||
|
- faster-whisper mit CUDA + float16 — fast Echtzeit-Transkription
|
||||||
|
- Modelle: tiny / base / small / medium / large-v3 (Hot-Swap via Diagnostic)
|
||||||
|
- Wird zusaetzlich von der f5tts-bridge intern genutzt um den Referenz-Text
|
||||||
|
beim Voice-Upload automatisch zu erzeugen
|
||||||
|
|
||||||
### TTS-Config
|
### TTS-Config
|
||||||
|
|
||||||
In der Diagnostic unter Einstellungen → Sprachausgabe:
|
In der Diagnostic unter Einstellungen → Sprachausgabe:
|
||||||
- **TTS aktiv**: Global An/Aus
|
- **TTS aktiv**: Global An/Aus
|
||||||
- **XTTS Stimme**: Default oder gecloned (Maia, etc.)
|
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
|
||||||
|
|
||||||
> XTTS ist die einzige Engine — wenn der Gaming-PC offline ist, bleibt ARIA stumm.
|
> F5-TTS ist die einzige Engine — wenn die Gamebox offline ist, bleibt ARIA stumm.
|
||||||
> Chat-Antworten kommen weiter an (nur kein Audio).
|
> Chat-Antworten kommen weiter an (nur kein Audio).
|
||||||
|
|
||||||
### Stimme klonen
|
### Stimme klonen
|
||||||
|
|
||||||
1. "Stimme klonen" → Audio-Dateien hochladen (WAV/MP3, 1-10 Dateien, min. 6-10s gesamt)
|
1. App oder Diagnostic → "Stimme klonen" → Audio-Dateien hochladen
|
||||||
|
(WAV/MP3, 1-10 Dateien, ~6-10s gesamt)
|
||||||
2. Name vergeben → "Stimme erstellen"
|
2. Name vergeben → "Stimme erstellen"
|
||||||
3. "Laden" klicken → neue Stimme in der Auswahl
|
3. f5tts-bridge speichert das WAV, schickt einen `stt_request` an die
|
||||||
4. Stimme auswaehlen → Config wird automatisch gespeichert
|
whisper-bridge, legt die Transkription als `.txt` daneben ab und meldet
|
||||||
|
`xtts_voice_saved` zurueck. Der Toast in der App zeigt "Stimme bereit".
|
||||||
|
4. Stimme auswaehlen → ein Voice-Preload (stiller Mini-Render) waermt die
|
||||||
|
Latents auf, "voice_ready" Toast bestaetigt es.
|
||||||
|
|
||||||
> **Tipp:** Fuer beste Ergebnisse: saubere Aufnahme, eine Stimme, kein Hintergrund,
|
> **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.
|
||||||
@@ -720,6 +764,15 @@ docker exec aria-core ssh aria-wohnung hostname
|
|||||||
- [x] "ARIA denkt..."-Indicator + Abbrechen-Button in App (via Bridge → RVS)
|
- [x] "ARIA denkt..."-Indicator + Abbrechen-Button in App (via Bridge → RVS)
|
||||||
- [x] Whisper-Modell waehlbar in Diagnostic (tiny…large-v3, Hot-Reload)
|
- [x] Whisper-Modell waehlbar in Diagnostic (tiny…large-v3, Hot-Reload)
|
||||||
- [x] App-Aufnahme explizit 16kHz mono (optimal fuer Whisper, kein Resample)
|
- [x] App-Aufnahme explizit 16kHz mono (optimal fuer Whisper, kein Resample)
|
||||||
|
- [x] Streaming TTS Pre-Roll-Buffer + Wartezeit auf playbackHeadPosition (kein Cutoff mid-Satz mehr)
|
||||||
|
- [x] Pre-Roll-Buffer einstellbar in App-Settings
|
||||||
|
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
|
||||||
|
- [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel
|
||||||
|
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit)
|
||||||
|
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
|
||||||
|
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
|
||||||
|
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
|
||||||
|
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||||
|
|
||||||
### Phase 2 — ARIA wird produktiv
|
### Phase 2 — ARIA wird produktiv
|
||||||
|
|
||||||
|
|||||||
@@ -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 504
|
versionCode 506
|
||||||
versionName "0.0.5.4"
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
buildToolsVersion = "34.0.0"
|
buildToolsVersion = "34.0.0"
|
||||||
minSdkVersion = 23
|
// 24 = Android 7.0 (Nougat). Verlangt von Porcupine (Picovoice).
|
||||||
|
// Realistisch eh das Minimum: alles unter 7.0 hat <1% Marktanteil.
|
||||||
|
minSdkVersion = 24
|
||||||
compileSdkVersion = 34
|
compileSdkVersion = 34
|
||||||
targetSdkVersion = 34
|
targetSdkVersion = 34
|
||||||
ndkVersion = "25.1.8937393"
|
ndkVersion = "25.1.8937393"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.5.4",
|
"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",
|
||||||
|
|||||||
@@ -29,7 +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 { RecordingResult } from '../services/audio';
|
import { RecordingResult, loadConvWindowMs } from '../services/audio';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
@@ -108,6 +108,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
const [searchVisible, setSearchVisible] = useState(false);
|
const [searchVisible, setSearchVisible] = useState(false);
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||||
|
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||||
|
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
|
||||||
|
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||||
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
||||||
const [ttsMuted, setTtsMuted] = useState(false);
|
const [ttsMuted, setTtsMuted] = useState(false);
|
||||||
@@ -139,6 +142,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
|
||||||
|
useEffect(() => {
|
||||||
|
wakeWordService.loadFromStorage().catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
setTtsMuted(prev => {
|
setTtsMuted(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -346,6 +354,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit`, ToastAndroid.SHORT);
|
ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit`, ToastAndroid.SHORT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gamebox-Bridges (f5tts/whisper) melden Lade-Status — Banner oben
|
||||||
|
if (message.type === ('service_status' as any)) {
|
||||||
|
const p = message.payload as any;
|
||||||
|
const svc = (p?.service as string) || '';
|
||||||
|
if (!svc) return;
|
||||||
|
setServiceStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[svc]: {
|
||||||
|
state: (p?.state as string) || 'unknown',
|
||||||
|
model: p?.model as string | undefined,
|
||||||
|
loadSeconds: p?.loadSeconds as number | undefined,
|
||||||
|
error: p?.error as string | undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// Bei neuer Loading-Phase Banner wieder aktivieren
|
||||||
|
if (p?.state === 'loading') setServiceBannerDismissed(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubState = rvs.onStateChange((state) => {
|
const unsubState = rvs.onStateChange((state) => {
|
||||||
@@ -385,10 +411,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubWake = wakeWordService.onWakeWord(async () => {
|
const unsubWake = wakeWordService.onWakeWord(async () => {
|
||||||
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
|
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
|
||||||
// Aufnahme mit Auto-Stop (VAD) starten
|
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
|
||||||
const started = await audioService.startRecording(true);
|
const windowMs = await loadConvWindowMs();
|
||||||
|
const started = await audioService.startRecording(true, windowMs);
|
||||||
if (!started) {
|
if (!started) {
|
||||||
// Mikrofon nicht verfuegbar, Wake Word wieder aktivieren
|
// Mikrofon nicht verfuegbar, naechsten Versuch
|
||||||
wakeWordService.resume();
|
wakeWordService.resume();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -397,7 +424,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
const unsubSilence = audioService.onSilenceDetected(async () => {
|
||||||
const result = await audioService.stopRecording();
|
const result = await audioService.stopRecording();
|
||||||
if (result && result.durationMs > 500) {
|
if (result && result.durationMs > 500) {
|
||||||
// Sprachnachricht senden (gleiche Logik wie handleVoiceRecording)
|
// User hat im Fenster gesprochen → Sprachnachricht senden
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -414,9 +441,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
...(location && { location }),
|
...(location && { location }),
|
||||||
});
|
});
|
||||||
|
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
|
||||||
|
} else {
|
||||||
|
// Kein Speech im Window → Konversation beenden (Ohr geht aus oder
|
||||||
|
// bleibt armed wenn Wake Word verfuegbar)
|
||||||
|
wakeWordService.endConversation();
|
||||||
|
// UI-State synchron halten
|
||||||
|
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||||
}
|
}
|
||||||
// Wake Word wieder aktivieren
|
|
||||||
if (wakeWordActive) wakeWordService.resume();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -753,6 +785,49 @@ const ChatScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) */}
|
||||||
|
{(() => {
|
||||||
|
const entries = Object.entries(serviceStatus);
|
||||||
|
if (entries.length === 0 || serviceBannerDismissed) return null;
|
||||||
|
const anyLoading = entries.some(([, v]) => v.state === 'loading');
|
||||||
|
const anyError = entries.some(([, v]) => v.state === 'error');
|
||||||
|
const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready');
|
||||||
|
const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A';
|
||||||
|
const border = anyError ? '#FF3B30' : anyLoading ? '#FFD60A' : '#34C759';
|
||||||
|
const labels: Record<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT' };
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={allReady ? 0.6 : 1.0}
|
||||||
|
onPress={() => { if (allReady) setServiceBannerDismissed(true); }}
|
||||||
|
style={[styles.serviceBanner, { backgroundColor: bg, borderColor: border }]}
|
||||||
|
>
|
||||||
|
{entries.map(([svc, info]) => {
|
||||||
|
let icon = '\u23F3', text = '';
|
||||||
|
if (info.state === 'loading') {
|
||||||
|
text = `${labels[svc] || svc}: laedt${info.model ? ' ' + info.model : ''}...`;
|
||||||
|
} else if (info.state === 'ready') {
|
||||||
|
icon = '\u2705';
|
||||||
|
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
|
||||||
|
text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}`;
|
||||||
|
} else if (info.state === 'error') {
|
||||||
|
icon = '\u274C';
|
||||||
|
text = `${labels[svc] || svc}: Fehler ${info.error || ''}`;
|
||||||
|
} else {
|
||||||
|
text = `${labels[svc] || svc}: ${info.state}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text key={svc} style={styles.serviceBannerLine}>
|
||||||
|
{icon} {text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Text style={styles.serviceBannerHint}>
|
||||||
|
{allReady ? 'Tippen zum Schliessen' : 'Bitte warten...'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Suchleiste */}
|
{/* Suchleiste */}
|
||||||
{searchVisible && (
|
{searchVisible && (
|
||||||
<View style={styles.searchBar}>
|
<View style={styles.searchBar}>
|
||||||
@@ -967,6 +1042,25 @@ const styles = StyleSheet.create({
|
|||||||
color: '#8888AA',
|
color: '#8888AA',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
|
serviceBanner: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderRightWidth: 0,
|
||||||
|
},
|
||||||
|
serviceBannerLine: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
serviceBannerHint: {
|
||||||
|
color: '#AAAACC',
|
||||||
|
fontSize: 10,
|
||||||
|
marginTop: 2,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
messageList: {
|
messageList: {
|
||||||
padding: 12,
|
padding: 12,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
|
|||||||
@@ -31,7 +31,17 @@ import {
|
|||||||
VAD_SILENCE_MIN_SEC,
|
VAD_SILENCE_MIN_SEC,
|
||||||
VAD_SILENCE_MAX_SEC,
|
VAD_SILENCE_MAX_SEC,
|
||||||
VAD_SILENCE_STORAGE_KEY,
|
VAD_SILENCE_STORAGE_KEY,
|
||||||
|
CONV_WINDOW_DEFAULT_SEC,
|
||||||
|
CONV_WINDOW_MIN_SEC,
|
||||||
|
CONV_WINDOW_MAX_SEC,
|
||||||
|
CONV_WINDOW_STORAGE_KEY,
|
||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
|
import wakeWordService, {
|
||||||
|
BUILTIN_KEYWORDS,
|
||||||
|
DEFAULT_KEYWORD,
|
||||||
|
WAKE_ACCESS_KEY_STORAGE,
|
||||||
|
WAKE_KEYWORD_STORAGE,
|
||||||
|
} from '../services/wakeword';
|
||||||
import ModeSelector from '../components/ModeSelector';
|
import ModeSelector from '../components/ModeSelector';
|
||||||
import QRScanner from '../components/QRScanner';
|
import QRScanner from '../components/QRScanner';
|
||||||
import VoiceCloneModal from '../components/VoiceCloneModal';
|
import VoiceCloneModal from '../components/VoiceCloneModal';
|
||||||
@@ -87,6 +97,11 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||||
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 [wakeAccessKey, setWakeAccessKey] = useState<string>('');
|
||||||
|
const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false);
|
||||||
|
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||||
|
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||||
const [editingPath, setEditingPath] = useState(false);
|
const [editingPath, setEditingPath] = useState(false);
|
||||||
const [xttsVoice, setXttsVoice] = useState('');
|
const [xttsVoice, setXttsVoice] = useState('');
|
||||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||||
@@ -130,6 +145,20 @@ const SettingsScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
|
||||||
|
if (saved != null) {
|
||||||
|
const n = parseFloat(saved);
|
||||||
|
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||||
|
setConvWindowSec(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE).then(saved => {
|
||||||
|
if (saved) setWakeAccessKey(saved);
|
||||||
|
});
|
||||||
|
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(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);
|
||||||
});
|
});
|
||||||
@@ -603,6 +632,117 @@ 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}]}>Konversations-Fenster</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
|
||||||
|
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
|
||||||
|
Sprichst du nichts → Mikrofon zu.
|
||||||
|
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.prerollRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.prerollButton}
|
||||||
|
onPress={() => {
|
||||||
|
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
|
||||||
|
setConvWindowSec(next);
|
||||||
|
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||||
|
}}
|
||||||
|
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
|
||||||
|
>
|
||||||
|
<Text style={styles.prerollButtonText}>−1</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.prerollButton}
|
||||||
|
onPress={() => {
|
||||||
|
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
|
||||||
|
setConvWindowSec(next);
|
||||||
|
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||||
|
}}
|
||||||
|
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
|
||||||
|
>
|
||||||
|
<Text style={styles.prerollButtonText}>+1</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === Wake-Word (geraetelokal) === */}
|
||||||
|
<Text style={styles.sectionTitle}>Wake-Word</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Wenn ein Picovoice-Access-Key eingetragen ist, hoert die App passiv
|
||||||
|
auf das gewaehlte Wake-Word — du kannst dich mit anderen unterhalten,
|
||||||
|
Musik laufen lassen und mit "{wakeKeyword}" eine Konversation mit
|
||||||
|
ARIA starten. Ohne Key oder bei Fehlschlag startet das Ohr direkt
|
||||||
|
eine Konversation (klassischer Modus).
|
||||||
|
</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.toggleHint}>
|
||||||
|
Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter
|
||||||
|
ueber Diagnostic-Upload.
|
||||||
|
</Text>
|
||||||
|
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
|
||||||
|
{BUILTIN_KEYWORDS.map(kw => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={kw}
|
||||||
|
style={[
|
||||||
|
styles.keywordChip,
|
||||||
|
wakeKeyword === kw && styles.keywordChipActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setWakeKeyword(kw)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.keywordChipText,
|
||||||
|
wakeKeyword === kw && styles.keywordChipTextActive,
|
||||||
|
]}>
|
||||||
|
{kw}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.connectButton, {flex: 1}]}
|
||||||
|
onPress={async () => {
|
||||||
|
setWakeStatus('Initialisiere...');
|
||||||
|
try {
|
||||||
|
const ok = await wakeWordService.configure(wakeAccessKey, wakeKeyword);
|
||||||
|
setWakeStatus(ok ? `✅ "${wakeKeyword}" bereit` : '❌ Fehlgeschlagen — Access Key pruefen');
|
||||||
|
} catch (err: any) {
|
||||||
|
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
|
||||||
|
}
|
||||||
|
setTimeout(() => setWakeStatus(''), 5000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.connectButtonText}>Speichern + Aktivieren</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{!!wakeStatus && (
|
||||||
|
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* === Sprachausgabe (geraetelokal) === */}
|
{/* === Sprachausgabe (geraetelokal) === */}
|
||||||
@@ -667,23 +807,13 @@ const SettingsScreen: React.FC = () => {
|
|||||||
<View style={{marginTop: 20}}>
|
<View style={{marginTop: 20}}>
|
||||||
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Eigene Wahl fuer dieses Geraet. Ohne Auswahl gilt der Diagnostic-Default.
|
Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz —
|
||||||
|
ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Default-Option */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.voiceRow, xttsVoice === '' && styles.voiceRowActive]}
|
|
||||||
onPress={() => selectVoice('')}
|
|
||||||
>
|
|
||||||
<Text style={[styles.voiceRowName, xttsVoice === '' && styles.voiceRowNameActive]}>
|
|
||||||
Standard (Diagnostic-Default)
|
|
||||||
</Text>
|
|
||||||
{xttsVoice === '' && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{availableVoices.length === 0 ? (
|
{availableVoices.length === 0 ? (
|
||||||
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
|
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
|
||||||
Keine eigenen Stimmen auf dem XTTS-Server.
|
Keine geklonten Stimmen vorhanden — unten "Eigene Stimme aufnehmen".
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
availableVoices.map(v => (
|
availableVoices.map(v => (
|
||||||
@@ -1285,6 +1415,28 @@ const styles = StyleSheet.create({
|
|||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
keywordChip: {
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#2A2A3E',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
keywordChipActive: {
|
||||||
|
backgroundColor: '#0096FF',
|
||||||
|
borderColor: '#0096FF',
|
||||||
|
},
|
||||||
|
keywordChipText: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
keywordChipTextActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default SettingsScreen;
|
export default SettingsScreen;
|
||||||
|
|||||||
@@ -84,6 +84,27 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
|
|||||||
export const VAD_SILENCE_MAX_SEC = 8.0;
|
export const VAD_SILENCE_MAX_SEC = 8.0;
|
||||||
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
||||||
|
|
||||||
|
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
|
||||||
|
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
|
||||||
|
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
|
||||||
|
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
|
||||||
|
export const CONV_WINDOW_MIN_SEC = 3.0;
|
||||||
|
export const CONV_WINDOW_MAX_SEC = 20.0;
|
||||||
|
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
|
||||||
|
|
||||||
|
export async function loadConvWindowMs(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
|
||||||
|
if (raw != null) {
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||||
|
return Math.round(n * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVadSilenceMs(): Promise<number> {
|
async function loadVadSilenceMs(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
|
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
|
||||||
@@ -157,6 +178,7 @@ class AudioService {
|
|||||||
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;
|
||||||
|
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.recorder = new AudioRecorderPlayer();
|
this.recorder = new AudioRecorderPlayer();
|
||||||
@@ -189,8 +211,16 @@ class AudioService {
|
|||||||
|
|
||||||
// --- Aufnahme ---
|
// --- Aufnahme ---
|
||||||
|
|
||||||
/** Mikrofon-Aufnahme starten */
|
/** Mikrofon-Aufnahme starten.
|
||||||
async startRecording(autoStop: boolean = false): Promise<boolean> {
|
*
|
||||||
|
* @param autoStop VAD aktivieren — Auto-Stop bei Stille
|
||||||
|
* @param noSpeechTimeoutMs Wenn der User innerhalb dieser Zeit nichts sagt,
|
||||||
|
* wird Stille gemeldet (Recording wird verworfen).
|
||||||
|
* Fuer Conversation-Window: nach ARIA's Antwort
|
||||||
|
* hast du nur N Sekunden um anzufangen, sonst
|
||||||
|
* Gespraech zu Ende.
|
||||||
|
*/
|
||||||
|
async startRecording(autoStop: boolean = false, noSpeechTimeoutMs: number = 0): Promise<boolean> {
|
||||||
if (this.recordingState !== 'idle') {
|
if (this.recordingState !== 'idle') {
|
||||||
console.warn('[Audio] Aufnahme laeuft bereits');
|
console.warn('[Audio] Aufnahme laeuft bereits');
|
||||||
return false;
|
return false;
|
||||||
@@ -276,6 +306,18 @@ class AudioService {
|
|||||||
}, MAX_RECORDING_MS);
|
}, MAX_RECORDING_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
|
||||||
|
// anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie),
|
||||||
|
// ChatScreen erkennt das und beendet die Konversation.
|
||||||
|
if (noSpeechTimeoutMs > 0) {
|
||||||
|
this.noSpeechTimer = setTimeout(() => {
|
||||||
|
if (!this.speechDetected && this.recordingState === 'recording') {
|
||||||
|
console.log(`[Audio] Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache — Stop`);
|
||||||
|
this.silenceListeners.forEach(cb => cb());
|
||||||
|
}
|
||||||
|
}, noSpeechTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -302,6 +344,10 @@ class AudioService {
|
|||||||
clearTimeout(this.maxDurationTimer);
|
clearTimeout(this.maxDurationTimer);
|
||||||
this.maxDurationTimer = null;
|
this.maxDurationTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.noSpeechTimer) {
|
||||||
|
clearTimeout(this.noSpeechTimer);
|
||||||
|
this.noSpeechTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.recorder.stopRecorder();
|
await this.recorder.stopRecorder();
|
||||||
|
|||||||
@@ -1,56 +1,218 @@
|
|||||||
/**
|
/**
|
||||||
* Gespraechsmodus — "Ohr-Button"
|
* Gespraechsmodus / Wake Word Service
|
||||||
*
|
*
|
||||||
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
|
* Drei Zustaende:
|
||||||
* Wie ein Walkie-Talkie / natuerliches Gespraech:
|
* off — Ohr aus, nichts laeuft
|
||||||
* ARIA spricht → Aufnahme startet → User spricht → VAD stoppt → ARIA antwortet → ...
|
* armed — Ohr aktiv, Porcupine hoert passiv auf das Wake-Word.
|
||||||
|
* Das Mikro ist von Porcupine belegt; AudioRecorder ist aus.
|
||||||
|
* conversing — Wake-Word getriggert (oder Ohr-Tap ohne Wake-Word):
|
||||||
|
* aktive Konversation. Porcupine pausiert (gibt Mikro frei),
|
||||||
|
* AudioRecorder uebernimmt fuer die Aufnahme.
|
||||||
|
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
|
||||||
|
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
|
||||||
*
|
*
|
||||||
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
|
* Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start'
|
||||||
|
* direkt in 'conversing' (klassischer Gespraechsmodus). 'endConversation'
|
||||||
|
* geht dann nach 'off' statt 'armed'.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
type WakeWordCallback = () => void;
|
type WakeWordCallback = () => void;
|
||||||
type StateCallback = (state: WakeWordState) => void;
|
type StateCallback = (state: WakeWordState) => void;
|
||||||
|
|
||||||
export type WakeWordState = 'off' | 'listening' | 'detected';
|
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
||||||
|
|
||||||
|
export const WAKE_ACCESS_KEY_STORAGE = 'aria_wake_access_key';
|
||||||
|
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||||
|
|
||||||
|
/** Built-In Keywords von Picovoice — pre-trained, sofort einsetzbar.
|
||||||
|
* Custom Keywords (z.B. "ARIA") brauchen ein .ppn File aus der Picovoice
|
||||||
|
* Console — wird spaeter ueber Diagnostic uploadbar. */
|
||||||
|
export const BUILTIN_KEYWORDS = [
|
||||||
|
'jarvis',
|
||||||
|
'computer',
|
||||||
|
'picovoice',
|
||||||
|
'porcupine',
|
||||||
|
'bumblebee',
|
||||||
|
'terminator',
|
||||||
|
'alexa',
|
||||||
|
'hey google',
|
||||||
|
'ok google',
|
||||||
|
'hey siri',
|
||||||
|
] as const;
|
||||||
|
export type BuiltinKeyword = typeof BUILTIN_KEYWORDS[number];
|
||||||
|
export const DEFAULT_KEYWORD: BuiltinKeyword = 'jarvis';
|
||||||
|
|
||||||
class WakeWordService {
|
class WakeWordService {
|
||||||
private state: WakeWordState = 'off';
|
private state: WakeWordState = 'off';
|
||||||
private wakeCallbacks: WakeWordCallback[] = [];
|
private wakeCallbacks: WakeWordCallback[] = [];
|
||||||
private stateCallbacks: StateCallback[] = [];
|
private stateCallbacks: StateCallback[] = [];
|
||||||
|
|
||||||
/** Gespraechsmodus starten */
|
// Picovoice Manager (lazy, da Native Module nicht in jedem Build verfuegbar ist)
|
||||||
|
private porcupine: any = null;
|
||||||
|
private accessKey: string = '';
|
||||||
|
private keyword: string = DEFAULT_KEYWORD;
|
||||||
|
private initInProgress: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
/** Beim App-Start aufrufen — laedt Settings, baut Porcupine wenn Key da ist. */
|
||||||
|
async loadFromStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const k = await AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE);
|
||||||
|
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
|
||||||
|
this.accessKey = (k || '').trim();
|
||||||
|
this.keyword = (w || DEFAULT_KEYWORD).trim();
|
||||||
|
if (this.accessKey) {
|
||||||
|
// Vorinitialisieren — wirft sich nicht durch wenn etwas fehlt
|
||||||
|
await this.initPorcupine();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WakeWord] loadFromStorage', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings-Wechsel — neuer Key oder Keyword. Re-Init Porcupine. */
|
||||||
|
async configure(accessKey: string, keyword: string): Promise<boolean> {
|
||||||
|
this.accessKey = (accessKey || '').trim();
|
||||||
|
this.keyword = (keyword || DEFAULT_KEYWORD).trim();
|
||||||
|
await AsyncStorage.setItem(WAKE_ACCESS_KEY_STORAGE, this.accessKey);
|
||||||
|
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, this.keyword);
|
||||||
|
|
||||||
|
// Laufende Instanz stoppen
|
||||||
|
await this.disposePorcupine();
|
||||||
|
if (!this.accessKey) return false;
|
||||||
|
|
||||||
|
// Neu initialisieren
|
||||||
|
return this.initPorcupine();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initPorcupine(): Promise<boolean> {
|
||||||
|
if (this.initInProgress) return this.initInProgress;
|
||||||
|
this.initInProgress = (async () => {
|
||||||
|
try {
|
||||||
|
const { PorcupineManager } = require('@picovoice/porcupine-react-native');
|
||||||
|
// Built-In Keyword-Identifier sind lower-case strings im SDK
|
||||||
|
this.porcupine = await PorcupineManager.fromBuiltInKeywords(
|
||||||
|
this.accessKey,
|
||||||
|
[this.keyword],
|
||||||
|
(_keywordIndex: number) => this.onWakeDetected(),
|
||||||
|
);
|
||||||
|
console.log('[WakeWord] Porcupine init OK (keyword=%s)', this.keyword);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err);
|
||||||
|
this.porcupine = null;
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.initInProgress = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.initInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disposePorcupine() {
|
||||||
|
if (this.porcupine) {
|
||||||
|
try { await this.porcupine.stop(); } catch {}
|
||||||
|
try { await this.porcupine.delete(); } catch {}
|
||||||
|
this.porcupine = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
||||||
async start(): Promise<boolean> {
|
async start(): Promise<boolean> {
|
||||||
if (this.state === 'listening') return true;
|
if (this.state !== 'off') return true;
|
||||||
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
|
if (this.porcupine) {
|
||||||
this.setState('listening');
|
// Passives Lauschen via Porcupine
|
||||||
// Sofort erste Aufnahme starten
|
try {
|
||||||
|
await this.porcupine.start();
|
||||||
|
console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword);
|
||||||
|
this.setState('armed');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: direkt in die Konversation
|
||||||
|
console.log('[WakeWord] Konversation startet sofort (kein Wake-Word)');
|
||||||
|
this.setState('conversing');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.state === 'listening') {
|
if (this.state === 'conversing') {
|
||||||
this.wakeCallbacks.forEach(cb => cb());
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gespraechsmodus stoppen */
|
/** Komplett ausschalten (Ohr abschalten) */
|
||||||
stop(): void {
|
async stop(): Promise<void> {
|
||||||
console.log('[WakeWord] Gespraechsmodus deaktiviert');
|
console.log('[WakeWord] Ohr deaktiviert');
|
||||||
|
if (this.porcupine) {
|
||||||
|
try { await this.porcupine.stop(); } catch {}
|
||||||
|
}
|
||||||
this.setState('off');
|
this.setState('off');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
|
/** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */
|
||||||
|
private async onWakeDetected(): Promise<void> {
|
||||||
|
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
|
||||||
|
if (this.porcupine) {
|
||||||
|
try { await this.porcupine.stop(); } catch {}
|
||||||
|
}
|
||||||
|
this.setState('conversing');
|
||||||
|
// kurz warten damit Mikrofon frei ist
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.state === 'conversing') {
|
||||||
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Konversation beenden — User hat im Window nichts gesagt.
|
||||||
|
* Mit Wake-Word: zurueck zu 'armed' (Porcupine wieder an).
|
||||||
|
* Ohne: zurueck zu 'off'.
|
||||||
|
*/
|
||||||
|
async endConversation(): Promise<void> {
|
||||||
|
if (this.state !== 'conversing') return;
|
||||||
|
if (this.porcupine && this.accessKey) {
|
||||||
|
try {
|
||||||
|
await this.porcupine.start();
|
||||||
|
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
|
||||||
|
this.setState('armed');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
|
||||||
|
this.setState('off');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
if (this.state !== 'listening') return;
|
if (this.state !== 'conversing') return;
|
||||||
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
if (this.state === 'listening') {
|
if (this.state === 'conversing') {
|
||||||
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
|
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
|
||||||
this.wakeCallbacks.forEach(cb => cb());
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True solange das Ohr aktiv ist (armed ODER conversing). */
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.state === 'listening';
|
return this.state !== 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
isConversing(): boolean {
|
||||||
|
return this.state === 'conversing';
|
||||||
|
}
|
||||||
|
|
||||||
|
hasWakeWord(): boolean {
|
||||||
|
return !!this.porcupine;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyword(): string {
|
||||||
|
return this.keyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Callbacks ---
|
// --- Callbacks ---
|
||||||
|
|||||||
+53
-5
@@ -496,6 +496,7 @@ class ARIABridge:
|
|||||||
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
|
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
|
||||||
self.tts_enabled = True
|
self.tts_enabled = True
|
||||||
self.xtts_voice = ""
|
self.xtts_voice = ""
|
||||||
|
self._f5tts_config: dict = {}
|
||||||
vc: dict = {}
|
vc: dict = {}
|
||||||
# Gespeicherte Voice-Config laden
|
# Gespeicherte Voice-Config laden
|
||||||
try:
|
try:
|
||||||
@@ -505,7 +506,16 @@ class ARIABridge:
|
|||||||
vc = json.load(f)
|
vc = json.load(f)
|
||||||
self.tts_enabled = vc.get("ttsEnabled", True)
|
self.tts_enabled = vc.get("ttsEnabled", True)
|
||||||
self.xtts_voice = vc.get("xttsVoice", "")
|
self.xtts_voice = vc.get("xttsVoice", "")
|
||||||
logger.info("Voice-Config geladen: tts=%s voice=%s", self.tts_enabled, self.xtts_voice or "default")
|
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
||||||
|
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
|
||||||
|
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
|
||||||
|
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
|
||||||
|
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
||||||
|
if k in vc:
|
||||||
|
self._f5tts_config[k] = vc[k]
|
||||||
|
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s",
|
||||||
|
self.tts_enabled, self.xtts_voice or "default",
|
||||||
|
self._f5tts_config or "defaults")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
||||||
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
||||||
@@ -963,6 +973,29 @@ class ARIABridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("[mode] Broadcast fehlgeschlagen: %s", e)
|
logger.debug("[mode] Broadcast fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
|
async def _broadcast_persisted_config(self) -> None:
|
||||||
|
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
|
||||||
|
|
||||||
|
Damit bekommen frisch verbundene Bridges (insbesondere die f5tts-bridge
|
||||||
|
auf der Gamebox nach Container-Restart) die zuletzt in Diagnostic
|
||||||
|
gewaehlten Settings — ohne dass der User in Diagnostic was klicken muss.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"ttsEnabled": getattr(self, "tts_enabled", True),
|
||||||
|
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||||
|
"whisperModel": self.stt_engine.model_size,
|
||||||
|
}
|
||||||
|
payload.update(getattr(self, "_f5tts_config", {}) or {})
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "config",
|
||||||
|
"payload": payload,
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
logger.info("[rvs] Persistierte Config broadcastet: %s", payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
def _fetch_active_session(self) -> None:
|
def _fetch_active_session(self) -> None:
|
||||||
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
||||||
try:
|
try:
|
||||||
@@ -1032,6 +1065,12 @@ class ARIABridge:
|
|||||||
# ihren UI-State sofort syncen koennen
|
# ihren UI-State sofort syncen koennen
|
||||||
await self._broadcast_current_mode()
|
await self._broadcast_current_mode()
|
||||||
|
|
||||||
|
# Persistierte Voice-Config broadcasten — die f5tts-bridge auf
|
||||||
|
# der Gamebox bekommt damit nach Restart die zuletzt in
|
||||||
|
# Diagnostic gewaehlten Settings wieder (sonst stuende sie auf
|
||||||
|
# ihren Hard-Defaults).
|
||||||
|
asyncio.create_task(self._broadcast_persisted_config())
|
||||||
|
|
||||||
# Heartbeat senden (RVS erwartet Ping alle 30s)
|
# Heartbeat senden (RVS erwartet Ping alle 30s)
|
||||||
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
|
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
|
||||||
|
|
||||||
@@ -1195,7 +1234,10 @@ class ARIABridge:
|
|||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "config":
|
elif msg_type == "config":
|
||||||
# Konfiguration von App/Diagnostic empfangen + persistent speichern
|
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
|
||||||
|
# Felder die nicht direkt zur aria-bridge gehoeren (f5tts*) werden
|
||||||
|
# nur persistiert; die f5tts-bridge auf der Gamebox empfaengt den
|
||||||
|
# gleichen RVS-Broadcast und reagiert selber.
|
||||||
changed = False
|
changed = False
|
||||||
if "ttsEnabled" in payload:
|
if "ttsEnabled" in payload:
|
||||||
self.tts_enabled = bool(payload["ttsEnabled"])
|
self.tts_enabled = bool(payload["ttsEnabled"])
|
||||||
@@ -1209,14 +1251,19 @@ class ARIABridge:
|
|||||||
new_model = payload["whisperModel"]
|
new_model = payload["whisperModel"]
|
||||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||||
if new_model in allowed and new_model != self.stt_engine.model_size:
|
if new_model in allowed and new_model != self.stt_engine.model_size:
|
||||||
# Merken und mitschicken an whisper-bridge (Gamebox).
|
|
||||||
# Lokales Modell wird NICHT geladen — nur das Fallback braucht's,
|
|
||||||
# und das passiert erst on-demand wenn Remote nicht antwortet.
|
|
||||||
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt Gamebox)",
|
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt Gamebox)",
|
||||||
new_model)
|
new_model)
|
||||||
self.stt_engine.model_size = new_model
|
self.stt_engine.model_size = new_model
|
||||||
self.stt_engine.model = None
|
self.stt_engine.model = None
|
||||||
changed = True
|
changed = True
|
||||||
|
# F5-TTS-Felder: einfach persistieren, f5tts-bridge applied selber.
|
||||||
|
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
|
||||||
|
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
||||||
|
if k in payload:
|
||||||
|
if not hasattr(self, "_f5tts_config"):
|
||||||
|
self._f5tts_config = {}
|
||||||
|
self._f5tts_config[k] = payload[k]
|
||||||
|
changed = True
|
||||||
# Persistent speichern in Shared Volume
|
# Persistent speichern in Shared Volume
|
||||||
if changed:
|
if changed:
|
||||||
try:
|
try:
|
||||||
@@ -1226,6 +1273,7 @@ class ARIABridge:
|
|||||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||||
"whisperModel": self.stt_engine.model_size,
|
"whisperModel": self.stt_engine.model_size,
|
||||||
}
|
}
|
||||||
|
config_data.update(getattr(self, "_f5tts_config", {}))
|
||||||
with open("/shared/config/voice_config.json", "w") as f:
|
with open("/shared/config/voice_config.json", "w") as f:
|
||||||
json.dump(config_data, f, indent=2)
|
json.dump(config_data, f, indent=2)
|
||||||
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
|
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
REM ================================================================
|
||||||
|
REM ARIA - Cleanup-Wrapper fuer Windows
|
||||||
|
REM ================================================================
|
||||||
|
REM Ruft cleanup-windows.ps1 mit ExecutionPolicy Bypass auf.
|
||||||
|
REM Funktioniert auch wenn Windows .ps1 direkt nicht startet.
|
||||||
|
REM
|
||||||
|
REM Nutzung:
|
||||||
|
REM cleanup-windows.bat stefan
|
||||||
|
REM cleanup-windows.bat stefan -SkipPrune
|
||||||
|
REM
|
||||||
|
REM Doppelklick funktioniert NICHT (braucht Username als Param).
|
||||||
|
REM Per Konsole aufrufen.
|
||||||
|
REM ================================================================
|
||||||
|
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0cleanup-windows.ps1" %*
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# ================================================================
|
||||||
|
# ARIA - Windows / WSL2 / Docker Desktop VHDX Cleanup
|
||||||
|
# ================================================================
|
||||||
|
#
|
||||||
|
# Findet alle WSL2 + Docker Desktop ext4.vhdx Files unter
|
||||||
|
# C:\Users\<USER>\AppData\Local\... und kompaktiert sie via diskpart.
|
||||||
|
# Damit bekommst du Speicherplatz zurueck den du IN den Distros/
|
||||||
|
# Containern geloescht hast (z.B. nach `docker system prune`),
|
||||||
|
# der aber von der VHDX bisher nicht freigegeben wurde.
|
||||||
|
#
|
||||||
|
# Nutzung (PowerShell als ADMIN, oder via cleanup-windows.bat):
|
||||||
|
# .\cleanup-windows.ps1 stefan
|
||||||
|
# .\cleanup-windows.ps1 -User stefan
|
||||||
|
# .\cleanup-windows.ps1 -User stefan -SkipPrune # nur compacten
|
||||||
|
# .\cleanup-windows.ps1 -User stefan -PruneOnly # nur prune
|
||||||
|
#
|
||||||
|
# Was passiert:
|
||||||
|
# 1. Erst (optional): docker system prune + builder prune in WSL2
|
||||||
|
# 2. wsl --shutdown
|
||||||
|
# 3. Alle gefundenen .vhdx Files mit diskpart compact vdisk shrinken
|
||||||
|
#
|
||||||
|
# Hinweis: diskpart braucht KEINE Hyper-V Tools (anders als Optimize-VHD).
|
||||||
|
#
|
||||||
|
# ASCII-only damit Windows-PowerShell 5.1 das File ohne BOM korrekt
|
||||||
|
# parsen kann (UTF-8-Sonderzeichen wuerden sonst als Windows-1252
|
||||||
|
# fehlinterpretiert).
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true, Position=0,
|
||||||
|
HelpMessage="Dein Windows-Benutzername (z.B. stefan)")]
|
||||||
|
[string]$User,
|
||||||
|
|
||||||
|
[Parameter(HelpMessage="Docker prune ueberspringen - nur compacten")]
|
||||||
|
[switch]$SkipPrune,
|
||||||
|
|
||||||
|
[Parameter(HelpMessage="Docker prune NUR machen, dann beenden")]
|
||||||
|
[switch]$PruneOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defensive: Process-Scope ExecutionPolicy auf Bypass - verhindert dass
|
||||||
|
# Untersaetze (z.B. Module) blockiert werden. Harmless wenn Parent schon
|
||||||
|
# Bypass aufgerufen hat.
|
||||||
|
try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force | Out-Null } catch {}
|
||||||
|
|
||||||
|
# Admin-Check + Self-Elevation
|
||||||
|
# Wenn nicht als Admin gestartet -> einmal neu starten als Admin, mit
|
||||||
|
# ExecutionPolicy Bypass + den Original-Argumenten. User muss nur einmal
|
||||||
|
# UAC-Prompt bestaetigen.
|
||||||
|
$isAdmin = ([Security.Principal.WindowsPrincipal] `
|
||||||
|
[Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||||
|
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
if (-not $isAdmin) {
|
||||||
|
Write-Host "-> Starte neu als Administrator (mit ExecutionPolicy Bypass)..." -ForegroundColor Yellow
|
||||||
|
$myPath = $MyInvocation.MyCommand.Path
|
||||||
|
$forwardArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$myPath`"")
|
||||||
|
if ($User) { $forwardArgs += @("-User", $User) }
|
||||||
|
if ($SkipPrune) { $forwardArgs += "-SkipPrune" }
|
||||||
|
if ($PruneOnly) { $forwardArgs += "-PruneOnly" }
|
||||||
|
try {
|
||||||
|
Start-Process powershell.exe -Verb RunAs -ArgumentList $forwardArgs
|
||||||
|
} catch {
|
||||||
|
Write-Host "[FAIL] UAC-Elevation abgebrochen oder fehlgeschlagen." -ForegroundColor Red
|
||||||
|
Write-Host " Rechtsklick auf PowerShell -> 'Als Administrator ausfuehren'" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$basePath = "C:\Users\$User\AppData\Local"
|
||||||
|
if (-not (Test-Path $basePath)) {
|
||||||
|
Write-Host "[FAIL] Pfad existiert nicht: $basePath" -ForegroundColor Red
|
||||||
|
Write-Host " Pruefe den Benutzernamen." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "================================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " ARIA Cleanup fuer User: $User" -ForegroundColor Cyan
|
||||||
|
Write-Host "================================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# -- 1. Docker prune (in WSL2) -----------------------------------
|
||||||
|
if (-not $SkipPrune) {
|
||||||
|
Write-Host "[1/3] Docker Cleanup in WSL2..." -ForegroundColor Yellow
|
||||||
|
Write-Host " docker system prune -a --volumes -f" -ForegroundColor Gray
|
||||||
|
Write-Host " docker builder prune -a -f" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
try {
|
||||||
|
wsl -e bash -c "docker system prune -a --volumes -f && docker builder prune -a -f"
|
||||||
|
Write-Host " [OK] fertig" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " [WARN] Docker prune fehlgeschlagen (vielleicht laeuft Docker Desktop nicht?)" -ForegroundColor Yellow
|
||||||
|
Write-Host " $_" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
if ($PruneOnly) {
|
||||||
|
Write-Host "PruneOnly gesetzt - fertig." -ForegroundColor Cyan
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- 2. WSL2 shutdown --------------------------------------------
|
||||||
|
Write-Host "[2/3] WSL2 herunterfahren..." -ForegroundColor Yellow
|
||||||
|
wsl --shutdown
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
Write-Host " [OK] fertig" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# -- 3. VHDX-Files finden + compacten ----------------------------
|
||||||
|
Write-Host "[3/3] VHDX-Files suchen + compacten..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$vhdxFiles = @()
|
||||||
|
$vhdxFiles += Get-ChildItem -Path "$basePath\Docker" -Recurse -Filter "*.vhdx" -ErrorAction SilentlyContinue
|
||||||
|
$vhdxFiles += Get-ChildItem -Path "$basePath\Packages" -Recurse -Filter "ext4.vhdx" -ErrorAction SilentlyContinue
|
||||||
|
$vhdxFiles = $vhdxFiles | Sort-Object FullName -Unique
|
||||||
|
|
||||||
|
if ($vhdxFiles.Count -eq 0) {
|
||||||
|
Write-Host " Keine .vhdx Files gefunden." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Gefundene Files (vorher):" -ForegroundColor Cyan
|
||||||
|
foreach ($f in $vhdxFiles) {
|
||||||
|
$sizeGB = [math]::Round($f.Length / 1GB, 2)
|
||||||
|
Write-Host (" {0,8} GB {1}" -f $sizeGB, $f.FullName) -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$totalBefore = ($vhdxFiles | Measure-Object Length -Sum).Sum
|
||||||
|
|
||||||
|
foreach ($f in $vhdxFiles) {
|
||||||
|
Write-Host "-> Compact: $($f.FullName)" -ForegroundColor White
|
||||||
|
$sizeBefore = [math]::Round($f.Length / 1GB, 2)
|
||||||
|
|
||||||
|
# Temporaeres diskpart-Script schreiben
|
||||||
|
$tmp = [System.IO.Path]::GetTempFileName()
|
||||||
|
@"
|
||||||
|
select vdisk file="$($f.FullName)"
|
||||||
|
attach vdisk readonly
|
||||||
|
compact vdisk
|
||||||
|
detach vdisk
|
||||||
|
exit
|
||||||
|
"@ | Out-File -Encoding ASCII -FilePath $tmp
|
||||||
|
|
||||||
|
try {
|
||||||
|
$output = & diskpart /s $tmp 2>&1
|
||||||
|
# Datei neu lesen - Length ist gecacht
|
||||||
|
$newFile = Get-Item $f.FullName
|
||||||
|
$sizeAfter = [math]::Round($newFile.Length / 1GB, 2)
|
||||||
|
$saved = [math]::Round($sizeBefore - $sizeAfter, 2)
|
||||||
|
if ($saved -gt 0) {
|
||||||
|
Write-Host (" [OK] {0} GB -> {1} GB (gespart: {2} GB)" -f $sizeBefore, $sizeAfter, $saved) -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host (" -- {0} GB -> {1} GB (nichts zu holen - File war schon optimal)" -f $sizeBefore, $sizeAfter) -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " [FAIL] Fehler: $_" -ForegroundColor Red
|
||||||
|
Write-Host " diskpart-Output:" -ForegroundColor DarkGray
|
||||||
|
$output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||||
|
} finally {
|
||||||
|
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Zusammenfassung ---------------------------------------------
|
||||||
|
$vhdxFilesAfter = @()
|
||||||
|
$vhdxFilesAfter += Get-ChildItem -Path "$basePath\Docker" -Recurse -Filter "*.vhdx" -ErrorAction SilentlyContinue
|
||||||
|
$vhdxFilesAfter += Get-ChildItem -Path "$basePath\Packages" -Recurse -Filter "ext4.vhdx" -ErrorAction SilentlyContinue
|
||||||
|
$vhdxFilesAfter = $vhdxFilesAfter | Sort-Object FullName -Unique
|
||||||
|
$totalAfter = ($vhdxFilesAfter | Measure-Object Length -Sum).Sum
|
||||||
|
|
||||||
|
$savedTotal = [math]::Round(($totalBefore - $totalAfter) / 1GB, 2)
|
||||||
|
|
||||||
|
Write-Host "================================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host (" Gesamt: {0} GB -> {1} GB (gespart: {2} GB)" -f `
|
||||||
|
[math]::Round($totalBefore / 1GB, 2),
|
||||||
|
[math]::Round($totalAfter / 1GB, 2),
|
||||||
|
$savedTotal) -ForegroundColor Cyan
|
||||||
|
Write-Host "================================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Fertig. Docker Desktop / WSL2 starten ja von alleine wieder beim naechsten Aufruf." -ForegroundColor Green
|
||||||
+154
-4
@@ -127,6 +127,15 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Service-Status Banner unten rechts (Gamebox: F5-TTS / Whisper Lade-Status) -->
|
||||||
|
<div id="service-status-banner" style="display:none;position:fixed;bottom:16px;right:16px;z-index:999;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:8px;padding:10px 14px;font-size:12px;color:#fff;min-width:240px;max-width:360px;box-shadow:0 4px 14px rgba(0,0,0,0.5);">
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:8px;">
|
||||||
|
<span id="service-status-icon" style="font-size:18px;line-height:1;">⏳</span>
|
||||||
|
<div id="service-status-list" style="flex:1;display:flex;flex-direction:column;gap:6px;"></div>
|
||||||
|
<button id="service-status-close" onclick="document.getElementById('service-status-banner').style.display='none'" style="background:none;border:none;color:#666680;font-size:16px;cursor:pointer;padding:0;line-height:1;display:none;">×</button>
|
||||||
|
</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;">
|
||||||
@@ -437,11 +446,11 @@
|
|||||||
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
|
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- XTTS Stimme -->
|
<!-- F5-TTS Stimme (zwingend eine Voice waehlen — F5-TTS braucht eine Referenz) -->
|
||||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
|
||||||
<label style="color:#8888AA;font-size:12px;">XTTS Stimme:</label>
|
<label style="color:#8888AA;font-size:12px;">F5-TTS Stimme:</label>
|
||||||
<select id="diag-xtts-voice" onchange="sendVoiceConfig()" style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
<select id="diag-xtts-voice" onchange="sendVoiceConfig()" style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||||
<option value="">Standard (XTTS Default)</option>
|
<option value="" disabled>(keine Stimme gewaehlt)</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn secondary" onclick="loadXTTSVoices()" style="padding:4px 10px;font-size:11px;">Laden</button>
|
<button class="btn secondary" onclick="loadXTTSVoices()" style="padding:4px 10px;font-size:11px;">Laden</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,6 +459,58 @@
|
|||||||
<!-- Gecloned Stimmen — Liste mit Loeschen -->
|
<!-- Gecloned Stimmen — Liste mit Loeschen -->
|
||||||
<div id="xtts-voice-list" style="margin-bottom:12px;"></div>
|
<div id="xtts-voice-list" style="margin-bottom:12px;"></div>
|
||||||
|
|
||||||
|
<!-- F5-TTS Modell-Tuning -->
|
||||||
|
<details style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;margin-bottom:12px;">
|
||||||
|
<summary style="color:#8888AA;font-size:12px;cursor:pointer;">F5-TTS Modell-Tuning (advanced)</summary>
|
||||||
|
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
||||||
|
<div style="color:#8888AA;font-size:11px;">
|
||||||
|
Werden via RVS an die f5tts-bridge auf der Gamebox geschickt.
|
||||||
|
Modell-/Checkpoint-Wechsel triggert einen Reload (~30s).
|
||||||
|
Hardcoded Defaults: F5TTS_v1_Base, cfg_strength=2.5, nfe_step=32.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="color:#8888AA;font-size:12px;">Modell-ID:</label>
|
||||||
|
<input type="text" id="diag-f5tts-model"
|
||||||
|
placeholder="F5TTS_v1_Base"
|
||||||
|
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||||
|
|
||||||
|
<label style="color:#8888AA;font-size:12px;">
|
||||||
|
Custom Checkpoint (HF-Repo "user/repo" oder Container-Pfad, leer = Default):
|
||||||
|
</label>
|
||||||
|
<input type="text" id="diag-f5tts-ckpt"
|
||||||
|
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;">
|
||||||
|
|
||||||
|
<label style="color:#8888AA;font-size:12px;">
|
||||||
|
Custom Vocab (passend zum Checkpoint, optional):
|
||||||
|
</label>
|
||||||
|
<input type="text" id="diag-f5tts-vocab"
|
||||||
|
placeholder="leer = Default"
|
||||||
|
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||||
|
|
||||||
|
<div style="display:flex;gap:12px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="color:#8888AA;font-size:12px;">cfg_strength (1.0 - 5.0):</label>
|
||||||
|
<input type="number" id="diag-f5tts-cfg" step="0.1" min="1" max="5"
|
||||||
|
placeholder="2.5"
|
||||||
|
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;width:100%;box-sizing:border-box;">
|
||||||
|
<div style="color:#666680;font-size:10px;">Hoeher = klebt staerker an Referenz</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="color:#8888AA;font-size:12px;">nfe_step (8 - 64):</label>
|
||||||
|
<input type="number" id="diag-f5tts-nfe" step="1" min="8" max="64"
|
||||||
|
placeholder="32"
|
||||||
|
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;width:100%;box-sizing:border-box;">
|
||||||
|
<div style="color:#666680;font-size:10px;">Hoeher = bessere Qualitaet, langsamer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;align-self:flex-start;margin-top:6px;">
|
||||||
|
Anwenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Voice Cloning -->
|
<!-- Voice Cloning -->
|
||||||
<div style="background:#1E1E2E;border-radius:8px;padding:12px;margin-top:8px;">
|
<div style="background:#1E1E2E;border-radius:8px;padding:12px;margin-top:8px;">
|
||||||
<div style="color:#0096FF;font-size:13px;font-weight:600;margin-bottom:8px;">Stimme klonen</div>
|
<div style="color:#0096FF;font-size:13px;font-weight:600;margin-bottom:8px;">Stimme klonen</div>
|
||||||
@@ -841,6 +902,16 @@
|
|||||||
const wSel = document.getElementById('diag-whisper-model');
|
const wSel = document.getElementById('diag-whisper-model');
|
||||||
if (wSel) wSel.value = msg.whisperModel;
|
if (wSel) wSel.value = msg.whisperModel;
|
||||||
}
|
}
|
||||||
|
// F5-TTS Tuning-Felder wiederherstellen (falls gesetzt)
|
||||||
|
const setIfPresent = (id, val) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && val !== undefined && val !== null && val !== '') el.value = val;
|
||||||
|
};
|
||||||
|
setIfPresent('diag-f5tts-model', msg.f5ttsModel);
|
||||||
|
setIfPresent('diag-f5tts-ckpt', msg.f5ttsCkptFile);
|
||||||
|
setIfPresent('diag-f5tts-vocab', msg.f5ttsVocabFile);
|
||||||
|
setIfPresent('diag-f5tts-cfg', msg.f5ttsCfgStrength);
|
||||||
|
setIfPresent('diag-f5tts-nfe', msg.f5ttsNfeStep);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +923,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'service_status') {
|
||||||
|
updateServiceStatus(msg.payload || {});
|
||||||
|
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;
|
||||||
@@ -1390,6 +1466,68 @@
|
|||||||
'Glob': '\uD83D\uDCC1 Dateien suchen',
|
'Glob': '\uD83D\uDCC1 Dateien suchen',
|
||||||
'Agent': '\uD83E\uDD16 Sub-Agent',
|
'Agent': '\uD83E\uDD16 Sub-Agent',
|
||||||
};
|
};
|
||||||
|
// ── Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) ──
|
||||||
|
// Aggregiert die Status-Infos der Bridges. Wenn irgendwas am Laden
|
||||||
|
// ist, zeigt das Banner unten rechts. Sobald alles auf 'ready' ist,
|
||||||
|
// bleibt's einen Moment und wird dann vom User weggeklickt (oder
|
||||||
|
// nach 8s automatisch).
|
||||||
|
const _serviceState = {}; // { f5tts: {state, model, ...}, whisper: {...} }
|
||||||
|
let _serviceFadeTimer = null;
|
||||||
|
function updateServiceStatus(p) {
|
||||||
|
const svc = p.service || '?';
|
||||||
|
_serviceState[svc] = p;
|
||||||
|
|
||||||
|
const banner = document.getElementById('service-status-banner');
|
||||||
|
const list = document.getElementById('service-status-list');
|
||||||
|
const icon = document.getElementById('service-status-icon');
|
||||||
|
const closeBtn = document.getElementById('service-status-close');
|
||||||
|
|
||||||
|
// Liste neu aufbauen
|
||||||
|
list.innerHTML = '';
|
||||||
|
let anyLoading = false, anyError = false;
|
||||||
|
const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT' };
|
||||||
|
for (const [s, info] of Object.entries(_serviceState)) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.cssText = 'display:flex;align-items:center;gap:6px;';
|
||||||
|
let dot = '⚫', color = '#666680', text = '';
|
||||||
|
if (info.state === 'loading') {
|
||||||
|
dot = '⏳'; color = '#FFD60A'; anyLoading = true;
|
||||||
|
text = `${labels[s] || s}: laedt${info.model ? ' ' + info.model : ''}...`;
|
||||||
|
} else if (info.state === 'ready') {
|
||||||
|
dot = '✅'; color = '#34C759';
|
||||||
|
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
|
||||||
|
text = `${labels[s] || s}: bereit${info.model ? ' ' + info.model : ''}${sec}`;
|
||||||
|
} else if (info.state === 'error') {
|
||||||
|
dot = '❌'; color = '#FF3B30'; anyError = true;
|
||||||
|
text = `${labels[s] || s}: Fehler ${info.error || ''}`;
|
||||||
|
} else {
|
||||||
|
text = `${labels[s] || s}: ${info.state}`;
|
||||||
|
}
|
||||||
|
row.innerHTML = `<span style="color:${color}">${dot}</span><span>${text}</span>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon spiegelt Gesamt-Status
|
||||||
|
if (anyError) icon.innerHTML = '❌';
|
||||||
|
else if (anyLoading) icon.innerHTML = '⏳';
|
||||||
|
else icon.innerHTML = '✅';
|
||||||
|
|
||||||
|
banner.style.display = 'block';
|
||||||
|
|
||||||
|
// Wenn alles ready (kein Loading, kein Error): X-Button anzeigen
|
||||||
|
// + nach 8s automatisch wegfaden
|
||||||
|
if (!anyLoading && !anyError) {
|
||||||
|
closeBtn.style.display = 'block';
|
||||||
|
clearTimeout(_serviceFadeTimer);
|
||||||
|
_serviceFadeTimer = setTimeout(() => {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
}, 8000);
|
||||||
|
} else {
|
||||||
|
closeBtn.style.display = 'none';
|
||||||
|
clearTimeout(_serviceFadeTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateThinkingIndicator(msg) {
|
function updateThinkingIndicator(msg) {
|
||||||
const indicators = [
|
const indicators = [
|
||||||
document.getElementById('thinking-indicator'),
|
document.getElementById('thinking-indicator'),
|
||||||
@@ -1570,7 +1708,19 @@
|
|||||||
const ttsEnabled = document.getElementById('diag-tts-enabled').checked;
|
const ttsEnabled = document.getElementById('diag-tts-enabled').checked;
|
||||||
const xttsVoice = document.getElementById('diag-xtts-voice').value;
|
const xttsVoice = document.getElementById('diag-xtts-voice').value;
|
||||||
const whisperModel = document.getElementById('diag-whisper-model').value;
|
const whisperModel = document.getElementById('diag-whisper-model').value;
|
||||||
send({ action: 'send_voice_config', ttsEnabled, xttsVoice, whisperModel });
|
const f5ttsModel = document.getElementById('diag-f5tts-model')?.value || '';
|
||||||
|
const f5ttsCkptFile = document.getElementById('diag-f5tts-ckpt')?.value || '';
|
||||||
|
const f5ttsVocabFile = document.getElementById('diag-f5tts-vocab')?.value || '';
|
||||||
|
const f5ttsCfgRaw = document.getElementById('diag-f5tts-cfg')?.value || '';
|
||||||
|
const f5ttsNfeRaw = document.getElementById('diag-f5tts-nfe')?.value || '';
|
||||||
|
const f5ttsCfgStrength = f5ttsCfgRaw ? parseFloat(f5ttsCfgRaw) : undefined;
|
||||||
|
const f5ttsNfeStep = f5ttsNfeRaw ? parseInt(f5ttsNfeRaw, 10) : undefined;
|
||||||
|
send({
|
||||||
|
action: 'send_voice_config',
|
||||||
|
ttsEnabled, xttsVoice, whisperModel,
|
||||||
|
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
||||||
|
f5ttsCfgStrength, f5ttsNfeStep,
|
||||||
|
});
|
||||||
const statusEl = document.getElementById('voice-status');
|
const statusEl = document.getElementById('voice-status');
|
||||||
if (statusEl && xttsVoice) {
|
if (statusEl && xttsVoice) {
|
||||||
statusEl.textContent = `⏳ Stimme "${xttsVoice}" wird geladen...`;
|
statusEl.textContent = `⏳ Stimme "${xttsVoice}" wird geladen...`;
|
||||||
|
|||||||
@@ -637,6 +637,22 @@ function connectRVS(forcePlain) {
|
|||||||
log("info", "rvs", `Voice "${v || "default"}" geladen${ms ? ` in ${(ms/1000).toFixed(1)}s` : ""}`);
|
log("info", "rvs", `Voice "${v || "default"}" geladen${ms ? ` in ${(ms/1000).toFixed(1)}s` : ""}`);
|
||||||
}
|
}
|
||||||
broadcast({ type: "voice_ready", payload: msg.payload });
|
broadcast({ type: "voice_ready", payload: msg.payload });
|
||||||
|
} else if (msg.type === "service_status") {
|
||||||
|
// Gamebox-Bridges (f5tts/whisper) melden ihren Lade-Status —
|
||||||
|
// an Browser durchreichen fuer das Banner unten rechts
|
||||||
|
const svc = msg.payload?.service || "?";
|
||||||
|
const state = msg.payload?.state || "?";
|
||||||
|
const model = msg.payload?.model || "";
|
||||||
|
const sec = msg.payload?.loadSeconds;
|
||||||
|
const err = msg.payload?.error;
|
||||||
|
if (err) {
|
||||||
|
log("warn", "rvs", `service_status ${svc}: ${err}`);
|
||||||
|
} else if (state === "ready" && sec) {
|
||||||
|
log("info", "rvs", `service_status ${svc} ready (${model}, ${sec.toFixed(1)}s)`);
|
||||||
|
} else {
|
||||||
|
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
|
||||||
|
}
|
||||||
|
broadcast({ type: "service_status", payload: msg.payload });
|
||||||
} else {
|
} else {
|
||||||
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
|
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
|
||||||
}
|
}
|
||||||
@@ -1423,6 +1439,25 @@ 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 — leere Strings entfernen damit der Default greift
|
||||||
|
if (msg.f5ttsModel !== undefined) {
|
||||||
|
if (msg.f5ttsModel) voiceConfig.f5ttsModel = msg.f5ttsModel;
|
||||||
|
else delete voiceConfig.f5ttsModel;
|
||||||
|
}
|
||||||
|
if (msg.f5ttsCkptFile !== undefined) {
|
||||||
|
if (msg.f5ttsCkptFile) voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile;
|
||||||
|
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)) {
|
||||||
|
voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength;
|
||||||
|
}
|
||||||
|
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
|
||||||
|
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync("/shared/config", { recursive: true });
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- [x] Bildupload funktioniert (Shared Volume /shared/uploads/)
|
- [x] Bildupload funktioniert (Shared Volume /shared/uploads/)
|
||||||
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
|
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
|
||||||
- [x] Cache leeren + Auto-Download von Anhaengen
|
- [x] Cache leeren + Auto-Download von Anhaengen
|
||||||
- [x] ARIA liest Nachrichten vor (TTS via Piper)
|
- [x] ARIA liest Nachrichten vor (TTS via Piper, später ersetzt)
|
||||||
- [x] Autoscroll zur letzten Nachricht (inverted FlatList)
|
- [x] Autoscroll zur letzten Nachricht (inverted FlatList)
|
||||||
- [x] Bilder im Chat groesser + Vollbild-Vorschau
|
- [x] Bilder im Chat groesser + Vollbild-Vorschau
|
||||||
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
|
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
|
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
|
||||||
- [x] Grosse Nachrichten satzweise aufteilen fuer TTS
|
- [x] Grosse Nachrichten satzweise aufteilen fuer TTS
|
||||||
- [x] RVS Nachrichten vom Smartphone gehen durch
|
- [x] RVS Nachrichten vom Smartphone gehen durch
|
||||||
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme)
|
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme — durch XTTS/F5-TTS ersetzt)
|
||||||
- [x] Highlight-Trigger konfigurierbar in Diagnostic
|
- [x] Highlight-Trigger konfigurierbar in Diagnostic
|
||||||
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning)
|
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning) — durch F5-TTS ersetzt
|
||||||
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
|
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
|
||||||
- [x] TTS Engine waehlbar (Piper/XTTS) in Diagnostic + App
|
- [x] TTS Engine waehlbar (Piper/XTTS) — Piper raus, XTTS raus, jetzt nur F5-TTS
|
||||||
- [x] Auto-Update System (APK via RVS WebSocket)
|
- [x] Auto-Update System (APK via RVS WebSocket)
|
||||||
- [x] Auto-Update: APK-Installation via FileProvider
|
- [x] Auto-Update: APK-Installation via FileProvider
|
||||||
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
|
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
|
||||||
@@ -31,49 +31,66 @@
|
|||||||
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
|
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
|
||||||
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
|
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
|
||||||
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
|
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
|
||||||
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt (verhindert dass Umgebungsgeraeusche an Whisper gehen)
|
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||||
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten (sessionFromFile-Flag, atomic write)
|
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten
|
||||||
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen (pipelineEnd broadcastet immer idle, auch bei Timeout/Fehler/Disconnect)
|
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
|
||||||
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
||||||
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload in Bridge, Default auf medium
|
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload
|
||||||
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
||||||
- [x] Streaming TTS (Weg A): XTTS → PCM-Stream → aria-bridge → App AudioTrack MODE_STREAM, keine WAV-Gaps mehr
|
- [x] Streaming TTS: PCM-Stream → AudioTrack MODE_STREAM, keine WAV-Gaps
|
||||||
- [x] Piper komplett entfernt: nur noch XTTS v2 als TTS-Engine (remote, GPU auf Gaming-PC). Wenn XTTS offline ist, ist ARIA stumm — bewusst akzeptiert.
|
- [x] Piper komplett entfernt
|
||||||
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms) — keine Umgebungsgeraeusche mehr
|
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms)
|
||||||
- [x] Gespraechsmodus: Max-Dauer 30s pro Aufnahme, Cache-Cleanup alter Files, Messages-Array gekappt (500)
|
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) angezeigt + exportierbar
|
||||||
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) werden angezeigt + exportierbar — OpenClaw resettet Sessions bei erster Nutzung nach Container-Restart, Inhalt ist aber in .reset.<timestamp> Dateien gesichert
|
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer Session-JSONL zu Markdown
|
||||||
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer beliebige Session-JSONL zu Markdown
|
- [x] NO_REPLY-Filter in Bridge + Diagnostic
|
||||||
- [x] NO_REPLY-Filter in Bridge + Diagnostic — still verworfen (kein Chat, kein TTS)
|
- [x] Audio-Ducking + Exklusiv-Focus (Kotlin AudioFocusModule)
|
||||||
- [x] Audio-Ducking + Exklusiv-Focus (Kotlin AudioFocusModule): andere Apps leiser bei TTS, pausiert bei Aufnahme
|
- [x] TTS-Cleanup serverseitig: Code-Bloecke raus, Einheiten ausgeschrieben, Abkuerzungen buchstabiert, URLs zu "ein Link"
|
||||||
- [x] TTS-Cleanup serverseitig: Code-Bloecke raus, Einheiten ausgeschrieben (22GB → Gigabyte), Abkuerzungen buchstabiert (CPU), URLs zu "ein Link". `<voice></voice>` Tag wird bevorzugt wenn ARIA ihn liefert.
|
- [x] QR-Code Onboarding: Diagnostic generiert QR, App scannt
|
||||||
- [x] QR-Code Onboarding: Diagnostic generiert QR, App scannt (bestehender QRScanner funktioniert out of the box)
|
- [x] TTS-Audio-Cache im Filesystem: WAV pro messageId, Play-Button spielt aus Cache
|
||||||
- [x] TTS-Audio-Cache im Filesystem: Piper-Audio wird mit messageId verknuepft, als WAV in DocumentDirectory/tts_cache gespeichert, Play-Button spielt aus Cache statt regenerieren
|
- [x] Config via Diagnostic: RVS-Credentials + Auth-Token persistiert in /shared/config/runtime.json
|
||||||
- [x] Config via Diagnostic: RVS-Credentials + Aria-Auth-Token via /api/runtime-config, persistiert in /shared/config/runtime.json, Bridge liest beim Start (Overrides der ENV)
|
- [x] Disk-Voll Banner in Diagnostic: rotes Overlay + copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||||
|
- [x] cleanup.sh: kombinierter Docker-Aufraeum-Befehl (safe / --full)
|
||||||
|
- [x] Streaming TTS Pre-Roll: AudioTrack play() startet erst wenn 2.5s gepuffert sind
|
||||||
|
- [x] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
|
||||||
|
- [x] Leading-Silence (200ms) am Stream-Anfang — AudioTrack faehrt sauber an
|
||||||
|
- [x] Pre-Roll-Buffer einstellbar in App-Settings (1.0-6.0s, Default 3.5s)
|
||||||
|
- [x] Fade-In auf erstem PCM-Chunk (120ms) — versteckt XTTS/F5-TTS Warmup-Glitches
|
||||||
|
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
|
||||||
|
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
|
||||||
|
- [x] Voice-Auswahl funktioniert wieder: speaker_wav als Basename statt Pfad fuer daswer123 local-Mode
|
||||||
|
- [x] Diagnostic-Voice-Wechsel resettet alle App-lokalen Voice-Overrides via type "config"
|
||||||
|
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
|
||||||
|
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
|
||||||
|
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
|
||||||
|
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic
|
||||||
|
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
|
||||||
|
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
|
||||||
|
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
|
||||||
|
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
|
||||||
|
- [x] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s)
|
||||||
|
- [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich
|
||||||
|
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
### Bugs (Prioritaet)
|
### Bugs
|
||||||
- [ ] App: Audioausgabe hoert ab und zu einfach auf (mitten im Satz oder zwischen Chunks)
|
|
||||||
- [ ] NO_REPLY wird als "NO" im Chat angezeigt — sollte still verworfen werden (Token nicht gesaeubert)
|
- [ ] NO_REPLY wird als "NO" im Chat angezeigt — sollte still verworfen werden (Token nicht gesaeubert)
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
|
- [ ] 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)
|
||||||
- [ ] Audio-Ducking: andere App-Audio-Ausgaben leiser stellen waehrend ARIA spricht (AudioFocus API)
|
|
||||||
- [ ] Audio-Muten waehrend Aufnahme/Ohr-Modus: andere Audio stumm (wie WhatsApp-Sprachaufnahme)
|
|
||||||
- [ ] Spracheingabe-Timeout erhoehen fuer laengere Texte
|
|
||||||
- [ ] Generierte TTS-Audiodaten in der Chat-Nachricht einbetten (oder lokal cachen), Play-Button spielt aus Cache statt Regenerierung via XTTS. Base64 im Tag <soundfile></soundfile> (invisible) oder lokaler Datei-Cache mit Referenz in der Message.
|
|
||||||
- [ ] QR-Code Onboarding: Diagnostic generiert QR mit RVS-Credentials, App scannt — keine manuelle Eingabe mehr
|
|
||||||
|
|
||||||
### TTS / Audio
|
### TTS / Audio
|
||||||
- [ ] Audio-Normalisierung (Lautstaerke zwischen Chunks angleichen)
|
- [ ] 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?)
|
||||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||||
- [ ] RVS Zombie-Connections endgueltig loesen
|
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||||
- [ ] Alle .env-Variablen ueber Diagnostic konfigurierbar machen (kein File-Sync mehr noetig, da alle ARIA-Container auf der gleichen VM laufen). Fallback .env bleibt fuer initialen Bootstrap.
|
- [ ] Alle .env-Variablen ueber Diagnostic konfigurierbar machen (Fallback .env bleibt fuer initialen Bootstrap)
|
||||||
- [ ] XTTS-Container: kleine Web-Oberflaeche fuer Credentials/Server-Config, oder zentral aus Diagnostic per RVS push
|
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
||||||
- [ ] Root-Cause OpenClaw Session-Reset: Herausfinden warum Sessions beim ersten chat.send nach Container-Restart verworfen werden (abortedLastRun / systemSent Theorie pruefen, ggf. Flag preemptiv patchen)
|
- [ ] Root-Cause OpenClaw Session-Reset: Herausfinden warum Sessions beim ersten chat.send nach Container-Restart verworfen werden
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"xtts_delete_voice",
|
"xtts_delete_voice",
|
||||||
"voice_preload", "voice_ready",
|
"voice_preload", "voice_ready",
|
||||||
"stt_request", "stt_response",
|
"stt_request", "stt_response",
|
||||||
|
"service_status",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Token-Raum: token -> { clients: Set<ws> }
|
// Token-Raum: token -> { clients: Set<ws> }
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Voice-Samples (lokal, gehoert nicht ins Repo)
|
||||||
|
voices/
|
||||||
|
|
||||||
|
# Docker .env
|
||||||
|
.env
|
||||||
@@ -31,14 +31,19 @@ services:
|
|||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
volumes:
|
volumes:
|
||||||
- ./voices:/voices # WAV + TXT Referenz
|
- ./voices:/voices # WAV + TXT Referenz
|
||||||
- f5tts-models:/root/.cache/huggingface # Model-Cache persistieren
|
# KEIN HF-Cache-Mount mehr —
|
||||||
|
# Modell wird beim Start neu
|
||||||
|
# gezogen. Diagnostic zeigt
|
||||||
|
# "TTS laedt..." Banner bis
|
||||||
|
# service_status: ready kommt.
|
||||||
environment:
|
environment:
|
||||||
|
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
|
||||||
|
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
|
||||||
- RVS_HOST=${RVS_HOST}
|
- RVS_HOST=${RVS_HOST}
|
||||||
- RVS_PORT=${RVS_PORT:-443}
|
- RVS_PORT=${RVS_PORT:-443}
|
||||||
- RVS_TLS=${RVS_TLS:-true}
|
- RVS_TLS=${RVS_TLS:-true}
|
||||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||||
- RVS_TOKEN=${RVS_TOKEN}
|
- RVS_TOKEN=${RVS_TOKEN}
|
||||||
- F5TTS_MODEL=${F5TTS_MODEL:-F5TTS_v1_Base}
|
|
||||||
- F5TTS_DEVICE=${F5TTS_DEVICE:-cuda}
|
- F5TTS_DEVICE=${F5TTS_DEVICE:-cuda}
|
||||||
- VOICES_DIR=/voices
|
- VOICES_DIR=/voices
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -72,10 +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.
|
||||||
- whisper-models:/root/.cache/huggingface
|
# Wechsel via Diagnostic triggert ebenso Re-Download.
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
f5tts-models:
|
|
||||||
whisper-models:
|
|
||||||
|
|||||||
+169
-17
@@ -52,13 +52,33 @@ RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
|||||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||||
|
|
||||||
F5TTS_MODEL = os.getenv("F5TTS_MODEL", "F5TTS_v1_Base")
|
# F5-TTS Konfiguration
|
||||||
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda")
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Defaults sind hard-coded — bewusst KEINE ENV-Vars (ausser F5TTS_DEVICE,
|
||||||
|
# weil Hardware-Bootstrap). Alle Settings werden zur Laufzeit via RVS
|
||||||
|
# config-Broadcast aus Diagnostic uebersteuert (Felder f5ttsModel,
|
||||||
|
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
|
||||||
|
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
|
||||||
|
|
||||||
|
DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base"
|
||||||
|
DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF
|
||||||
|
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
|
||||||
|
# cfg_strength: wie stark der Generator am Referenz-Voice klebt.
|
||||||
|
# Default F5-TTS = 2.0. Bei nicht-EN/CN Sprachen (Deutsch!) hilft 2.5+,
|
||||||
|
# damit das Modell nicht in eine andere Sprache abrutscht.
|
||||||
|
DEFAULT_F5TTS_CFG_STRENGTH = 2.5
|
||||||
|
DEFAULT_F5TTS_NFE_STEP = 32
|
||||||
|
|
||||||
VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
|
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
|
||||||
|
|
||||||
|
# Wird in einer Uebergangsphase als "ungueltige Referenz" erkannt (alte voices,
|
||||||
|
# die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung
|
||||||
|
# loeschen wir die .txt und ziehen den echten Text nach.
|
||||||
|
_LEGACY_PLACEHOLDER_REF = "Das ist ein Referenz Audio."
|
||||||
|
|
||||||
# ── Lazy F5-TTS Loader ──────────────────────────────────────
|
# ── Lazy F5-TTS Loader ──────────────────────────────────────
|
||||||
|
|
||||||
_F5TTS_cls = None
|
_F5TTS_cls = None
|
||||||
@@ -74,18 +94,42 @@ def _get_f5tts_cls():
|
|||||||
|
|
||||||
|
|
||||||
class F5Runner:
|
class F5Runner:
|
||||||
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking)."""
|
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking).
|
||||||
|
|
||||||
|
Live-Settings (Modell, cfg_strength, nfe_step) werden ueber update_config()
|
||||||
|
aus dem Diagnostic-Config-Broadcast gesetzt; bei Modell-Wechsel wird
|
||||||
|
automatisch neu geladen.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.model = None
|
self.model = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Aktuelle Werte — gestartet mit Hard-Defaults, ueberschrieben von Diagnostic
|
||||||
|
self.model_id: str = DEFAULT_F5TTS_MODEL
|
||||||
|
self.ckpt_file: str = DEFAULT_F5TTS_CKPT_FILE
|
||||||
|
self.vocab_file: str = DEFAULT_F5TTS_VOCAB_FILE
|
||||||
|
self.cfg_strength: float = DEFAULT_F5TTS_CFG_STRENGTH
|
||||||
|
self.nfe_step: int = DEFAULT_F5TTS_NFE_STEP
|
||||||
|
# Last load-time fuer service_status Broadcast
|
||||||
|
self.last_load_seconds: float = 0.0
|
||||||
|
self._load_started_at: float = 0.0
|
||||||
|
|
||||||
def _load_blocking(self) -> None:
|
def _load_blocking(self) -> None:
|
||||||
cls = _get_f5tts_cls()
|
cls = _get_f5tts_cls()
|
||||||
logger.info("Lade F5-TTS '%s' (device=%s)...", F5TTS_MODEL, F5TTS_DEVICE)
|
logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...",
|
||||||
t0 = time.time()
|
self.model_id, F5TTS_DEVICE, self.ckpt_file or "default")
|
||||||
self.model = cls(model=F5TTS_MODEL, device=F5TTS_DEVICE)
|
self._load_started_at = time.time()
|
||||||
logger.info("F5-TTS geladen in %.1fs", time.time() - t0)
|
kwargs = {"model": self.model_id, "device": F5TTS_DEVICE}
|
||||||
|
if self.ckpt_file:
|
||||||
|
kwargs["ckpt_file"] = self.ckpt_file
|
||||||
|
if self.vocab_file:
|
||||||
|
kwargs["vocab_file"] = self.vocab_file
|
||||||
|
self.model = cls(**kwargs)
|
||||||
|
elapsed = time.time() - self._load_started_at
|
||||||
|
logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)",
|
||||||
|
elapsed, self.cfg_strength, self.nfe_step)
|
||||||
|
# Wird von outside (run_loop) gelesen um service_status auf 'ready' zu setzen
|
||||||
|
self.last_load_seconds = elapsed
|
||||||
|
|
||||||
async def ensure_loaded(self) -> None:
|
async def ensure_loaded(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@@ -94,6 +138,49 @@ class F5Runner:
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, self._load_blocking)
|
await loop.run_in_executor(None, self._load_blocking)
|
||||||
|
|
||||||
|
async def update_config(self, payload: dict) -> None:
|
||||||
|
"""Liest f5tts*-Felder aus einem config-Broadcast.
|
||||||
|
Bei Modell-relevantem Wechsel wird neu geladen."""
|
||||||
|
new_model = (payload.get("f5ttsModel") or "").strip() or self.model_id
|
||||||
|
new_ckpt = payload.get("f5ttsCkptFile", self.ckpt_file) or ""
|
||||||
|
new_vocab = payload.get("f5ttsVocabFile", self.vocab_file) or ""
|
||||||
|
try:
|
||||||
|
new_cfg = float(payload.get("f5ttsCfgStrength", self.cfg_strength))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
new_cfg = self.cfg_strength
|
||||||
|
try:
|
||||||
|
new_nfe = int(payload.get("f5ttsNfeStep", self.nfe_step))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
new_nfe = self.nfe_step
|
||||||
|
|
||||||
|
# Settings die KEINEN Modell-Reload brauchen (zur naechsten Synthese aktiv)
|
||||||
|
self.cfg_strength = new_cfg
|
||||||
|
self.nfe_step = new_nfe
|
||||||
|
|
||||||
|
# Settings die einen Reload triggern
|
||||||
|
model_changed = (new_model != self.model_id
|
||||||
|
or new_ckpt != self.ckpt_file
|
||||||
|
or new_vocab != self.vocab_file)
|
||||||
|
if model_changed:
|
||||||
|
logger.info("F5-TTS Config-Wechsel: model=%s ckpt=%s vocab=%s — Reload",
|
||||||
|
new_model, new_ckpt or "default", new_vocab or "default")
|
||||||
|
self.model_id = new_model
|
||||||
|
self.ckpt_file = new_ckpt
|
||||||
|
self.vocab_file = new_vocab
|
||||||
|
async with self._lock:
|
||||||
|
old = self.model
|
||||||
|
self.model = None
|
||||||
|
# Alte Instanz freigeben
|
||||||
|
try:
|
||||||
|
if old is not None:
|
||||||
|
del old
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._load_blocking)
|
||||||
|
else:
|
||||||
|
logger.info("F5-TTS Live-Config: cfg_strength=%.2f nfe=%d", new_cfg, new_nfe)
|
||||||
|
|
||||||
def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str) -> tuple[np.ndarray, int]:
|
def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str) -> tuple[np.ndarray, int]:
|
||||||
wav, sr, _ = self.model.infer(
|
wav, sr, _ = self.model.infer(
|
||||||
ref_file=ref_wav,
|
ref_file=ref_wav,
|
||||||
@@ -101,6 +188,8 @@ class F5Runner:
|
|||||||
gen_text=gen_text,
|
gen_text=gen_text,
|
||||||
remove_silence=True,
|
remove_silence=True,
|
||||||
seed=-1,
|
seed=-1,
|
||||||
|
cfg_strength=self.cfg_strength,
|
||||||
|
nfe_step=self.nfe_step,
|
||||||
)
|
)
|
||||||
# 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):
|
||||||
@@ -259,13 +348,24 @@ async def _do_tts(ws, runner: F5Runner, text: str, voice: str,
|
|||||||
request_id: str, message_id: str, language: str) -> None:
|
request_id: str, message_id: str, language: str) -> 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)
|
||||||
|
|
||||||
|
# Legacy-Platzhalter erkennen → behandeln als "kein txt" und neu transkribieren
|
||||||
|
if voice and ref_txt_path and ref_txt_path.exists():
|
||||||
|
try:
|
||||||
|
existing = ref_txt_path.read_text(encoding="utf-8").strip()
|
||||||
|
if existing == _LEGACY_PLACEHOLDER_REF or not existing:
|
||||||
|
logger.info("Voice '%s' hat Legacy-Platzhalter → loesche, transkribiere neu", voice)
|
||||||
|
ref_txt_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
has_custom = bool(voice and ref_wav_path and ref_wav_path.exists() and ref_txt_path.exists())
|
has_custom = bool(voice and ref_wav_path and ref_wav_path.exists() and ref_txt_path.exists())
|
||||||
if voice and not has_custom:
|
if voice and not has_custom:
|
||||||
# Wenn nur WAV da ist aber kein txt → on-the-fly transkribieren
|
# Wenn nur WAV da ist aber kein txt → on-the-fly transkribieren
|
||||||
if ref_wav_path and ref_wav_path.exists() and (not ref_txt_path or not ref_txt_path.exists()):
|
if ref_wav_path and ref_wav_path.exists() and (not ref_txt_path or not ref_txt_path.exists()):
|
||||||
logger.info("Voice '%s' hat kein txt — transkribiere on-the-fly", voice)
|
logger.info("Voice '%s' hat kein txt — transkribiere on-the-fly", voice)
|
||||||
text_ref = await request_transcription(ws, ref_wav_path, language)
|
text_ref = await request_transcription(ws, ref_wav_path, language)
|
||||||
if text_ref:
|
if text_ref and text_ref.strip():
|
||||||
try:
|
try:
|
||||||
ref_txt_path.write_text(text_ref.strip(), encoding="utf-8")
|
ref_txt_path.write_text(text_ref.strip(), encoding="utf-8")
|
||||||
has_custom = True
|
has_custom = True
|
||||||
@@ -397,14 +497,20 @@ async def handle_voice_upload(ws, payload: dict) -> None:
|
|||||||
# 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)
|
||||||
text = await request_transcription(ws, wav_path, language="de")
|
text = await request_transcription(ws, wav_path, language="de")
|
||||||
if not text:
|
if text and text.strip():
|
||||||
logger.warning("Transkription fehlgeschlagen — speichere Platzhalter-Text")
|
txt_path.write_text(text.strip(), encoding="utf-8")
|
||||||
text = "Das ist ein Referenz Audio."
|
logger.info("Voice '%s' komplett (txt: %s)", name, text[:80])
|
||||||
txt_path.write_text(text.strip(), encoding="utf-8")
|
ref_text_for_response = text.strip()
|
||||||
logger.info("Voice '%s' komplett (txt: %s)", name, text[:80])
|
else:
|
||||||
|
# KEIN Platzhalter mehr schreiben! Beim ersten echten TTS-Use wird
|
||||||
|
# on-the-fly nachtranskribiert. Wenn die whisper-bridge dann online
|
||||||
|
# ist, klappt's — sonst koennte der User die .txt manuell anlegen.
|
||||||
|
logger.warning("Voice '%s': Transkription fehlgeschlagen — .txt bleibt leer, "
|
||||||
|
"wird on-the-fly bei erstem Render nachgezogen", name)
|
||||||
|
ref_text_for_response = ""
|
||||||
|
|
||||||
await _send(ws, "xtts_voice_saved", {
|
await _send(ws, "xtts_voice_saved", {
|
||||||
"name": name, "size": int(size_kb * 1024), "refText": text.strip(),
|
"name": name, "size": int(size_kb * 1024), "refText": ref_text_for_response,
|
||||||
})
|
})
|
||||||
# Liste aktualisieren
|
# Liste aktualisieren
|
||||||
await handle_list_voices(ws)
|
await handle_list_voices(ws)
|
||||||
@@ -480,10 +586,15 @@ async def handle_voice_preload(ws, payload: dict, runner: F5Runner) -> None:
|
|||||||
|
|
||||||
# ── Haupt-Loop ──────────────────────────────────────────────
|
# ── Haupt-Loop ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def run_loop(runner: F5Runner) -> None:
|
async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||||
# Preload im Hintergrund starten damit der Startup nicht blockiert
|
"""Sendet service_status fuer das F5-TTS Modul.
|
||||||
asyncio.create_task(runner.ensure_loaded())
|
state: 'loading' | 'ready' | 'error'."""
|
||||||
|
payload = {"service": "f5tts", "state": state}
|
||||||
|
payload.update(extra)
|
||||||
|
await _send(ws, "service_status", payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_loop(runner: F5Runner) -> None:
|
||||||
use_tls = RVS_TLS
|
use_tls = RVS_TLS
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
@@ -501,6 +612,25 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
# Status-Broadcast: erst loading, dann ready nach erfolgreichem Load.
|
||||||
|
# Modell wird hier (nicht ausserhalb der Schleife) gestartet damit
|
||||||
|
# der Loading-Status auch wirklich uebertragen werden kann.
|
||||||
|
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:
|
||||||
|
await runner.ensure_loaded()
|
||||||
|
await _broadcast_status(ws, "ready",
|
||||||
|
model=runner.model_id,
|
||||||
|
loadSeconds=runner.last_load_seconds)
|
||||||
|
except Exception as e:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
asyncio.create_task(_load_with_status())
|
||||||
|
|
||||||
# TTS-Worker fuer diese Verbindung starten
|
# TTS-Worker fuer diese Verbindung starten
|
||||||
worker = asyncio.create_task(_tts_worker(ws, runner))
|
worker = asyncio.create_task(_tts_worker(ws, runner))
|
||||||
|
|
||||||
@@ -539,6 +669,28 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
else:
|
else:
|
||||||
fut.set_result(payload.get("text") or "")
|
fut.set_result(payload.get("text") or "")
|
||||||
elif mtype == "config":
|
elif mtype == "config":
|
||||||
|
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
|
||||||
|
async def _update_with_status(p):
|
||||||
|
# Schaut ob ein Modell-Wechsel ansteht — falls ja:
|
||||||
|
# erst loading-Status, dann update, dann ready.
|
||||||
|
old_model = (runner.model_id, runner.ckpt_file, runner.vocab_file)
|
||||||
|
new_model_id = (p.get("f5ttsModel") or runner.model_id,
|
||||||
|
p.get("f5ttsCkptFile", runner.ckpt_file) or "",
|
||||||
|
p.get("f5ttsVocabFile", runner.vocab_file) or "")
|
||||||
|
will_reload = old_model != new_model_id
|
||||||
|
if will_reload:
|
||||||
|
await _broadcast_status(ws, "loading", model=new_model_id[0])
|
||||||
|
try:
|
||||||
|
await runner.update_config(p)
|
||||||
|
if will_reload:
|
||||||
|
await _broadcast_status(ws, "ready",
|
||||||
|
model=runner.model_id,
|
||||||
|
loadSeconds=runner.last_load_seconds)
|
||||||
|
except Exception as e:
|
||||||
|
if will_reload:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
asyncio.create_task(_update_with_status(payload))
|
||||||
|
# Voice-Preload bei Wechsel
|
||||||
v = (payload.get("xttsVoice") or "").strip()
|
v = (payload.get("xttsVoice") or "").strip()
|
||||||
if v and v != _last_diag_voice:
|
if v and v != _last_diag_voice:
|
||||||
_last_diag_voice = v
|
_last_diag_voice = v
|
||||||
|
|||||||
+39
-8
@@ -184,13 +184,15 @@ async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
async def run_loop(runner: WhisperRunner) -> None:
|
async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||||
# Modell vorab laden damit erste Anfrage flott ist
|
"""Sendet service_status fuer das Whisper-Modul.
|
||||||
try:
|
state: 'loading' | 'ready' | 'error'."""
|
||||||
await runner.ensure_loaded(WHISPER_MODEL)
|
payload = {"service": "whisper", "state": state}
|
||||||
except Exception as e:
|
payload.update(extra)
|
||||||
logger.error("Preload fehlgeschlagen: %s — Fortsetzung, wird bei erstem Request nachgeladen", e)
|
await _send(ws, "service_status", payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_loop(runner: WhisperRunner) -> None:
|
||||||
use_tls = RVS_TLS
|
use_tls = RVS_TLS
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
@@ -205,6 +207,24 @@ async def run_loop(runner: WhisperRunner) -> None:
|
|||||||
logger.info("RVS verbunden")
|
logger.info("RVS verbunden")
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
# Modell laden, dabei loading→ready broadcasten
|
||||||
|
async def _load_with_status():
|
||||||
|
if runner.model is not None:
|
||||||
|
await _broadcast_status(ws, "ready", model=runner.model_size)
|
||||||
|
return
|
||||||
|
await _broadcast_status(ws, "loading", model=WHISPER_MODEL)
|
||||||
|
try:
|
||||||
|
t0 = time.time()
|
||||||
|
await runner.ensure_loaded(WHISPER_MODEL)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
await _broadcast_status(ws, "ready",
|
||||||
|
model=runner.model_size,
|
||||||
|
loadSeconds=elapsed)
|
||||||
|
except Exception as e:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
asyncio.create_task(_load_with_status())
|
||||||
|
|
||||||
async for raw in ws:
|
async for raw in ws:
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
@@ -222,8 +242,19 @@ async def run_loop(runner: WhisperRunner) -> None:
|
|||||||
elif mtype == "config":
|
elif mtype == "config":
|
||||||
new_model = payload.get("whisperModel")
|
new_model = payload.get("whisperModel")
|
||||||
if new_model and new_model != runner.model_size:
|
if new_model and new_model != runner.model_size:
|
||||||
logger.info("Config-Broadcast: Whisper-Modell → %s", new_model)
|
logger.info("Config-Broadcast: Whisper-Modell -> %s", new_model)
|
||||||
asyncio.create_task(runner.ensure_loaded(new_model))
|
async def _swap_with_status(target):
|
||||||
|
await _broadcast_status(ws, "loading", model=target)
|
||||||
|
try:
|
||||||
|
t0 = time.time()
|
||||||
|
await runner.ensure_loaded(target)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
await _broadcast_status(ws, "ready",
|
||||||
|
model=runner.model_size,
|
||||||
|
loadSeconds=elapsed)
|
||||||
|
except Exception as e:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
asyncio.create_task(_swap_with_status(new_model))
|
||||||
else:
|
else:
|
||||||
# Alle anderen Nachrichten debug-loggen — hilft beim Diagnostizieren,
|
# Alle anderen Nachrichten debug-loggen — hilft beim Diagnostizieren,
|
||||||
# ob stt_request ueberhaupt durch den RVS kommt
|
# ob stt_request ueberhaupt durch den RVS kommt
|
||||||
|
|||||||
Reference in New Issue
Block a user