Compare commits
231 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
b1ccf29295 | |
|
|
4cd9faece2 | |
|
|
fec8aa977b | |
|
|
20123de827 | |
|
|
8761d1a1b7 | |
|
|
abc5b971f4 | |
|
|
b588dd7e3b | |
|
|
309df9d851 | |
|
|
f2e643d1fb | |
|
|
6ac374621c | |
|
|
efbd306597 | |
|
|
4454613a98 | |
|
|
55cfb752a2 | |
|
|
a4d3449e3a | |
|
|
44d2c6b4fe | |
|
|
0309c95aa5 | |
|
|
2aa2cc70c9 | |
|
|
9d0776c819 | |
|
|
f031fa159e | |
|
|
be373466a3 | |
|
|
bbf9aed3ba | |
|
|
745b4a07c0 | |
|
|
23ca815cb2 | |
|
|
cc3fac8142 | |
|
|
cd89e36ec2 | |
|
|
f5b4285d15 | |
|
|
248e7c9ae4 | |
|
|
7058cc8d8d | |
|
|
7919489543 | |
|
|
feac7f2479 | |
|
|
b80b813703 | |
|
|
e7bb6c37cb | |
|
|
d146ca92c4 | |
|
|
fd95af2c40 | |
|
|
9e12e0001c | |
|
|
1d34143be5 | |
|
|
0fc11e33c8 | |
|
|
dae603541b | |
|
|
87b4cd305c | |
|
|
190352820c | |
|
|
2264f4e3bc | |
|
|
58fd8721e3 | |
|
|
4f494daffb | |
|
|
958c8d6fc6 | |
|
|
5ba89c7191 | |
|
|
b373f915b5 | |
|
|
7748834a0f | |
|
|
8b52f4c92b | |
|
|
dc20570f6d | |
|
|
744a27cfd1 | |
|
|
37c5f6c368 | |
|
|
a361015ff4 | |
|
|
d83b555209 | |
|
|
a029267d9d | |
|
|
8ba6a71a49 | |
|
|
2f625572fc | |
|
|
ac56916eb0 | |
|
|
ae08a5051c | |
|
|
d372cd638e | |
|
|
60c5cb7e59 | |
|
|
607a4c9ff8 | |
|
|
4ea16cfa8f | |
|
|
6ce9880bc0 | |
|
|
187ffad7ee | |
|
|
467f95424e | |
|
|
c1a5518fb7 | |
|
|
22fa4b3ccf | |
|
|
1b8a51aad0 | |
|
|
578ade3544 | |
|
|
ed2f1bb5ee | |
|
|
0a04972455 | |
|
|
2a4379eb64 | |
|
|
e64df23bb7 | |
|
|
576ae925dd | |
|
|
e170991222 | |
|
|
a1343ee18f | |
|
|
b2d3c935d8 | |
|
|
49089eee4b | |
|
|
e544992c9f | |
|
|
97a1a3089a | |
|
|
64f18e97a0 | |
|
|
9cbea27455 | |
|
|
c8881f9e4d | |
|
|
028e3b2240 | |
|
|
c042f27106 | |
|
|
4ceadf8be5 | |
|
|
ddd30b3059 | |
|
|
6c8ba5fe2d | |
|
|
32ddac002f | |
|
|
bbbe69d928 | |
|
|
23c39d5bba | |
|
|
5328dc8595 | |
|
|
0c03b4f161 | |
|
|
31fe70bab5 | |
|
|
39251b3d32 | |
|
|
0623de32a0 | |
|
|
cd5e6e7ee6 | |
|
|
ee3e0a0af6 | |
|
|
0783b1b99d | |
|
|
5492c7a46f | |
|
|
4cbe184faa | |
|
|
647a1cb726 | |
|
|
73263b69a6 | |
|
|
c62ceafdc2 | |
|
|
9b5a35cb4a | |
|
|
5ac1a0a522 | |
|
|
a28b46a809 | |
|
|
59c8d36a3d | |
|
|
79ba7b8487 | |
|
|
ba62cec78c | |
|
|
f15b3f583f | |
|
|
402bddc18a | |
|
|
350069d371 | |
|
|
019c078393 | |
|
|
d411df4074 | |
|
|
763e0d79ab | |
|
|
47fe4ad655 | |
|
|
99cb83202e | |
|
|
fc2438be2d | |
|
|
40e48b046b | |
|
|
f801d99748 | |
|
|
6ab6196739 | |
|
|
eb12281dfc | |
|
|
1fb1fdef9e | |
|
|
593d26e0ff | |
|
|
394abb58be | |
|
|
fc3bee6d05 | |
|
|
b203503fd8 | |
|
|
8b0a72dc9b | |
|
|
23add7a107 | |
|
|
caf84196fb | |
|
|
099b9651a6 | |
|
|
76d72a1eef | |
|
|
87deede078 | |
|
|
6fec8588c1 | |
|
|
aafdbcd57a | |
|
|
08da28f475 | |
|
|
8c1014d281 | |
|
|
271fc4edf6 | |
|
|
cd390a4115 | |
|
|
a65ed579d2 | |
|
|
2ad1f57382 | |
|
|
58e3cfd3e6 | |
|
|
7de4ee8f5b | |
|
|
213edac3a7 | |
|
|
acc13aef6b | |
|
|
4bbc6f7787 | |
|
|
20f2ea1829 | |
|
|
2d23f0668b | |
|
|
d6030a06b7 | |
|
|
0df76e2af6 | |
|
|
f80fe1df93 | |
|
|
cff421bc53 | |
|
|
bca925d385 | |
|
|
9abde89805 | |
|
|
ea4f639fcb | |
|
|
64cd5f7d52 | |
|
|
843ebe1d8f | |
|
|
764619f076 | |
|
|
e3a0cfb55a | |
|
|
2929749314 | |
|
|
51b9512f4e | |
|
|
ffcfa44eef | |
|
|
6363da97b1 | |
|
|
07ed2cdcf6 | |
|
|
5ad68b7dfc | |
|
|
8a6ee018ea | |
|
|
b42590ff95 | |
|
|
056b579c47 | |
|
|
576e612cd0 | |
|
|
c2faa06a15 | |
|
|
d3ed3556eb | |
|
|
d960d125c0 | |
|
|
89d5d7ec0a | |
|
|
ea0c13936b | |
|
|
773c976822 | |
|
|
cd05ed2379 | |
|
|
054e4057d8 | |
|
|
3943e79bb1 | |
|
|
87f4317c15 | |
|
|
50aa793910 | |
|
|
5efc9865a8 | |
|
|
949c573c49 | |
|
|
f7f450a09d | |
|
|
81f7c38383 | |
|
|
2c785cb37a | |
|
|
57e65b061c | |
|
|
aa54765b03 | |
|
|
8929bc99bb | |
|
|
0428c06612 | |
|
|
a7eb3cf433 | |
|
|
e4e0e793a8 | |
|
|
b3d3b8b6bc | |
|
|
06bc456221 | |
|
|
3461f45207 | |
|
|
a17d4acc13 | |
|
|
62fd9193a1 | |
|
|
2329645df4 | |
|
|
8a435ddf6c | |
|
|
25b754ba31 | |
|
|
b734593bf2 | |
|
|
16847ce6f7 | |
|
|
6300829317 | |
|
|
a1e1ee31bd | |
|
|
7ed70b876d | |
|
|
3ca85da906 | |
|
|
d6a89168ef | |
|
|
cb33a20694 | |
|
|
a242693751 | |
|
|
81ca3cc7a7 | |
|
|
1a32098c9e | |
|
|
fa4c32270b | |
|
|
9c43b875f4 | |
|
|
63560e290b | |
|
|
1ab8a6a2fe | |
|
|
a2c0196e05 | |
|
|
680f7a64e2 | |
|
|
4893616a5a | |
|
|
04e8c0245d | |
|
|
10cefaf1cd | |
|
|
adbb1fe80a | |
|
|
79c50aedcc | |
|
|
eb72b35e23 | |
|
|
bbd02d46a6 | |
|
|
3d3c8ce973 | |
|
|
562f929056 | |
|
|
ff03d8ce62 | |
|
|
8281131432 | |
|
|
8a6bd4e0e7 | |
|
|
1b4df0565a | |
|
|
eb3692ef81 |
44
.env.example
44
.env.example
|
|
@ -1,20 +1,50 @@
|
|||
# ARIA Environment Configuration
|
||||
# Copy to .env and fill in values
|
||||
# ════════════════════════════════════════════════
|
||||
# ARIA — Umgebungsvariablen
|
||||
# Kopieren nach .env und Werte eintragen
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
# Auth token for ARIA Core (generate a long random string)
|
||||
# openssl rand -hex 32
|
||||
# ── ARIA Auth Token ──────────────────────────────
|
||||
# Authentifizierung fuer den OpenClaw Gateway (aria-core).
|
||||
# Wird von Diagnostic, Bridge und App genutzt um sich am Gateway anzumelden.
|
||||
# Alle Services die mit aria-core kommunizieren brauchen diesen Token.
|
||||
# Generieren: openssl rand -hex 32
|
||||
ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
|
||||
|
||||
# RVS — Rendezvous-Server (Bridge + App verbinden sich hierüber)
|
||||
# ── RVS — Rendezvous-Server ─────────────────────
|
||||
# Der RVS ist ein WebSocket-Relay im Rechenzentrum.
|
||||
# App, Bridge, Diagnostic und XTTS-Bridge verbinden sich hierueber.
|
||||
# Alle muessen den gleichen Host, Port und Token nutzen.
|
||||
|
||||
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
|
||||
RVS_HOST=rvs.example.de
|
||||
|
||||
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
|
||||
RVS_PORT=443
|
||||
|
||||
# TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://)
|
||||
RVS_TLS=true
|
||||
|
||||
# Bei TLS-Fehler automatisch auf ws:// (ohne TLS) fallback?
|
||||
# true = Fallback erlaubt, false = nur mit TLS verbinden
|
||||
# Nuetzlich wenn kein TLS-Zertifikat vorhanden (z.B. Entwicklung)
|
||||
RVS_TLS_FALLBACK=true
|
||||
|
||||
# Pairing-Token: Wer den gleichen Token hat, landet im gleichen RVS-Room.
|
||||
# Wird von generate-token.sh automatisch generiert und hier eingetragen.
|
||||
# Die Android App bekommt den Token per QR-Code beim Pairing.
|
||||
# WICHTIG: Muss auf ARIA-VM, Gaming-PC (xtts/.env) und App identisch sein!
|
||||
# Generieren: ./generate-token.sh (traegt den Token automatisch ein)
|
||||
RVS_TOKEN=
|
||||
|
||||
# Gitea (for release.sh — Kennwort wird interaktiv abgefragt)
|
||||
# ── Gitea — Release-Verwaltung ───────────────────
|
||||
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
|
||||
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
|
||||
GITEA_URL=https://git.hacker-net.de
|
||||
GITEA_REPO=Hacker-Software/ARIA-AGENT
|
||||
GITEA_USER=duffyduck
|
||||
|
||||
# ── Auto-Update — APK auf RVS-Server kopieren ───
|
||||
# SSH-Ziel fuer scp: release.sh kopiert die APK dorthin.
|
||||
# Der RVS-Server stellt sie dann per WebSocket an die App bereit.
|
||||
# Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de)
|
||||
# Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren.
|
||||
RVS_UPDATE_HOST=
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ android/local.properties
|
|||
android/package-lock.json
|
||||
*.apk
|
||||
*.aab
|
||||
rvs/updates/*.apk
|
||||
|
||||
# ── Tauri / Desktop Build ───────────────────────
|
||||
desktop/src-tauri/target/
|
||||
|
|
|
|||
400
README.md
400
README.md
|
|
@ -29,11 +29,26 @@ ARIA hat zwei Rollen:
|
|||
┌─────────────────────────────────────────────────────────┐
|
||||
│ RVS — Rendezvous-Server │
|
||||
│ Node.js WebSocket Relay (Docker, Rechenzentrum) │
|
||||
│ Reiner Relay — kennt keine Tokens, leitet durch │
|
||||
│ Relay + Auto-Update (APK-Verteilung) │
|
||||
│ rvs/docker-compose.yml │
|
||||
└───────────────────────┬─────────────────────────────────┘
|
||||
│ WebSocket Tunnel
|
||||
▼
|
||||
└───────────┬───────────────────────────┬─────────────────┘
|
||||
│ WebSocket Tunnel │ WebSocket Tunnel
|
||||
▼ ▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Gamebox (Windows + WSL2) │
|
||||
│ RTX 3060, Docker Desktop │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ aria-f5tts-bridge │ │
|
||||
│ │ F5-TTS Voice Cloning │ │
|
||||
│ │ PCM-Streaming an die App │ │
|
||||
│ ├──────────────────────────┤ │
|
||||
│ │ aria-whisper-bridge │ │
|
||||
│ │ Faster-Whisper CUDA │ │
|
||||
│ │ STT in fast-Echtzeit │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ Beide teilen ./voices Volume │
|
||||
│ xtts/docker-compose.yml │
|
||||
└─────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ARIA-VM (Proxmox, Debian 13) — ARIAs Wohnung │
|
||||
│ Basissystem + Docker. Rest richtet ARIA selbst ein. │
|
||||
|
|
@ -50,8 +65,10 @@ ARIA hat zwei Rollen:
|
|||
│ │ Liest BOOTSTRAP.md + AGENT.md │ │
|
||||
│ │ │ │
|
||||
│ │ [bridge] ARIA Voice Bridge Container │ │
|
||||
│ │ Whisper STT · Piper TTS · Wake-Word │ │
|
||||
│ │ Ramona (weiblich) + Thorsten (tief) │ │
|
||||
│ │ Wake-Word (lokales Mikro auf VM) │ │
|
||||
│ │ STT primaer remote (Gamebox-Whisper) │ │
|
||||
│ │ Fallback: lokales faster-whisper (CPU) │ │
|
||||
│ │ TTS via F5-TTS auf Gamebox │ │
|
||||
│ │ Bruecke: App <> RVS <> Bridge <> ARIA │ │
|
||||
│ │ │ │
|
||||
│ │ [diagnostic] Selbstcheck-UI + Einstellungen │ │
|
||||
|
|
@ -66,13 +83,17 @@ ARIA hat zwei Rollen:
|
|||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Drei separate Deployments:**
|
||||
**Vier separate Deployments:**
|
||||
|
||||
| Was | Wo | Wie |
|
||||
|-----|----|-----|
|
||||
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
||||
| ARIA Core | Debian 13 VM | `docker compose up -d && ./aria-setup.sh` |
|
||||
| Android App | Stefans Handy | APK installieren, QR-Code scannen |
|
||||
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
|
||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||
|
||||
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
||||
> langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -95,16 +116,31 @@ cd ~/ARIA-AGENT
|
|||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env` Datei editieren:
|
||||
`.env` Datei editieren (Details siehe `.env.example`):
|
||||
```bash
|
||||
# Gateway-Auth: Alle Services die mit aria-core reden brauchen diesen Token
|
||||
# Diagnostic, Bridge, App nutzen ihn fuer den WebSocket-Handshake
|
||||
ARIA_AUTH_TOKEN= # openssl rand -hex 32
|
||||
|
||||
# RVS-Verbindung: Hostname + Port deines Rendezvous-Servers
|
||||
RVS_HOST= # z.B. rvs.hackersoft.de
|
||||
RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TLS_FALLBACK=true
|
||||
RVS_TOKEN= # wird von generate-token.sh automatisch gesetzt
|
||||
|
||||
# Pairing-Token: Verbindet App, Bridge, Diagnostic und XTTS im gleichen RVS-Room
|
||||
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Gaming-PC, App)
|
||||
# Wird von generate-token.sh automatisch generiert und eingetragen
|
||||
RVS_TOKEN= # ./generate-token.sh
|
||||
|
||||
# Optional: SSH-Host des RVS-Servers fuer Auto-Update (z.B. root@aria-rvs)
|
||||
RVS_UPDATE_HOST=
|
||||
```
|
||||
|
||||
**Zwei Tokens, zwei Zwecke:**
|
||||
- **ARIA_AUTH_TOKEN**: Authentifizierung am OpenClaw Gateway (aria-core). Wer diesen Token hat, kann ARIA Befehle geben.
|
||||
- **RVS_TOKEN**: Pairing-Token fuer den Rendezvous-Server. Alle Geraete mit dem gleichen Token landen im gleichen "Room" und koennen kommunizieren. Die App bekommt diesen Token per QR-Code.
|
||||
|
||||
### 2. Claude CLI einloggen (Proxy-Auth)
|
||||
|
||||
Der Proxy-Container nutzt deine Claude Max Subscription. Die Credentials muessen
|
||||
|
|
@ -120,21 +156,17 @@ claude login
|
|||
**Wichtig:** Der Ordner `~/.claude/` (nicht `~/.config/claude/`!) wird als Volume
|
||||
in den Proxy gemountet. Die Credentials ueberleben Container-Restarts.
|
||||
|
||||
### 3. Stimmen herunterladen
|
||||
|
||||
```bash
|
||||
./get-voices.sh
|
||||
# Laedt Ramona + Thorsten (Piper TTS) nach aria-data/voices/
|
||||
# Ca. 100MB, dauert ein paar Minuten
|
||||
```
|
||||
|
||||
### 4. Voice Bridge konfigurieren
|
||||
### 3. Voice Bridge konfigurieren
|
||||
|
||||
```bash
|
||||
cp aria-data/config/aria.env.example aria-data/config/aria.env
|
||||
# Bei Bedarf anpassen (Whisper-Modell, Sprache, Stimmen-Pfade)
|
||||
# Bei Bedarf anpassen (Whisper-Modell als Fallback, Sprache, Wake-Word)
|
||||
```
|
||||
|
||||
STT laeuft primaer auf der Gamebox (faster-whisper auf GPU), TTS ausschliesslich
|
||||
ueber F5-TTS auf der Gamebox — siehe Abschnitt "Gamebox-Stack — F5-TTS + Whisper"
|
||||
weiter unten.
|
||||
|
||||
### 5. RVS-Token generieren & Container starten
|
||||
|
||||
```bash
|
||||
|
|
@ -230,7 +262,6 @@ Danach werden per `sed` vier Patches angewendet:
|
|||
- Sicherheitsregeln (kein ClawHub, Prompt Injection abwehren)
|
||||
- Tool-Freigaben (alle Claude Code Tools: WebFetch, Bash, etc.)
|
||||
- SSH-Zugriff auf aria-wohnung (VM)
|
||||
- Stimmen-Auswahl (Ramona vs Thorsten)
|
||||
- Gedaechtnis-System
|
||||
|
||||
### openclaw.json (via aria-setup.sh)
|
||||
|
|
@ -267,25 +298,34 @@ braucht ARIA mehrere API-Roundtrips.
|
|||
|
||||
## Voice Bridge
|
||||
|
||||
Die Bridge verbindet die Android App mit ARIA und bietet lokale Sprachverarbeitung.
|
||||
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
|
||||
auf der Gamebox.
|
||||
|
||||
**Nachrichtenfluss:**
|
||||
```
|
||||
Text: App → RVS → Bridge → chat.send → aria-core
|
||||
Audio: App → RVS → Bridge → FFmpeg → Whisper STT → chat.send → aria-core
|
||||
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox)
|
||||
→ stt_response → Bridge → chat.send → aria-core
|
||||
Fallback bei Timeout: lokales faster-whisper (CPU)
|
||||
Datei: App → RVS → Bridge → /shared/uploads/ → chat.send (mit Pfad) → aria-core
|
||||
|
||||
aria-core → Antwort → Gateway → Diagnostic → RVS → App
|
||||
→ Bridge → Piper TTS → RVS → App (Audio)
|
||||
→ Bridge → Lautsprecher (lokal)
|
||||
→ Bridge → xtts_request (RVS) → f5tts-bridge
|
||||
→ audio_pcm Stream → RVS → App AudioTrack
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **STT**: faster-whisper (lokal, offline, 16kHz mono)
|
||||
- **TTS**: Piper (Ramona + Thorsten, offline)
|
||||
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM)
|
||||
- **App-Audio**: Base64 Audio von App → FFmpeg → Whisper STT → Text an aria-core
|
||||
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper
|
||||
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales
|
||||
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast.
|
||||
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit
|
||||
Referenz-Audio + automatisch transkribiertem Referenz-Text.
|
||||
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
|
||||
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
|
||||
ausgeschrieben (`0,1` → "null komma eins"). Acronyme bis 5 Buchstaben werden
|
||||
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
|
||||
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
|
||||
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
||||
|
||||
### Betriebsmodi
|
||||
|
|
@ -298,13 +338,6 @@ aria-core → Antwort → Gateway → Diagnostic → RVS → App
|
|||
| Hangar | `"ARIA, ich arbeite"` | Nur wichtige Meldungen |
|
||||
| Gaming | `"ARIA, Gaming-Modus"` | Nur auf direkte Fragen antworten |
|
||||
|
||||
### Stimmen
|
||||
|
||||
| Stimme | Modell | Wann |
|
||||
|--------|--------|------|
|
||||
| **Ramona** (weiblich) | `de_DE-ramona-low` | Alltag, Antworten, Gespraeche |
|
||||
| **Thorsten** (maennlich, tief) | `de_DE-thorsten-high` | Epische Momente, Alarme |
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic — Selbstcheck-UI und Einstellungen
|
||||
|
|
@ -314,13 +347,21 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit aria-core.
|
|||
### Features
|
||||
|
||||
- **Status-Karten**: Gateway (Handshake), RVS (TLS-Fallback), Proxy (Auth)
|
||||
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS)
|
||||
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen
|
||||
- **Disk-Voll Banner**: Rotes Overlay wenn die VM-Disk knapp wird, mit copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||
- **Chat-Test**: Nachrichten direkt an ARIA senden (Gateway oder via RVS), Vollbild-Modus
|
||||
- **"ARIA denkt..." Indikator**: Zeigt live was ARIA gerade tut (Denken, Tool, Schreiben)
|
||||
- **Abbrechen-Button**: Stoppt laufende Anfragen + doctor --fix
|
||||
- **Session-Verwaltung**: Sessions auflisten, wechseln, erstellen, loeschen, als Markdown exportieren (⬇ Button)
|
||||
- **Chat-History**: Wird beim Laden und Session-Wechsel angezeigt (read-only aus JSONL)
|
||||
- **TTS-Diagnose Tab**: Stimmen testen, Status pruefen, Fehler anzeigen
|
||||
- **Einstellungen**: TTS aktiv-Toggle, F5-TTS-Voice (gecloned), Betriebsmodi, Whisper-Modell (tiny…large-v3, Hot-Reload auf der Gamebox)
|
||||
- **Voice-Status**: Beim Wechsel der globalen Stimme zeigt ein Status-Text "Lade…" → "bereit (X.Ys)" — getriggert ueber `voice_preload`/`voice_ready`
|
||||
- **Voice Cloning**: Audio-Samples hochladen, Referenz-Text wird automatisch via Whisper transkribiert
|
||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||
- **Core Terminal**: Shell in aria-core (openclaw CLI)
|
||||
- **Container-Logs**: Echtzeit-Logs aller Container (gefiltert nach Tab)
|
||||
- **Container-Logs**: Echtzeit-Logs aller Container (gefiltert nach Tab + Pipeline)
|
||||
- **SSH Terminal**: Direkter SSH-Zugang zu aria-wohnung
|
||||
- **Watchdog**: Erkennt stuck Runs (2min Warnung → 5min doctor --fix → 8min Container-Restart)
|
||||
|
||||
### Session-Verwaltung
|
||||
|
||||
|
|
@ -338,15 +379,65 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
|
|||
|
||||
- Text-Chat mit ARIA
|
||||
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
|
||||
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
|
||||
- **STT (Speech-to-Text)**: Audio wird in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
|
||||
- **Wake Word**: Toggle-Button (Ohr-Symbol) aktiviert kontinuierliches Mikrofon-Monitoring
|
||||
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Ramona/Thorsten)
|
||||
- **Datei- und Bild-Upload**: Bilder inline im Chat, Dateien mit Icon + Name + Groesse
|
||||
- **Anhaenge**: Bridge speichert Dateien in Shared Volume (`/shared/uploads/`), ARIA kann darauf zugreifen
|
||||
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
|
||||
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
|
||||
- **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s.
|
||||
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||
- **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
|
||||
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
||||
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS
|
||||
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||
- GPS-Position (optional)
|
||||
- QR-Code Scanner fuer Token-Pairing
|
||||
|
||||
### Wake-Word (openWakeWord, on-device)
|
||||
|
||||
Wake-Word-Erkennung laeuft komplett **on-device** ueber [openWakeWord](https://github.com/dscripka/openWakeWord)
|
||||
mit ONNX Runtime — kein API-Key, kein Cloud-Roundtrip, kein Cent Lizenzgebuehren,
|
||||
und das Audio verlaesst das Geraet nie.
|
||||
|
||||
**Mitgelieferte Wake-Words** (ONNX-Dateien in `android/android/app/src/main/assets/openwakeword/`):
|
||||
- `Hey Jarvis` (Default, openWakeWord-Original)
|
||||
- `Computer` (Star-Trek-Style, Community-Modell)
|
||||
- `Alexa`, `Hey Mycroft`, `Hey Rhasspy` (openWakeWord-Originale)
|
||||
|
||||
Community-Modelle stammen aus [fwartner/home-assistant-wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection).
|
||||
|
||||
**Bedienung:**
|
||||
- App → **Einstellungen** → **Wake-Word** → gewuenschtes Keyword waehlen → **Speichern + Aktivieren**
|
||||
- **Ohr-Button (👂)** in der Statusleiste tippen → Wake-Word ist scharf, App hoert passiv mit
|
||||
- Wake-Word sagen → Symbol wechselt auf 🎙️, Konversation laeuft
|
||||
- Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂
|
||||
- Erneut tippen → Ohr aus (🔇)
|
||||
|
||||
**Eigene Wake-Words trainieren** (gratis, ~30 Min):
|
||||
|
||||
1. openWakeWord Trainings-Notebook auf Colab oeffnen (Link im
|
||||
[openWakeWord Repo](https://github.com/dscripka/openWakeWord) unter "Training Custom Models")
|
||||
2. Wake-Word-Phrase eingeben (z.B. "ARIA", "Hey Stefan"), Notebook ausfuehren —
|
||||
das Notebook generiert synthetische Trainings-Beispiele und trainiert das Modell.
|
||||
3. Resultierende `.onnx`-Datei runterladen
|
||||
4. Datei in `android/android/app/src/main/assets/openwakeword/` ablegen
|
||||
5. In `android/src/services/wakeword.ts` den Dateinamen (ohne `.onnx`) zur
|
||||
`WAKE_KEYWORDS`-Liste hinzufuegen
|
||||
6. APK neu bauen
|
||||
|
||||
*(Diagnostic-Upload fuer Custom-`.onnx` ohne Rebuild kommt spaeter.)*
|
||||
|
||||
**Tuning** (in [wakeword.ts](android/src/services/wakeword.ts)):
|
||||
- `DEFAULT_THRESHOLD = 0.5` — Score-Schwelle (raise auf 0.6–0.7 bei False-Positives)
|
||||
- `DEFAULT_PATIENCE = 2` — wie viele Frames ueber Threshold noetig
|
||||
- `DEFAULT_DEBOUNCE_MS = 1500` — Mindestabstand zwischen zwei Triggern
|
||||
|
||||
### Ersteinrichtung (Dev-Maschine, einmalig)
|
||||
|
||||
```bash
|
||||
|
|
@ -374,27 +465,50 @@ cd android
|
|||
```
|
||||
|
||||
Das Script macht alles in einem Schritt:
|
||||
1. Fragt Gitea-Kennwort ab (wird nirgends gespeichert)
|
||||
2. Baut die Release-APK
|
||||
3. Erstellt Git Tag + pusht
|
||||
4. Erstellt Gitea Release
|
||||
5. Laedt APK als Asset hoch
|
||||
1. Setzt Versionsnummern (package.json, build.gradle, SettingsScreen)
|
||||
2. Fragt Gitea-Kennwort ab (wird nirgends gespeichert)
|
||||
3. Baut die Release-APK
|
||||
4. Git Commit + Tag + Push
|
||||
5. Erstellt Gitea Release + laedt APK hoch
|
||||
6. Kopiert APK auf RVS-Server (Auto-Update, optional)
|
||||
|
||||
Voraussetzung in `.env`:
|
||||
```bash
|
||||
GITEA_URL=https://gitea.hackersoft.de
|
||||
GITEA_REPO=stefan/aria-agent
|
||||
GITEA_USER=stefan
|
||||
RVS_UPDATE_HOST=root@aria-rvs # Optional: fuer Auto-Update
|
||||
```
|
||||
|
||||
### Docker-Cleanup
|
||||
|
||||
Das Bridge-Image zieht grosse ML-Deps (faster-whisper, ctranslate2, onnxruntime,
|
||||
openwakeword) — bei jedem Rebuild waechst der Docker-Build-Cache. Wenn
|
||||
die VM voll laeuft:
|
||||
|
||||
```bash
|
||||
./cleanup.sh # sicher: Build-Cache + ungenutzte Images
|
||||
./cleanup.sh --full # aggressiv: zusaetzlich ungenutzte Volumes (mit Rueckfrage)
|
||||
```
|
||||
|
||||
### Auto-Update
|
||||
|
||||
Die App prueft beim Start ob eine neuere Version auf dem RVS liegt.
|
||||
Der Update-Flow:
|
||||
1. `./release.sh 0.0.3.0` → APK wird auf RVS kopiert (via scp)
|
||||
2. Alternativ: `git pull` auf dem RVS-Server → APK in `rvs/updates/`
|
||||
3. App sendet `update_check` mit aktueller Version
|
||||
4. RVS vergleicht → sendet `update_available`
|
||||
5. App zeigt Dialog → Download ueber WebSocket → Installation
|
||||
|
||||
### Audio-Pipeline (Spracheingabe)
|
||||
|
||||
```
|
||||
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
|
||||
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core
|
||||
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
|
||||
aria-core → Antwort → Bridge → Piper TTS (WAV) → Base64 → RVS → App
|
||||
App: Base64 → WAV → Lautsprecher
|
||||
aria-core → Antwort → Bridge → XTTS (Gaming-PC) → PCM-Stream → RVS → App
|
||||
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
|
||||
```
|
||||
|
||||
### Datei-Pipeline (Bilder & Anhaenge)
|
||||
|
|
@ -442,10 +556,6 @@ aria-data/
|
|||
│
|
||||
├── skills/ ← ARIAs Faehigkeiten (selbst geschrieben!)
|
||||
│
|
||||
├── voices/ ← Piper TTS Stimmen (offline)
|
||||
│ ├── de_DE-ramona-low.onnx
|
||||
│ └── de_DE-thorsten-high.onnx
|
||||
│
|
||||
├── config/
|
||||
│ ├── BOOTSTRAP.md ← System-Prompt (Identitaet, Regeln, Tools)
|
||||
│ ├── AGENT.md ← Persoenlichkeit & Arbeitsprinzipien
|
||||
|
|
@ -454,6 +564,11 @@ aria-data/
|
|||
│ ├── aria.env ← Voice Bridge Config
|
||||
│ └── diag-state/ ← Diagnostic persistenter State
|
||||
│
|
||||
│ (im Shared Volume /shared/config/):
|
||||
│ ├── voice_config.json ← TTS-Einstellungen (Stimme, Speed, Engine)
|
||||
│ ├── highlight_triggers.json ← Highlight-Trigger Woerter
|
||||
│ └── chat_backup.jsonl ← Nachrichten-Backup (on-the-fly)
|
||||
│
|
||||
└── ssh/ ← SSH Keys fuer VM-Zugriff
|
||||
├── id_ed25519 ← Private Key (generiert von aria-setup.sh)
|
||||
├── id_ed25519.pub ← Public Key (muss in VM authorized_keys!)
|
||||
|
|
@ -469,7 +584,7 @@ tar -czf aria-backup-$(date +%Y%m%d).tar.gz aria-data/
|
|||
|
||||
## RVS — Rendezvous-Server
|
||||
|
||||
Laeuft im Rechenzentrum. Reiner Relay — kennt keine Tokens, speichert nichts.
|
||||
Laeuft im Rechenzentrum. WebSocket Relay + Auto-Update Server.
|
||||
Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
|
||||
|
||||
```bash
|
||||
|
|
@ -477,10 +592,133 @@ cd rvs
|
|||
docker compose up -d
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
|
||||
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
|
||||
- Heartbeat + tote Verbindungen aufraeumen
|
||||
|
||||
**Auto-Update APK bereitstellen:**
|
||||
```bash
|
||||
# APK in updates/ legen (manuell oder via release.sh)
|
||||
cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
|
||||
# RVS erkennt die Version aus dem Dateinamen
|
||||
```
|
||||
|
||||
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
|
||||
|
||||
---
|
||||
|
||||
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
|
||||
|
||||
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060).
|
||||
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
|
||||
ueber verschiedene Netze hinweg.
|
||||
|
||||
### Architektur
|
||||
|
||||
```
|
||||
Gamebox (Windows, RTX 3060, Docker Desktop + WSL2)
|
||||
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay
|
||||
│ Hoert auf xtts_request, streamt audio_pcm
|
||||
├── aria-whisper-bridge faster-whisper auf CUDA (float16)
|
||||
│ Hoert auf stt_request, antwortet mit stt_response
|
||||
└── ./voices/ Geteilt zwischen beiden:
|
||||
{name}.wav — Referenz-Audio (~6-10s)
|
||||
{name}.txt — Referenz-Text (auto via Whisper)
|
||||
|
||||
↕ RVS (Rechenzentrum, WebSocket Relay)
|
||||
|
||||
ARIA-VM
|
||||
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
|
||||
TTS via xtts_request → audio_pcm Stream
|
||||
```
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
|
||||
- NVIDIA Container Toolkit
|
||||
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
|
||||
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd xtts
|
||||
cp .env.example .env
|
||||
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
|
||||
docker compose up -d
|
||||
# Erster Start laedt die Modelle (Whisper ~1-3GB je nach Groesse, F5-TTS ~1GB)
|
||||
```
|
||||
|
||||
Die Modelle werden in den Volumes `f5tts-models` und `whisper-models` gecacht
|
||||
und muessen nur einmal geladen werden.
|
||||
|
||||
### Features
|
||||
|
||||
**F5-TTS (Sprachausgabe):**
|
||||
- Hochqualitatives Voice Cloning auf Basis von 6-10s Referenz-Audio
|
||||
- Renderzeit ~0.3x Realtime auf RTX 3060 (RTF ≈ 0.3)
|
||||
- Satzweises Streaming, fade-in auf erstem Chunk gegen Warmup-Glitches
|
||||
- Sequentielle Queue gegen GPU-OOM bei parallelen Requests
|
||||
|
||||
**Whisper (Spracherkennung):**
|
||||
- faster-whisper mit CUDA + float16 — fast Echtzeit-Transkription
|
||||
- Modelle: tiny / base / small / medium / large-v3 (Hot-Swap via Diagnostic)
|
||||
- Wird zusaetzlich von der f5tts-bridge intern genutzt um den Referenz-Text
|
||||
beim Voice-Upload automatisch zu erzeugen
|
||||
|
||||
### TTS-Config
|
||||
|
||||
In der Diagnostic unter Einstellungen → Sprachausgabe:
|
||||
- **TTS aktiv**: Global An/Aus
|
||||
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
|
||||
|
||||
> F5-TTS ist die einzige Engine — wenn die Gamebox offline ist, bleibt ARIA stumm.
|
||||
> Chat-Antworten kommen weiter an (nur kein Audio).
|
||||
|
||||
### Stimme klonen
|
||||
|
||||
1. App oder Diagnostic → "Stimme klonen" → Audio-Dateien hochladen
|
||||
(WAV/MP3, 1-10 Dateien, ~6-10s gesamt)
|
||||
2. Name vergeben → "Stimme erstellen"
|
||||
3. f5tts-bridge speichert das WAV, schickt einen `stt_request` an die
|
||||
whisper-bridge, legt die Transkription als `.txt` daneben ab und meldet
|
||||
`xtts_voice_saved` zurueck. Der Toast in der App zeigt "Stimme bereit".
|
||||
4. Stimme auswaehlen → ein Voice-Preload (stiller Mini-Render) waermt die
|
||||
Latents auf, "voice_ready" Toast bestaetigt es.
|
||||
|
||||
> **Tipp:** Fuer beste Ergebnisse: saubere Aufnahme, eine Stimme, kein Hintergrund,
|
||||
> 10-30 Sekunden Gesamtlaenge. Mehrere kurze Dateien werden zusammengefuegt.
|
||||
|
||||
### Deutsches Fine-Tune (bessere Qualitaet auf Deutsch)
|
||||
|
||||
Das Default-Modell `F5TTS_v1_Base` ist primaer auf Englisch + Chinesisch trainiert
|
||||
und liefert auf Deutsch merklich schwaechere Voice-Cloning-Qualitaet als XTTS es
|
||||
tat. Community-Fine-Tune von [aihpi](https://huggingface.co/aihpi/F5-TTS-German)
|
||||
auf dem Emilia-Dataset + Common Voice 19.0 funktioniert deutlich besser.
|
||||
|
||||
**Konfiguration ueber Diagnostic → "F5-TTS Modell-Tuning (advanced)":**
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| Modell-Architektur | `F5TTS_Base` *(nicht v1_Base! Fine-Tune basiert auf der alten Architektur)* |
|
||||
| Custom Checkpoint | `hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors` |
|
||||
| Custom Vocab | `hf://aihpi/F5-TTS-German/vocab.txt` |
|
||||
| cfg_strength | `2.0` |
|
||||
| nfe_step | `32` |
|
||||
|
||||
→ "Anwenden" klicken. Die `hf://`-Pfade werden einmalig automatisch runter-
|
||||
geladen (~3-5GB, landet im `xtts/hf-cache/`) und bei Container-Restart aus
|
||||
dem Cache wiederverwendet.
|
||||
|
||||
> **Warnung zur BigVGAN-Variante** (`F5TTS_Base_bigvgan/model_295000.safetensors`):
|
||||
> funktioniert AKTUELL NICHT mit dieser Bridge. Die f5-tts Library laedt
|
||||
> per Default den Vocos-Vocoder, die BigVGAN-Weights sind damit inkompatibel
|
||||
> → Modell produziert NaN, App bleibt stumm. Nur die **Vocos-Variante
|
||||
> (F5TTS_Base/model_365000.safetensors)** nutzen.
|
||||
|
||||
---
|
||||
|
||||
## Docker Volumes
|
||||
|
||||
| Volume | Pfad im Container | Zweck |
|
||||
|
|
@ -491,7 +729,7 @@ docker compose up -d
|
|||
| `./aria-data/ssh` (bind) | `/root/.ssh`, `/home/node/.ssh` | SSH Keys |
|
||||
| `./aria-data/brain` (bind) | `/home/node/.openclaw/workspace/memory` | Gedaechtnis |
|
||||
| `./aria-data/skills` (bind) | `/home/node/.openclaw/workspace/skills` | Skills |
|
||||
| `aria-shared` | `/shared` (Core + Bridge) | Datei-Austausch (Uploads von App) |
|
||||
| `aria-shared` | `/shared` (Core + Bridge + Proxy + Diag) | Datei-Austausch, Config, Uploads |
|
||||
| `./aria-data/config/diag-state` (bind) | `/data` (Diagnostic) | Persistenter State (aktive Session) |
|
||||
|
||||
---
|
||||
|
|
@ -546,9 +784,13 @@ docker exec aria-core ssh aria-wohnung hostname
|
|||
- **Proxy Cold Start**: Jede Nachricht spawnt einen neuen `claude --print` Prozess.
|
||||
Dadurch ist ARIA langsamer als die direkte Claude CLI. Timeout ist auf 900s (15 Min).
|
||||
- **Kein Streaming zur App**: Die App zeigt erst die fertige Antwort, keine Streaming-Tokens.
|
||||
- **Wake Word nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM.
|
||||
In der App gibt es Energy-basierte Erkennung (Phase 1). On-device "ARIA"-Keyword (Porcupine) ist Phase 2.
|
||||
- **Wake-Word in der App nur eingebaute Keywords**: `Hey Jarvis`, `Alexa`, `Hey Mycroft`,
|
||||
`Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als
|
||||
`.onnx`-Datei ins App-Bundle gelegt + zur Liste in `wakeword.ts` hinzugefuegt werden.
|
||||
Die Diagnostic-Upload-UI ist Phase 2.
|
||||
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
|
||||
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
|
||||
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
|
||||
- **Bildanalyse eingeschraenkt**: Bilder werden in `/shared/uploads/` gespeichert. ARIA kann
|
||||
sie per Bash/Read-Tool oeffnen, aber Claude Vision (direkte Bildanalyse) ist ueber den
|
||||
Proxy-Pfad (`claude --print`) noch nicht moeglich. ARIA sieht den Dateipfad, nicht das Bild.
|
||||
|
|
@ -569,8 +811,38 @@ docker exec aria-core ssh aria-wohnung hostname
|
|||
- [x] Android App (Chat + Sprache + Uploads)
|
||||
- [x] Tool-Permissions (alle Tools freigeschaltet)
|
||||
- [x] SSH-Zugriff auf VM (aria-wohnung)
|
||||
- [x] Diagnostic Web-UI
|
||||
- [x] Diagnostic Web-UI + Einstellungen
|
||||
- [x] Session-Verwaltung + Chat-History
|
||||
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed, Highlight-Trigger) — durch XTTS v2 Voice Cloning ersetzt
|
||||
- [x] Piper komplett entfernt — nur noch XTTS v2 als TTS (Gaming-PC)
|
||||
- [x] Streaming TTS: PCM-Chunks direkt in AudioTrack, nahtlose Wiedergabe
|
||||
- [x] TTS satzweise fuer lange Texte
|
||||
- [x] Datei-/Bild-Upload mit Shared Volume
|
||||
- [x] Watchdog (stuck Run Erkennung + Auto-Fix + Container-Restart)
|
||||
- [x] Auto-Update System (APK via RVS)
|
||||
- [x] Chat-Suche, Play-Button, Abbrechen-Button
|
||||
- [x] XTTS v2 Integration (GPU, Voice Cloning, remote ueber RVS)
|
||||
- [x] Gespraechsmodus (Ohr-Button, automatische Aufnahme nach ARIA-Antwort)
|
||||
- [x] Mehrere Anhaenge + Text vor dem Senden + Paste-Support
|
||||
- [x] Markdown-Bereinigung fuer TTS
|
||||
- [x] Auto-Update mit FileProvider + Update-Check Button
|
||||
- [x] Inverted FlatList (zuverlaessiges Scroll-to-Bottom)
|
||||
- [x] Speech Gate (VAD verwirft Aufnahme ohne erkannte Sprache)
|
||||
- [x] Session-Persistenz ueber Container-Restarts (sessionFromFile + atomic write)
|
||||
- [x] Session-Export als Markdown-Datei (Download-Button pro Session)
|
||||
- [x] "ARIA denkt..."-Indicator + Abbrechen-Button in App (via Bridge → RVS)
|
||||
- [x] Whisper-Modell waehlbar in Diagnostic (tiny…large-v3, Hot-Reload)
|
||||
- [x] App-Aufnahme explizit 16kHz mono (optimal fuer Whisper, kein Resample)
|
||||
- [x] Streaming TTS Pre-Roll-Buffer + Wartezeit auf playbackHeadPosition (kein Cutoff mid-Satz mehr)
|
||||
- [x] Pre-Roll-Buffer einstellbar in App-Settings
|
||||
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
|
||||
- [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel
|
||||
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit)
|
||||
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
|
||||
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
|
||||
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
|
||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
||||
|
||||
### Phase 2 — ARIA wird produktiv
|
||||
|
||||
|
|
@ -578,11 +850,13 @@ docker exec aria-core ssh aria-wohnung hostname
|
|||
- [ ] Gitea-Integration
|
||||
- [ ] VM einrichten (Desktop, Browser, Tools)
|
||||
- [ ] Heartbeat (periodische Selbst-Checks)
|
||||
- [ ] Lokales LLM als Wächter (Triage vor Claude-Call)
|
||||
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
|
||||
- [ ] Auto-Compacting / Memory-Verwaltung
|
||||
|
||||
### Phase 3 — Erweiterungen
|
||||
|
||||
- [ ] STARFACE Telefonie-Skill
|
||||
- [ ] Desktop Client (Tauri)
|
||||
- [ ] bKVM Remote IT-Support
|
||||
- [ ] Porcupine Wake Word (on-device "ARIA" in der App)
|
||||
- [ ] Custom-`.onnx`-Upload fuer Wake-Word ueber Diagnostic (ohne App-Rebuild)
|
||||
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ android {
|
|||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 108
|
||||
versionName "0.0.1.8"
|
||||
versionCode 701
|
||||
versionName "0.0.7.1"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
|
@ -104,6 +104,19 @@ android {
|
|||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
|
||||
// ABI-Split: nur arm64-v8a (jedes Android-Phone seit ~2017). Bringt die
|
||||
// APK von ~136 MB auf ~35 MB — relevant weil ONNX Runtime + die anderen
|
||||
// Native-Libs sonst pro Architektur dazukommen. Wer 32-bit oder Emulator
|
||||
// braucht, kann hier "armeabi-v7a", "x86_64" etc. ergaenzen.
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "arm64-v8a"
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
@ -111,6 +124,9 @@ dependencies {
|
|||
implementation("com.facebook.react:react-android")
|
||||
implementation("com.facebook.react:flipper-integration")
|
||||
|
||||
// ONNX Runtime fuer on-device Wake-Word (openWakeWord ONNX-Modelle in assets/openwakeword/)
|
||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.1")
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
|
@ -24,5 +27,15 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,44 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.Promise
|
||||
import java.io.File
|
||||
|
||||
class ApkInstallerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "ApkInstaller"
|
||||
|
||||
@ReactMethod
|
||||
fun install(filePath: String, promise: Promise) {
|
||||
try {
|
||||
val file = File(filePath)
|
||||
if (!file.exists()) {
|
||||
promise.reject("FILE_NOT_FOUND", "APK nicht gefunden: $filePath")
|
||||
return
|
||||
}
|
||||
|
||||
val context = reactApplicationContext
|
||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
} else {
|
||||
Uri.fromFile(file)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("INSTALL_ERROR", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class ApkInstallerPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ApkInstallerModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/**
|
||||
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps.
|
||||
*
|
||||
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
|
||||
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
|
||||
* - release() → Focus abgeben, andere Apps duerfen wieder
|
||||
*/
|
||||
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "AudioFocus"
|
||||
|
||||
private var currentRequest: AudioFocusRequest? = null
|
||||
|
||||
private fun audioManager(): AudioManager? =
|
||||
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
|
||||
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
|
||||
val am = audioManager()
|
||||
if (am == null) {
|
||||
promise.reject("NO_AUDIO_MANAGER", "AudioManager nicht verfuegbar")
|
||||
return
|
||||
}
|
||||
|
||||
release()
|
||||
|
||||
val result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setUsage(usage)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
val req = AudioFocusRequest.Builder(durationHint)
|
||||
.setAudioAttributes(attrs)
|
||||
.setOnAudioFocusChangeListener { /* kein Callback noetig */ }
|
||||
.build()
|
||||
currentRequest = req
|
||||
am.requestAudioFocus(req)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint)
|
||||
}
|
||||
|
||||
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||
}
|
||||
|
||||
/** Andere Apps werden pausiert (TTS spricht).
|
||||
*
|
||||
* TRANSIENT (statt TRANSIENT_MAY_DUCK): Spotify/YouTube pausieren komplett
|
||||
* statt nur leiser zu werden. Verhindert auch das "kommt-wieder-hoch"-
|
||||
* Problem mit MAY_DUCK, wo das System nach kurzer Zeit den Duck-Effekt
|
||||
* wieder aufgehoben hat obwohl wir den Fokus noch hielten.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun requestDuck(promise: Promise) {
|
||||
requestFocus(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
AudioAttributes.USAGE_ASSISTANT,
|
||||
promise,
|
||||
)
|
||||
}
|
||||
|
||||
/** Andere Apps werden pausiert (Mikrofon-Aufnahme / Gespraech). */
|
||||
@ReactMethod
|
||||
fun requestExclusive(promise: Promise) {
|
||||
requestFocus(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
AudioAttributes.USAGE_VOICE_COMMUNICATION,
|
||||
promise,
|
||||
)
|
||||
}
|
||||
|
||||
/** Focus abgeben — andere Apps duerfen wieder volle Lautstaerke. */
|
||||
@ReactMethod
|
||||
fun release(promise: Promise) {
|
||||
release()
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
val am = audioManager() ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
currentRequest?.let { am.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
am.abandonAudioFocus(null)
|
||||
}
|
||||
currentRequest = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class AudioFocusPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(AudioFocusModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -18,8 +18,11 @@ class MainApplication : Application(), ReactApplication {
|
|||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
add(ApkInstallerPackage())
|
||||
add(AudioFocusPackage())
|
||||
add(PcmStreamPlayerPackage())
|
||||
add(OpenWakeWordPackage())
|
||||
add(PhoneCallPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,369 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.nio.FloatBuffer
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Wake-Word Erkennung on-device via openWakeWord (https://github.com/dscripka/openWakeWord).
|
||||
*
|
||||
* Drei-stufige ONNX Pipeline:
|
||||
* 1. Audio (16kHz mono int16, 1280-Sample-Chunks) → Melspectrogram → 32-mel Frames
|
||||
* 2. 76 Mel-Frames Sliding Window (stride 8) → Speech-Embedding → 96-dim Vektor
|
||||
* 3. Letzte 16 Embeddings (~1.28s Kontext) → Wake-Word-Klassifikator → Sigmoid-Score
|
||||
*
|
||||
* Modelle liegen in assets/openwakeword/ (mel + embedding shared, plus pro Keyword
|
||||
* ein eigenes .onnx). Erkennung feuert nach `patience` aufeinanderfolgenden
|
||||
* Frames ueber `threshold` und unterdrueckt Wiederholungen fuer `debounceMs`.
|
||||
*
|
||||
* Emittiert "WakeWordDetected" als RN-Event wenn ein Trigger erkannt wurde.
|
||||
*/
|
||||
class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "OpenWakeWord"
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OpenWakeWord"
|
||||
private const val SAMPLE_RATE = 16000
|
||||
private const val CHUNK_SAMPLES = 1280 // 80ms @ 16kHz
|
||||
private const val MEL_FRAMES_PER_EMBEDDING = 76 // Embedding-Fenster
|
||||
private const val EMBEDDING_STRIDE = 8 // Slide um 8 Mel-Frames
|
||||
private const val EMBEDDING_DIM = 96
|
||||
private const val MEL_BINS = 32
|
||||
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
|
||||
}
|
||||
|
||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||
private var melSession: OrtSession? = null
|
||||
private var embSession: OrtSession? = null
|
||||
private var wwSession: OrtSession? = null
|
||||
|
||||
private var melInputName: String = "input"
|
||||
private var embInputName: String = "input_1"
|
||||
private var wwInputName: String = "input"
|
||||
// Anzahl Embedding-Frames die der Wake-Word-Klassifikator pro Inferenz erwartet —
|
||||
// hey_jarvis hat 16, andere Community-Modelle koennen abweichen (z.B. 28).
|
||||
// Wird beim init() aus den Modell-Metadaten gelesen.
|
||||
private var wwInputFrames: Int = DEFAULT_WW_INPUT_FRAMES
|
||||
|
||||
// Konfiguration
|
||||
private var threshold: Float = 0.5f
|
||||
private var patience: Int = 2
|
||||
private var debounceMs: Long = 1500
|
||||
private var modelName: String = "hey_jarvis"
|
||||
|
||||
// Audio-Capture-Thread
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private val running = AtomicBoolean(false)
|
||||
private var captureThread: Thread? = null
|
||||
|
||||
// Inferenz-State
|
||||
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
||||
private var melProcessedIdx: Int = 0
|
||||
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
||||
private var consecutiveAboveThreshold: Int = 0
|
||||
private var lastDetectionMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
|
||||
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
|
||||
*/
|
||||
@ReactMethod
|
||||
fun init(modelName: String, threshold: Double, patience: Int, debounceMs: Int, promise: Promise) {
|
||||
try {
|
||||
disposeSessions()
|
||||
this.modelName = modelName
|
||||
this.threshold = threshold.toFloat()
|
||||
this.patience = patience.coerceAtLeast(1)
|
||||
this.debounceMs = debounceMs.toLong()
|
||||
|
||||
val ctx = reactApplicationContext
|
||||
val melBytes = ctx.assets.open("openwakeword/melspectrogram.onnx").use { it.readBytes() }
|
||||
val embBytes = ctx.assets.open("openwakeword/embedding_model.onnx").use { it.readBytes() }
|
||||
val wwBytes = ctx.assets.open("openwakeword/$modelName.onnx").use { it.readBytes() }
|
||||
|
||||
val opts = OrtSession.SessionOptions()
|
||||
melSession = env.createSession(melBytes, opts)
|
||||
embSession = env.createSession(embBytes, opts)
|
||||
wwSession = env.createSession(wwBytes, opts)
|
||||
|
||||
melInputName = melSession!!.inputNames.first()
|
||||
embInputName = embSession!!.inputNames.first()
|
||||
wwInputName = wwSession!!.inputNames.first()
|
||||
|
||||
// WW-Input-Frame-Count aus dem Modell lesen — variiert pro Keyword.
|
||||
// Erwartete Form: (1, N, 96), N steht in der Modell-Metadaten.
|
||||
val wwInputInfo = wwSession!!.inputInfo[wwInputName]
|
||||
val wwShape = (wwInputInfo?.info as? ai.onnxruntime.TensorInfo)?.shape
|
||||
wwInputFrames = wwShape?.getOrNull(1)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_WW_INPUT_FRAMES
|
||||
|
||||
Log.i(TAG, "Init OK: model=$modelName wwFrames=$wwInputFrames threshold=$threshold patience=$patience " +
|
||||
"debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Init fehlgeschlagen: ${e.message}", e)
|
||||
disposeSessions()
|
||||
promise.reject("INIT_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun start(promise: Promise) {
|
||||
if (running.get()) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
if (melSession == null || embSession == null || wwSession == null) {
|
||||
promise.reject("NOT_INITIALIZED", "init() muss vor start() aufgerufen werden")
|
||||
return
|
||||
}
|
||||
// Berechtigung pruefen — der App-Code holt die ueblicherweise schon vorher,
|
||||
// aber wir bestehen hier explizit darauf damit AudioRecord nicht stumm
|
||||
// failt.
|
||||
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.RECORD_AUDIO)
|
||||
if (perm != PackageManager.PERMISSION_GRANTED) {
|
||||
promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val minBuf = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
|
||||
|
||||
val record = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
minBuf,
|
||||
)
|
||||
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||
record.release()
|
||||
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)")
|
||||
return
|
||||
}
|
||||
audioRecord = record
|
||||
resetInferenceState()
|
||||
running.set(true)
|
||||
record.startRecording()
|
||||
|
||||
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start fehlgeschlagen", e)
|
||||
running.set(false)
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
running.set(false)
|
||||
try {
|
||||
captureThread?.join(1500)
|
||||
} catch (_: InterruptedException) {}
|
||||
captureThread = null
|
||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
||||
try { audioRecord?.release() } catch (_: Exception) {}
|
||||
audioRecord = null
|
||||
Log.i(TAG, "Lauschen gestoppt")
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun dispose(promise: Promise) {
|
||||
running.set(false)
|
||||
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
|
||||
captureThread = null
|
||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
||||
try { audioRecord?.release() } catch (_: Exception) {}
|
||||
audioRecord = null
|
||||
disposeSessions()
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun isAvailable(promise: Promise) {
|
||||
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
// RN-Event-Subscriptions — RN-Konvention, sonst Warnung im Debug-Build
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
|
||||
private fun disposeSessions() {
|
||||
try { melSession?.close() } catch (_: Exception) {}
|
||||
try { embSession?.close() } catch (_: Exception) {}
|
||||
try { wwSession?.close() } catch (_: Exception) {}
|
||||
melSession = null
|
||||
embSession = null
|
||||
wwSession = null
|
||||
}
|
||||
|
||||
private fun resetInferenceState() {
|
||||
melBuffer.clear()
|
||||
melProcessedIdx = 0
|
||||
embBuffer.clear()
|
||||
consecutiveAboveThreshold = 0
|
||||
lastDetectionMs = 0L
|
||||
}
|
||||
|
||||
private fun emitDetected() {
|
||||
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
||||
putString("model", modelName)
|
||||
}
|
||||
try {
|
||||
reactApplicationContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("WakeWordDetected", params)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "emit fehlgeschlagen: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureLoop() {
|
||||
val buf = ShortArray(CHUNK_SAMPLES)
|
||||
val record = audioRecord ?: return
|
||||
Log.i(TAG, "Capture-Loop gestartet")
|
||||
while (running.get()) {
|
||||
var read = 0
|
||||
while (read < CHUNK_SAMPLES && running.get()) {
|
||||
val n = record.read(buf, read, CHUNK_SAMPLES - read)
|
||||
if (n <= 0) {
|
||||
Log.w(TAG, "AudioRecord.read returned $n — Loop ende")
|
||||
running.set(false)
|
||||
return
|
||||
}
|
||||
read += n
|
||||
}
|
||||
if (!running.get()) break
|
||||
try {
|
||||
processChunk(buf)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "processChunk: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Capture-Loop beendet")
|
||||
}
|
||||
|
||||
/** Verarbeitet einen 1280-Sample int16 Audio-Chunk. */
|
||||
private fun processChunk(audio: ShortArray) {
|
||||
// 1) Audio → mel (output (1, 1, frames, 32))
|
||||
val floats = FloatArray(audio.size) { audio[it].toFloat() }
|
||||
val melTensor = OnnxTensor.createTensor(
|
||||
env,
|
||||
FloatBuffer.wrap(floats),
|
||||
longArrayOf(1L, audio.size.toLong()),
|
||||
)
|
||||
val melResult = melSession!!.run(mapOf(melInputName to melTensor))
|
||||
val melOut = melResult.get(0).value
|
||||
melTensor.close()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val mel4 = melOut as Array<Array<Array<FloatArray>>>
|
||||
val frames = mel4[0][0]
|
||||
// openWakeWord wendet `mel/10 + 2` an, bevor es ans Embedding-Modell geht
|
||||
for (frame in frames) {
|
||||
val scaled = FloatArray(frame.size) { frame[it] / 10f + 2f }
|
||||
melBuffer.add(scaled)
|
||||
}
|
||||
melResult.close()
|
||||
|
||||
// 2) Sliding window: alle vollstaendigen 76-Frame-Fenster verarbeiten
|
||||
while (melBuffer.size >= melProcessedIdx + MEL_FRAMES_PER_EMBEDDING) {
|
||||
val flat = FloatArray(MEL_FRAMES_PER_EMBEDDING * MEL_BINS)
|
||||
var pos = 0
|
||||
for (i in 0 until MEL_FRAMES_PER_EMBEDDING) {
|
||||
val src = melBuffer[melProcessedIdx + i]
|
||||
System.arraycopy(src, 0, flat, pos, MEL_BINS)
|
||||
pos += MEL_BINS
|
||||
}
|
||||
val embIn = OnnxTensor.createTensor(
|
||||
env,
|
||||
FloatBuffer.wrap(flat),
|
||||
longArrayOf(1L, MEL_FRAMES_PER_EMBEDDING.toLong(), MEL_BINS.toLong(), 1L),
|
||||
)
|
||||
val embRes = embSession!!.run(mapOf(embInputName to embIn))
|
||||
val embOut = embRes.get(0).value
|
||||
embIn.close()
|
||||
// Erwartete Output-Form: (1, 1, 1, 96) — rank-4, NICHT (1, 96).
|
||||
// Die Google-Embedding-Pipeline behaelt extra Dimensionen.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val embArr = embOut as Array<Array<Array<FloatArray>>>
|
||||
embBuffer.addLast(embArr[0][0][0].copyOf())
|
||||
while (embBuffer.size > wwInputFrames) embBuffer.removeFirst()
|
||||
embRes.close()
|
||||
|
||||
melProcessedIdx += EMBEDDING_STRIDE
|
||||
}
|
||||
// Mel-Buffer trimmen — verhindert Memory-Wachstum
|
||||
if (melProcessedIdx > MEL_FRAMES_PER_EMBEDDING) {
|
||||
val keepFrom = melProcessedIdx - MEL_FRAMES_PER_EMBEDDING
|
||||
val newList = ArrayList<FloatArray>(melBuffer.size - keepFrom)
|
||||
for (i in keepFrom until melBuffer.size) newList.add(melBuffer[i])
|
||||
melBuffer.clear()
|
||||
melBuffer.addAll(newList)
|
||||
melProcessedIdx = MEL_FRAMES_PER_EMBEDDING
|
||||
}
|
||||
|
||||
// 3) Klassifikation — sobald wir 16 Embeddings haben
|
||||
if (embBuffer.size < wwInputFrames) return
|
||||
val flatEmb = FloatArray(wwInputFrames * EMBEDDING_DIM)
|
||||
var p = 0
|
||||
// Letzte wwInputFrames Embeddings nehmen (embBuffer ist auf wwInputFrames begrenzt)
|
||||
for (e in embBuffer) {
|
||||
System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM)
|
||||
p += EMBEDDING_DIM
|
||||
}
|
||||
val wwIn = OnnxTensor.createTensor(
|
||||
env,
|
||||
FloatBuffer.wrap(flatEmb),
|
||||
longArrayOf(1L, wwInputFrames.toLong(), EMBEDDING_DIM.toLong()),
|
||||
)
|
||||
val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn))
|
||||
val wwOut = wwRes.get(0).value
|
||||
wwIn.close()
|
||||
// Erwartete Output-Form: (1, 1) → Array<FloatArray>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val score = (wwOut as Array<FloatArray>)[0][0]
|
||||
wwRes.close()
|
||||
|
||||
if (score >= threshold) {
|
||||
consecutiveAboveThreshold++
|
||||
if (consecutiveAboveThreshold >= patience) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastDetectionMs >= debounceMs) {
|
||||
lastDetectionMs = now
|
||||
consecutiveAboveThreshold = 0
|
||||
Log.i(TAG, "Wake-Word erkannt! score=$score model=$modelName")
|
||||
emitDetected()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecutiveAboveThreshold = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class OpenWakeWordPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(OpenWakeWordModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
|
||||
/**
|
||||
* Streamt PCM-s16le Audio direkt via AudioTrack MODE_STREAM mit Pre-Roll.
|
||||
*
|
||||
* Pre-Roll: AudioTrack wird zwar direkt gebaut und gefuttert, aber play()
|
||||
* wird erst aufgerufen wenn PREROLL_SECONDS Audio im Buffer ist. So hat
|
||||
* der Stream Zeit einen Vorrat aufzubauen — wenn XTTS mit RTF>1 rendert
|
||||
* (langsamer als Echtzeit), laeuft der Buffer trotzdem nicht leer.
|
||||
*
|
||||
* Flow:
|
||||
* JS: start(sampleRate, channels) → öffnet AudioTrack (noch nicht play())
|
||||
* JS: writeChunk(base64) → dekodiert, queued, Writer schreibt
|
||||
* Writer: spielt los sobald PREROLL erreicht ist
|
||||
* JS: end() → wartet bis Queue leer, schließt
|
||||
* JS: stop() → Hart stoppen (Cancel)
|
||||
*/
|
||||
class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
companion object {
|
||||
private const val TAG = "PcmStreamPlayer"
|
||||
// Fallback wenn JS keinen Wert uebergibt.
|
||||
private const val DEFAULT_PREROLL_SECONDS = 3.5
|
||||
// 0.0 = sofortige Wiedergabe — play() direkt beim ersten Chunk.
|
||||
// Macht Sinn fuer F5-TTS weil Render so schnell ist dass ein Puffer
|
||||
// unnoetig ist und bei kurzen Saetzen sogar stoeren kann.
|
||||
private const val MIN_PREROLL_SECONDS = 0.0
|
||||
private const val MAX_PREROLL_SECONDS = 10.0
|
||||
// Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die
|
||||
// ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz).
|
||||
private const val LEADING_SILENCE_SECONDS = 0.3
|
||||
// Stille am Ende — puffert das Hardware-Flushen damit die letzten
|
||||
// echten Samples garantiert ausgespielt werden bevor stop() kommt.
|
||||
private const val TRAILING_SILENCE_SECONDS = 0.3
|
||||
}
|
||||
|
||||
override fun getName() = "PcmStreamPlayer"
|
||||
|
||||
private var track: AudioTrack? = null
|
||||
private val queue = LinkedBlockingQueue<ByteArray>()
|
||||
private var writerThread: Thread? = null
|
||||
@Volatile private var writerShouldStop = false
|
||||
@Volatile private var endRequested = false
|
||||
@Volatile private var prerollBytes: Int = 0
|
||||
@Volatile private var playbackStarted = false
|
||||
@Volatile private var bytesBuffered: Long = 0
|
||||
@Volatile private var streamBytesPerFrame: Int = 2 // mono s16le default
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
@ReactMethod
|
||||
fun start(sampleRate: Int, channels: Int, prerollSeconds: Double, promise: Promise) {
|
||||
try {
|
||||
// Alte Session beenden falls vorhanden
|
||||
stopInternal()
|
||||
|
||||
// Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe).
|
||||
val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) {
|
||||
prerollSeconds.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
|
||||
} else {
|
||||
DEFAULT_PREROLL_SECONDS
|
||||
}
|
||||
|
||||
val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
|
||||
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
|
||||
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
|
||||
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
|
||||
val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2)
|
||||
prerollBytes = prerollTarget
|
||||
bytesBuffered = 0
|
||||
playbackStarted = false
|
||||
streamBytesPerFrame = channels * 2 // s16 = 2 bytes per sample
|
||||
|
||||
val newTrack = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelConfig)
|
||||
.setEncoding(encoding)
|
||||
.build(),
|
||||
)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
|
||||
// AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht.
|
||||
track = newTrack
|
||||
queue.clear()
|
||||
writerShouldStop = false
|
||||
endRequested = false
|
||||
|
||||
writerThread = Thread({
|
||||
val t = track ?: return@Thread
|
||||
try {
|
||||
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
|
||||
val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
|
||||
if (leadingBytes > 0) {
|
||||
val silence = ByteArray(leadingBytes)
|
||||
var silOff = 0
|
||||
while (silOff < silence.size && !writerShouldStop) {
|
||||
val w = t.write(silence, silOff, silence.size - silOff)
|
||||
if (w <= 0) break
|
||||
silOff += w
|
||||
}
|
||||
bytesBuffered += silence.size
|
||||
}
|
||||
// Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen,
|
||||
// nicht erst bei Ankunft des ersten echten Chunks. Android's
|
||||
// AudioTrack haelt den Play-State und wartet auf neue Samples.
|
||||
// So verschluckt es keine Worte wenn der erste Chunk erst
|
||||
// nach play()-Startup-Latenz eintrifft.
|
||||
if (prerollBytes == 0 && !playbackStarted) {
|
||||
try {
|
||||
t.play()
|
||||
playbackStarted = true
|
||||
Log.i(TAG, "Playback sofort gestartet (preroll=0, ${bytesBuffered}B silence)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "play() sofort failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
// Idle-Cutoff: wenn endRequested NICHT kam aber 30s nichts mehr
|
||||
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
|
||||
var idleMs = 0L
|
||||
val maxIdleMs = 30_000L
|
||||
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
|
||||
// Stille rein damit AudioTrack nicht underrunt waehrend die
|
||||
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
|
||||
// sonst mit eigenmaechtiger Wiederaufnahme nach ~10s Stille.
|
||||
val underrunGuardFrames = sampleRate / 10 // ~100ms
|
||||
val silenceFillFrames = sampleRate / 20 // ~50ms pro Refill
|
||||
|
||||
mainLoop@ while (!writerShouldStop) {
|
||||
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
if (data == null) {
|
||||
if (endRequested) {
|
||||
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
|
||||
if (!playbackStarted) {
|
||||
try {
|
||||
t.play()
|
||||
playbackStarted = true
|
||||
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "play() fallback failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
break@mainLoop
|
||||
}
|
||||
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
|
||||
// Puffer leerzulaufen droht. Spotify resumed sonst nach
|
||||
// ~10s Pause auf eigene Faust, obwohl wir den Fokus halten.
|
||||
if (playbackStarted) {
|
||||
val framesWritten = bytesBuffered / streamBytesPerFrame
|
||||
val framesPlayed = t.playbackHeadPosition.toLong()
|
||||
val framesInBuffer = framesWritten - framesPlayed
|
||||
if (framesInBuffer < underrunGuardFrames) {
|
||||
val fillBytes = silenceFillFrames * streamBytesPerFrame
|
||||
val silence = ByteArray(fillBytes)
|
||||
var silOff = 0
|
||||
while (silOff < silence.size && !writerShouldStop) {
|
||||
val w = t.write(silence, silOff, silence.size - silOff)
|
||||
if (w <= 0) break
|
||||
silOff += w
|
||||
}
|
||||
bytesBuffered += silence.size
|
||||
}
|
||||
}
|
||||
idleMs += 50L
|
||||
if (idleMs >= maxIdleMs) {
|
||||
Log.w(TAG, "Idle-Cutoff: ${maxIdleMs}ms keine Daten — Stream wird beendet")
|
||||
break@mainLoop
|
||||
}
|
||||
continue@mainLoop
|
||||
}
|
||||
idleMs = 0L
|
||||
|
||||
// Pre-Roll Check: play() erst wenn genug gepuffert
|
||||
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
|
||||
try {
|
||||
t.play()
|
||||
playbackStarted = true
|
||||
Log.i(TAG, "Playback gestartet nach Pre-Roll ${bytesBuffered + data.size} Bytes")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "play() failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
while (offset < data.size && !writerShouldStop) {
|
||||
val written = t.write(data, offset, data.size - offset)
|
||||
if (written <= 0) break
|
||||
offset += written
|
||||
}
|
||||
bytesBuffered += data.size
|
||||
}
|
||||
// Trailing-Silence damit die letzten echten Samples garantiert
|
||||
// durch das Hardware-Buffering kommen bevor stop() sie abschneidet
|
||||
val trailingBytes = ((sampleRate * channels * 2) * TRAILING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
|
||||
if (trailingBytes > 0 && !writerShouldStop) {
|
||||
val silence = ByteArray(trailingBytes)
|
||||
var silOff = 0
|
||||
while (silOff < silence.size && !writerShouldStop) {
|
||||
val w = t.write(silence, silOff, silence.size - silOff)
|
||||
if (w <= 0) break
|
||||
silOff += w
|
||||
}
|
||||
bytesBuffered += silence.size
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
|
||||
} finally {
|
||||
// Warten bis alle geschriebenen Samples tatsaechlich abgespielt sind,
|
||||
// sonst cuttet t.release() die letzten Sekunden ab.
|
||||
try {
|
||||
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
|
||||
var lastPos = -1
|
||||
var stalledCount = 0
|
||||
while (!writerShouldStop) {
|
||||
val pos = t.playbackHeadPosition
|
||||
if (pos >= totalFrames) break
|
||||
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
|
||||
if (pos == lastPos) {
|
||||
stalledCount++
|
||||
if (stalledCount > 40) {
|
||||
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
|
||||
break
|
||||
}
|
||||
} else {
|
||||
stalledCount = 0
|
||||
lastPos = pos
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
Log.i(TAG, "Playback fertig: frames=$totalFrames pos=${t.playbackHeadPosition}")
|
||||
} catch (_: Exception) {}
|
||||
try { t.stop() } catch (_: Exception) {}
|
||||
try { t.release() } catch (_: Exception) {}
|
||||
}
|
||||
}, "PcmStreamWriter").apply { start() }
|
||||
|
||||
Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B (${prerollSec}s)")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start fehlgeschlagen", e)
|
||||
promise.reject("START_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun writeChunk(base64Pcm: String, promise: Promise) {
|
||||
try {
|
||||
if (base64Pcm.isEmpty()) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
val bytes = Base64.decode(base64Pcm, Base64.DEFAULT)
|
||||
queue.put(bytes)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("WRITE_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Signalisiert: keine weiteren Chunks. Writer spielt aus, dann stoppt.
|
||||
* Das Promise resolved erst wenn der Writer-Thread fertig ist —
|
||||
* wichtig damit der Aufrufer den AudioFocus erst NACH dem letzten
|
||||
* abgespielten Sample wieder freigibt (sonst dreht Spotify hoch
|
||||
* waehrend das Pre-Roll noch ausspielt).
|
||||
*/
|
||||
@ReactMethod
|
||||
fun end(promise: Promise) {
|
||||
endRequested = true
|
||||
val t = writerThread
|
||||
if (t == null || !t.isAlive) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
// Im Hintergrund auf den Writer warten — kein Threading-Block fuer JS-Bridge
|
||||
Thread({
|
||||
try {
|
||||
t.join(15_000) // hartes Cap, falls Writer haengt
|
||||
} catch (_: InterruptedException) {}
|
||||
promise.resolve(true)
|
||||
}, "PcmStreamEndWaiter").start()
|
||||
}
|
||||
|
||||
/** Harter Stop (Cancel) — Queue verwerfen. */
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
stopInternal()
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
private fun stopInternal() {
|
||||
writerShouldStop = true
|
||||
endRequested = true
|
||||
queue.clear()
|
||||
writerThread?.interrupt()
|
||||
writerThread = null
|
||||
val t = track
|
||||
if (t != null) {
|
||||
try { t.stop() } catch (_: Exception) {}
|
||||
try { t.release() } catch (_: Exception) {}
|
||||
}
|
||||
track = null
|
||||
}
|
||||
|
||||
override fun onCatalystInstanceDestroy() {
|
||||
stopInternal()
|
||||
super.onCatalystInstanceDestroy()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class PcmStreamPlayerPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(PcmStreamPlayerModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.telephony.PhoneStateListener
|
||||
import android.telephony.TelephonyCallback
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
|
||||
/**
|
||||
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
||||
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
|
||||
*
|
||||
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
|
||||
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise — der Rest
|
||||
* der App funktioniert wie bisher.
|
||||
*
|
||||
* State-Strings: "idle" | "ringing" | "offhook"
|
||||
*/
|
||||
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "PhoneCall"
|
||||
|
||||
companion object { private const val TAG = "PhoneCall" }
|
||||
|
||||
private var telephonyManager: TelephonyManager? = null
|
||||
private var legacyListener: PhoneStateListener? = null
|
||||
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
||||
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
||||
|
||||
@ReactMethod
|
||||
fun start(promise: Promise) {
|
||||
try {
|
||||
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
|
||||
if (perm != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
|
||||
promise.resolve(false)
|
||||
return
|
||||
}
|
||||
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
||||
if (tm == null) {
|
||||
Log.w(TAG, "TelephonyManager nicht verfuegbar")
|
||||
promise.resolve(false)
|
||||
return
|
||||
}
|
||||
telephonyManager = tm
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) {
|
||||
handleStateChange(state)
|
||||
}
|
||||
}
|
||||
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
|
||||
modernCallback = cb
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val l = object : PhoneStateListener() {
|
||||
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
|
||||
handleStateChange(state)
|
||||
}
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
|
||||
legacyListener = l
|
||||
}
|
||||
Log.i(TAG, "PhoneCall-Listener aktiv")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start fehlgeschlagen", e)
|
||||
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
try {
|
||||
val tm = telephonyManager
|
||||
if (tm != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
(modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
|
||||
modernCallback = null
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
|
||||
legacyListener = null
|
||||
}
|
||||
}
|
||||
telephonyManager = null
|
||||
lastState = TelephonyManager.CALL_STATE_IDLE
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("STOP_FAILED", e.message ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStateChange(state: Int) {
|
||||
if (state == lastState) return
|
||||
lastState = state
|
||||
val name = when (state) {
|
||||
TelephonyManager.CALL_STATE_RINGING -> "ringing"
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
|
||||
TelephonyManager.CALL_STATE_IDLE -> "idle"
|
||||
else -> return
|
||||
}
|
||||
Log.i(TAG, "Telefon-State: $name")
|
||||
val params = Arguments.createMap().apply { putString("state", name) }
|
||||
try {
|
||||
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("PhoneCallStateChanged", params)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class PhoneCallPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(PhoneCallModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="cache" path="." />
|
||||
</paths>
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "34.0.0"
|
||||
minSdkVersion = 23
|
||||
// 24 = Android 7.0 (Nougat). Verlangt von Porcupine (Picovoice).
|
||||
// Realistisch eh das Minimum: alles unter 7.0 hat <1% Marktanteil.
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 34
|
||||
ndkVersion = "25.1.8937393"
|
||||
|
|
|
|||
|
|
@ -167,10 +167,23 @@ export CI=true
|
|||
|
||||
if [ "$MODE" = "debug" ]; then
|
||||
./gradlew assembleDebug
|
||||
APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
|
||||
OUT_DIR="app/build/outputs/apk/debug"
|
||||
else
|
||||
./gradlew assembleRelease
|
||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||
OUT_DIR="app/build/outputs/apk/release"
|
||||
fi
|
||||
|
||||
# Mit ABI-Splits heisst die APK z.B. app-arm64-v8a-release.apk statt
|
||||
# app-release.apk. arm64-v8a-Variante zuerst probieren (das ist unser
|
||||
# Standard), Universal-APK als Fallback falls Splits deaktiviert sind.
|
||||
if [ -f "$OUT_DIR/app-arm64-v8a-${MODE}.apk" ]; then
|
||||
APK_PATH="$OUT_DIR/app-arm64-v8a-${MODE}.apk"
|
||||
elif [ -f "$OUT_DIR/app-${MODE}.apk" ]; then
|
||||
APK_PATH="$OUT_DIR/app-${MODE}.apk"
|
||||
else
|
||||
echo -e "${RED}Keine passende APK in $OUT_DIR gefunden${NC}"
|
||||
cd ..
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.0.1.8",
|
||||
"version": "0.0.7.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
|
@ -24,8 +24,7 @@
|
|||
"react-native-camera-kit": "^13.0.0",
|
||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-audio-recorder-player": "^3.6.7",
|
||||
"react-native-live-audio-stream": "^1.1.1"
|
||||
"react-native-audio-recorder-player": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* MessageText — rendert Chat-Text mit Auto-Linkifizierung:
|
||||
* - http(s)://... → tippbar, oeffnet im Browser
|
||||
* - mailto: oder plain E-Mail → tippbar, oeffnet Mail-App
|
||||
* - Telefonnummern → tippbar, oeffnet Android-Dialer
|
||||
*
|
||||
* Text ist durchgaengig markierbar/kopierbar (selectable).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Linking, TextStyle, StyleProp } from 'react-native';
|
||||
|
||||
// Regex kombiniert URL | Email | Telefonnummer.
|
||||
// Gruppenreihenfolge ist wichtig fuer die Erkennung unten.
|
||||
//
|
||||
// URL: http://... oder https://... bis zum ersten Whitespace / Anfuehrungszeichen.
|
||||
// Email: simpler Standard-Match (kein RFC-kompatibel aber gut genug).
|
||||
// Telefon: internationale Form (+49..., 0049..., 0176...), darf Leerzeichen
|
||||
// / Bindestriche / Schraegstriche / Klammern enthalten, mindestens 7
|
||||
// Ziffern insgesamt. Vermeidet banale Zahlen (Uhrzeiten, Datum).
|
||||
const LINK_REGEX = new RegExp(
|
||||
'(https?:\\/\\/[^\\s<>"]+)' + // 1: URL
|
||||
'|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})' + // 2: Email
|
||||
'|((?:\\+|00)\\d[\\d\\s()\\-\\/]{6,}\\d|0\\d{2,4}[\\s\\/\\-]?[\\d\\s\\-\\/]{5,}\\d)', // 3: Telefon
|
||||
'g',
|
||||
);
|
||||
|
||||
const LINK_STYLE = { color: '#0096FF', textDecorationLine: 'underline' } as TextStyle;
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
kind: 'text' | 'url' | 'email' | 'phone';
|
||||
}
|
||||
|
||||
function tokenize(raw: string): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let lastEnd = 0;
|
||||
LINK_REGEX.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = LINK_REGEX.exec(raw)) !== null) {
|
||||
if (m.index > lastEnd) {
|
||||
out.push({ text: raw.slice(lastEnd, m.index), kind: 'text' });
|
||||
}
|
||||
if (m[1]) out.push({ text: m[1], kind: 'url' });
|
||||
else if (m[2]) out.push({ text: m[2], kind: 'email' });
|
||||
else if (m[3]) out.push({ text: m[3], kind: 'phone' });
|
||||
lastEnd = LINK_REGEX.lastIndex;
|
||||
}
|
||||
if (lastEnd < raw.length) out.push({ text: raw.slice(lastEnd), kind: 'text' });
|
||||
return out;
|
||||
}
|
||||
|
||||
function onPress(seg: Segment) {
|
||||
try {
|
||||
if (seg.kind === 'url') {
|
||||
Linking.openURL(seg.text);
|
||||
} else if (seg.kind === 'email') {
|
||||
Linking.openURL(`mailto:${seg.text}`);
|
||||
} else if (seg.kind === 'phone') {
|
||||
// Android-Dialer erwartet tel:-Schema ohne Leerzeichen/Bindestriche
|
||||
const clean = seg.text.replace(/[\s\-\/()]/g, '');
|
||||
Linking.openURL(`tel:${clean}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const MessageText: React.FC<Props> = ({ text, style }) => {
|
||||
const segments = React.useMemo(() => tokenize(text), [text]);
|
||||
return (
|
||||
<Text
|
||||
style={style}
|
||||
selectable
|
||||
// dataDetectorType ist Android-only und macht Phone/URL/Email zusaetzlich
|
||||
// ueber System-Detection klickbar — als Fallback falls unsere Regex-
|
||||
// Tokens nicht passen.
|
||||
dataDetectorType="all"
|
||||
>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.kind === 'text') {
|
||||
return <Text key={i} selectable>{seg.text}</Text>;
|
||||
}
|
||||
return (
|
||||
<Text
|
||||
key={i}
|
||||
selectable
|
||||
style={LINK_STYLE}
|
||||
onPress={() => onPress(seg)}
|
||||
// Long-Press soll an den Parent durch fuer Selection
|
||||
onLongPress={undefined}
|
||||
suppressHighlighting={false}
|
||||
>
|
||||
{seg.text}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageText;
|
||||
|
|
@ -93,18 +93,24 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||
}
|
||||
}, [isRecording]);
|
||||
|
||||
// VAD Silence Callback — Auto-Stop
|
||||
// VAD Silence Callback — Auto-Stop.
|
||||
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
|
||||
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
|
||||
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
|
||||
// feuert (silenceFired-Latch).
|
||||
const onCompleteRef = useRef(onRecordingComplete);
|
||||
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
|
||||
useEffect(() => {
|
||||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
||||
if (!isRecording) return;
|
||||
setIsRecording(false);
|
||||
if (audioService.getRecordingState() !== 'recording') return;
|
||||
const result = await audioService.stopRecording();
|
||||
setIsRecording(false);
|
||||
if (result && result.durationMs > 500) {
|
||||
onRecordingComplete(result);
|
||||
onCompleteRef.current(result);
|
||||
}
|
||||
});
|
||||
return unsubSilence;
|
||||
}, [isRecording, onRecordingComplete]);
|
||||
}, []);
|
||||
|
||||
// Auto-Start fuer Wake Word (extern getriggert)
|
||||
const startAutoRecording = useCallback(async () => {
|
||||
|
|
@ -136,23 +142,35 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop
|
||||
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
|
||||
// Guard gegen Doppel-Tap während asyncer Start/Stop.
|
||||
const tapBusy = useRef(false);
|
||||
const handleTap = async () => {
|
||||
if (disabled) return;
|
||||
if (isRecording) {
|
||||
// Aufnahme manuell stoppen
|
||||
setIsRecording(false);
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 300) {
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
} else {
|
||||
// Aufnahme mit Auto-Stop starten
|
||||
const started = await audioService.startRecording(true);
|
||||
if (started) {
|
||||
isLongPress.current = false;
|
||||
setIsRecording(true);
|
||||
if (disabled || tapBusy.current) return;
|
||||
tapBusy.current = true;
|
||||
try {
|
||||
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
|
||||
const svcState = audioService.getRecordingState();
|
||||
if (svcState === 'recording') {
|
||||
// Aufnahme manuell stoppen
|
||||
const result = await audioService.stopRecording();
|
||||
setIsRecording(false);
|
||||
if (result && result.durationMs > 300) {
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
} else if (svcState === 'idle') {
|
||||
// Aufnahme mit Auto-Stop starten
|
||||
const started = await audioService.startRecording(true);
|
||||
if (started) {
|
||||
isLongPress.current = false;
|
||||
setIsRecording(true);
|
||||
}
|
||||
}
|
||||
// svcState === 'processing': Stopp in progress — nichts tun, User
|
||||
// muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy
|
||||
// kurz damit der User's UI-Feedback synchron bleibt.
|
||||
} finally {
|
||||
tapBusy.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* VoiceCloneModal — Eigene Stimme aufnehmen und an XTTS uploaden.
|
||||
*
|
||||
* Flow:
|
||||
* - Modal zeigt Vorlesetext (>30s Lesedauer) + Aufnahme-Button
|
||||
* - Bei Aufnahme: max 30s, Fortschrittsbalken, Countdown
|
||||
* - Bei Stop: Name abfragen, dann als voice_upload ueber RVS schicken
|
||||
* - XTTS-Bridge speichert /voices/<name>.wav, antwortet mit xtts_voice_saved
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import audioService from '../services/audio';
|
||||
import rvs from '../services/rvs';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SAMPLE_TEXT = `Das ist meine eigene Stimme fuer ARIA. Ich lese jetzt einen laengeren Absatz laut vor, damit das Voice-Cloning eine gute Grundlage hat. Guten Tag, ich heisse Stefan und baue gerade mit grosser Begeisterung an meinem persoenlichen KI-Assistenten. Wir automatisieren Infrastruktur, managen Sessions und spielen mit Sprachsynthese. Die letzten Jahre habe ich viel gelernt, vor allem dass Geduld genauso wichtig ist wie Neugier. Hoert sich das jetzt an wie ich selbst? Wenn alles klappt, spricht ARIA bald mit dieser Stimme.`;
|
||||
|
||||
const MAX_DURATION_MS = 30000;
|
||||
const TARGET_DURATION_MS = 15000;
|
||||
|
||||
const VoiceCloneModal: React.FC<Props> = ({ visible, onClose }) => {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [durationMs, setDurationMs] = useState(0);
|
||||
const [voiceName, setVoiceName] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [recordingPath, setRecordingPath] = useState('');
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
// Zustand zuruecksetzen wenn Modal schliesst/oeffnet
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setRecording(false);
|
||||
setDurationMs(0);
|
||||
setVoiceName('');
|
||||
setProcessing(false);
|
||||
setRecordingPath('');
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Cleanup bei Unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (recording) audioService.stopRecording().catch(() => {});
|
||||
};
|
||||
}, [recording]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
// Frische Aufnahme
|
||||
setDurationMs(0);
|
||||
setRecordingPath('');
|
||||
const ok = await audioService.startRecording(false);
|
||||
if (!ok) {
|
||||
Alert.alert('Fehler', 'Aufnahme konnte nicht gestartet werden (Mikrofon-Berechtigung?)');
|
||||
return;
|
||||
}
|
||||
setRecording(true);
|
||||
startTimeRef.current = Date.now();
|
||||
timerRef.current = setInterval(async () => {
|
||||
const elapsed = Date.now() - startTimeRef.current;
|
||||
setDurationMs(elapsed);
|
||||
if (elapsed >= MAX_DURATION_MS) {
|
||||
await stopRecording();
|
||||
}
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (!recording) return;
|
||||
const result = await audioService.stopRecording();
|
||||
setRecording(false);
|
||||
if (!result) {
|
||||
Alert.alert('Keine Sprache erkannt', 'Versuch es bitte nochmal — sprich bis der Timer mindestens 10 Sekunden anzeigt.');
|
||||
setDurationMs(0);
|
||||
return;
|
||||
}
|
||||
// Temp-Datei wurde schon geloescht (stopRecording cleaned up).
|
||||
// Wir brauchen aber base64 aus result direkt fuers Upload.
|
||||
// result.base64 ist bereits da.
|
||||
setRecordingPath(result.base64);
|
||||
}, [recording]);
|
||||
|
||||
const uploadVoice = useCallback(async () => {
|
||||
const name = voiceName.trim();
|
||||
if (!name) {
|
||||
Alert.alert('Name fehlt', 'Bitte gib der Stimme einen Namen (nur Buchstaben, Zahlen, _ und -).');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
Alert.alert('Ungueltiger Name', 'Nur Buchstaben, Zahlen, _ und - erlaubt.');
|
||||
return;
|
||||
}
|
||||
if (!recordingPath) {
|
||||
Alert.alert('Keine Aufnahme', 'Bitte zuerst aufnehmen.');
|
||||
return;
|
||||
}
|
||||
setProcessing(true);
|
||||
try {
|
||||
// voice_upload erwartet samples als Array mit base64 (aus Diagnostic-Format kopiert)
|
||||
rvs.send('voice_upload' as any, {
|
||||
name,
|
||||
samples: [{ base64: recordingPath }],
|
||||
});
|
||||
Alert.alert('Hochgeladen', `Stimme "${name}" wird vom XTTS-Server verarbeitet. Nach ein paar Sekunden in der Liste verfuegbar.`);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
Alert.alert('Fehler', err.message);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [voiceName, recordingPath, onClose]);
|
||||
|
||||
const progress = Math.min(durationMs / MAX_DURATION_MS, 1);
|
||||
const sec = Math.floor(durationMs / 1000);
|
||||
const enoughRecorded = durationMs >= TARGET_DURATION_MS;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Eigene Stimme aufnehmen</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Text style={styles.closeX}>{'\u2715'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} contentContainerStyle={{padding: 16}}>
|
||||
<Text style={styles.hint}>
|
||||
Lies den Text laut und deutlich vor. Maximal 30 Sekunden. Je mehr du sprichst
|
||||
(ziel: bis zum Ende des Textes, ca. 20-30s), desto besser wird die geklonte
|
||||
Stimme.
|
||||
</Text>
|
||||
|
||||
<View style={styles.sampleTextBox}>
|
||||
<Text style={styles.sampleText}>{SAMPLE_TEXT}</Text>
|
||||
</View>
|
||||
|
||||
{/* Timer + Fortschritt */}
|
||||
<View style={{marginTop: 20, alignItems: 'center'}}>
|
||||
<Text style={[styles.timer, recording && styles.timerActive]}>
|
||||
{sec.toString().padStart(2, '0')} / 30 s
|
||||
</Text>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, {width: `${progress * 100}%`, backgroundColor: recording ? '#FF3B30' : '#0096FF'}]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Aufnahme-Button */}
|
||||
{!recordingPath && (
|
||||
<TouchableOpacity
|
||||
style={[styles.recordBtn, recording && styles.recordBtnActive]}
|
||||
onPress={recording ? stopRecording : startRecording}
|
||||
>
|
||||
<Text style={styles.recordIcon}>{recording ? '\u25A0' : '\u25CF'}</Text>
|
||||
<Text style={styles.recordLabel}>{recording ? 'Stop' : 'Aufnahme starten'}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Nach Aufnahme: Name + Upload */}
|
||||
{recordingPath && (
|
||||
<View style={{marginTop: 20}}>
|
||||
<Text style={styles.hint}>
|
||||
Aufnahme ({sec}s) fertig. Vergib einen Namen und lade hoch.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.nameInput}
|
||||
value={voiceName}
|
||||
onChangeText={setVoiceName}
|
||||
placeholder="z.B. stefan"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 12}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryBtn, {flex: 1}]}
|
||||
onPress={() => { setRecordingPath(''); setDurationMs(0); }}
|
||||
>
|
||||
<Text style={styles.secondaryBtnText}>Nochmal aufnehmen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, {flex: 1}]}
|
||||
onPress={uploadVoice}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? <ActivityIndicator color="#fff" />
|
||||
: <Text style={styles.primaryBtnText}>Hochladen</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{recording && !enoughRecorded && (
|
||||
<Text style={[styles.hint, {marginTop: 12, color: '#FFD60A', textAlign: 'center'}]}>
|
||||
Bitte weiter lesen — mindestens 15 Sekunden
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{recording && enoughRecorded && (
|
||||
<Text style={[styles.hint, {marginTop: 12, color: '#34C759', textAlign: 'center'}]}>
|
||||
Genug Audio fuer eine gute Clonung. Du kannst stoppen.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D1A',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 48,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeX: {
|
||||
color: '#8888AA',
|
||||
fontSize: 24,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
hint: {
|
||||
color: '#8888AA',
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
},
|
||||
sampleTextBox: {
|
||||
marginTop: 12,
|
||||
padding: 14,
|
||||
backgroundColor: '#12122A',
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
},
|
||||
sampleText: {
|
||||
color: '#E0E0F0',
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
},
|
||||
timer: {
|
||||
color: '#666680',
|
||||
fontSize: 42,
|
||||
fontWeight: '700',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
timerActive: {
|
||||
color: '#FF3B30',
|
||||
},
|
||||
progressBar: {
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
},
|
||||
recordBtn: {
|
||||
marginTop: 24,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 12,
|
||||
padding: 18,
|
||||
borderWidth: 2,
|
||||
borderColor: '#34C759',
|
||||
},
|
||||
recordBtnActive: {
|
||||
borderColor: '#FF3B30',
|
||||
backgroundColor: 'rgba(255,59,48,0.15)',
|
||||
},
|
||||
recordIcon: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
},
|
||||
recordLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameInput: {
|
||||
marginTop: 10,
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A2A3E',
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: '#0096FF',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryBtn: {
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A2A3E',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default VoiceCloneModal;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,13 +15,40 @@ import {
|
|||
StyleSheet,
|
||||
Alert,
|
||||
Platform,
|
||||
ToastAndroid,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
import DocumentPicker from 'react-native-document-picker';
|
||||
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
||||
import {
|
||||
TTS_PREROLL_DEFAULT_SEC,
|
||||
TTS_PREROLL_MIN_SEC,
|
||||
TTS_PREROLL_MAX_SEC,
|
||||
TTS_PREROLL_STORAGE_KEY,
|
||||
VAD_SILENCE_DEFAULT_SEC,
|
||||
VAD_SILENCE_MIN_SEC,
|
||||
VAD_SILENCE_MAX_SEC,
|
||||
VAD_SILENCE_STORAGE_KEY,
|
||||
CONV_WINDOW_DEFAULT_SEC,
|
||||
CONV_WINDOW_MIN_SEC,
|
||||
CONV_WINDOW_MAX_SEC,
|
||||
CONV_WINDOW_STORAGE_KEY,
|
||||
TTS_SPEED_DEFAULT,
|
||||
TTS_SPEED_MIN,
|
||||
TTS_SPEED_MAX,
|
||||
TTS_SPEED_STORAGE_KEY,
|
||||
} from '../services/audio';
|
||||
import wakeWordService, {
|
||||
WAKE_KEYWORDS,
|
||||
KEYWORD_LABELS,
|
||||
DEFAULT_KEYWORD,
|
||||
WAKE_KEYWORD_STORAGE,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
import VoiceCloneModal from '../components/VoiceCloneModal';
|
||||
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
|
|
@ -71,7 +98,18 @@ const SettingsScreen: React.FC = () => {
|
|||
const [storagePath, setStoragePath] = useState(DEFAULT_STORAGE_PATH);
|
||||
const [autoDownload, setAutoDownload] = useState(true);
|
||||
const [storageSize, setStorageSize] = useState('...');
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
|
||||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
||||
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
||||
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [xttsVoice, setXttsVoice] = useState('');
|
||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
|
||||
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
|
||||
const [tempPath, setTempPath] = useState('');
|
||||
|
||||
let logIdCounter = 0;
|
||||
|
|
@ -91,6 +129,47 @@ const SettingsScreen: React.FC = () => {
|
|||
AsyncStorage.getItem('aria_auto_download').then(saved => {
|
||||
if (saved !== null) setAutoDownload(saved === 'true');
|
||||
});
|
||||
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
|
||||
if (saved !== null) setTtsEnabled(saved === 'true');
|
||||
});
|
||||
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) {
|
||||
setTtsPrerollSec(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= VAD_SILENCE_MIN_SEC && n <= VAD_SILENCE_MAX_SEC) {
|
||||
setVadSilenceSec(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
setConvWindowSec(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) setTtsSpeed(n);
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
|
||||
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
|
||||
});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
if (saved) setXttsVoice(saved);
|
||||
});
|
||||
// Voice-Liste vom XTTS-Server holen (via RVS)
|
||||
rvs.send('xtts_list_voices' as any, {});
|
||||
}, []);
|
||||
|
||||
// Speichergroesse berechnen
|
||||
|
|
@ -221,6 +300,47 @@ const SettingsScreen: React.FC = () => {
|
|||
const mode = message.payload.mode as string;
|
||||
if (mode) setCurrentMode(mode);
|
||||
}
|
||||
|
||||
// XTTS-Voice-Liste
|
||||
if (message.type === ('xtts_voices_list' as any)) {
|
||||
const voices = ((message.payload as any).voices || []) as Array<{name: string, size: number}>;
|
||||
setAvailableVoices(voices);
|
||||
}
|
||||
|
||||
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
|
||||
if (message.type === ('xtts_voice_saved' as any)) {
|
||||
const name = (message.payload as any).name as string;
|
||||
if (name) {
|
||||
setXttsVoice(name);
|
||||
AsyncStorage.setItem('aria_xtts_voice', name);
|
||||
}
|
||||
rvs.send('xtts_list_voices' as any, {});
|
||||
}
|
||||
|
||||
// Diagnostic-Voice-Wechsel → lokale App-Stimme auf den neuen Default zuruecksetzen.
|
||||
// Zusaetzlich Preload triggern, damit der User weiss wann's geladen ist.
|
||||
if (message.type === ('config' as any)) {
|
||||
const newVoice = ((message.payload as any).xttsVoice as string) ?? '';
|
||||
setXttsVoice(newVoice);
|
||||
AsyncStorage.setItem('aria_xtts_voice', newVoice);
|
||||
if (newVoice) {
|
||||
setLoadingVoice(newVoice);
|
||||
}
|
||||
}
|
||||
|
||||
// XTTS-Bridge meldet: Stimme fertig geladen
|
||||
if (message.type === ('voice_ready' as any)) {
|
||||
const v = ((message.payload as any).voice as string) ?? '';
|
||||
const err = (message.payload as any).error as string | undefined;
|
||||
const ms = (message.payload as any).loadMs as number | undefined;
|
||||
setLoadingVoice(null);
|
||||
if (err) {
|
||||
ToastAndroid.show(`Stimme "${v}" konnte nicht geladen werden: ${err}`, ToastAndroid.LONG);
|
||||
} else {
|
||||
const suffix = ms ? ` (${(ms / 1000).toFixed(1)}s)` : '';
|
||||
ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit${suffix}`, ToastAndroid.SHORT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
@ -284,6 +404,43 @@ const SettingsScreen: React.FC = () => {
|
|||
// In Produktion: Wert in AsyncStorage persistieren
|
||||
}, []);
|
||||
|
||||
// --- XTTS Voice ---
|
||||
|
||||
const selectVoice = useCallback((voiceName: string) => {
|
||||
setXttsVoice(voiceName);
|
||||
AsyncStorage.setItem('aria_xtts_voice', voiceName);
|
||||
// Preload nur fuer Custom-Voices — "Standard" braucht keinen Ladevorgang
|
||||
if (voiceName) {
|
||||
setLoadingVoice(voiceName);
|
||||
rvs.send('voice_preload' as any, { voice: voiceName, source: 'app' });
|
||||
} else {
|
||||
setLoadingVoice(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteVoice = useCallback((name: string) => {
|
||||
Alert.alert(
|
||||
'Stimme loeschen',
|
||||
`Stimme "${name}" vom Server endgueltig loeschen?\nAlle Apps verlieren sie.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
rvs.send('xtts_delete_voice' as any, { name });
|
||||
if (xttsVoice === name) {
|
||||
setXttsVoice('');
|
||||
AsyncStorage.setItem('aria_xtts_voice', '');
|
||||
}
|
||||
// Liste nach kurzer Wartezeit neu laden (XTTS-Bridge schickt eh neue Liste)
|
||||
setTimeout(() => rvs.send('xtts_list_voices' as any, {}), 500);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [xttsVoice]);
|
||||
|
||||
// --- Modus aendern ---
|
||||
|
||||
const handleModeChange = useCallback((modeId: string) => {
|
||||
|
|
@ -317,6 +474,10 @@ const SettingsScreen: React.FC = () => {
|
|||
onScan={handleQRScan}
|
||||
onClose={() => setScannerVisible(false)}
|
||||
/>
|
||||
<VoiceCloneModal
|
||||
visible={voiceCloneVisible}
|
||||
onClose={() => setVoiceCloneVisible(false)}
|
||||
/>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
|
||||
{/* === Verbindung === */}
|
||||
|
|
@ -442,6 +603,276 @@ const SettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Spracheingabe (geraetelokal) === */}
|
||||
<Text style={styles.sectionTitle}>Spracheingabe</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
|
||||
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
|
||||
Nachdenken; niedriger = schnelleres Senden.
|
||||
Default: {VAD_SILENCE_DEFAULT_SEC.toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(VAD_SILENCE_MIN_SEC, Math.round((vadSilenceSec - 0.5) * 10) / 10);
|
||||
setVadSilenceSec(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={vadSilenceSec <= VAD_SILENCE_MIN_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−0.5</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(VAD_SILENCE_MAX_SEC, Math.round((vadSilenceSec + 0.5) * 10) / 10);
|
||||
setVadSilenceSec(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={vadSilenceSec >= VAD_SILENCE_MAX_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
|
||||
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
|
||||
Sprichst du nichts → Mikrofon zu.
|
||||
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Wake-Word (komplett on-device, openWakeWord) === */}
|
||||
<Text style={styles.sectionTitle}>Wake-Word</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.toggleHint}>
|
||||
Lokale Erkennung via openWakeWord (ONNX, on-device). Kein API-Key,
|
||||
kein Cloud-Roundtrip — Audio verlaesst das Geraet nicht. Wenn das Ohr
|
||||
aktiv ist, hoerst du normal mit; sagst du das Wake-Word, startet eine
|
||||
Konversation mit ARIA.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Eigene Wake-Words via openWakeWord-Notebook trainierbar (gratis).
|
||||
Custom-Upload ueber Diagnostic kommt in einer spaeteren Version.
|
||||
</Text>
|
||||
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
|
||||
{WAKE_KEYWORDS.map(kw => (
|
||||
<TouchableOpacity
|
||||
key={kw}
|
||||
style={[
|
||||
styles.keywordChip,
|
||||
wakeKeyword === kw && styles.keywordChipActive,
|
||||
]}
|
||||
onPress={() => setWakeKeyword(kw)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.keywordChipText,
|
||||
wakeKeyword === kw && styles.keywordChipTextActive,
|
||||
]}>
|
||||
{KEYWORD_LABELS[kw]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {flex: 1}]}
|
||||
onPress={async () => {
|
||||
setWakeStatus('Initialisiere...');
|
||||
try {
|
||||
const ok = await wakeWordService.configure(wakeKeyword);
|
||||
setWakeStatus(ok ? `✅ "${KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS]}" bereit` : '❌ Init-Fehler — Logs pruefen');
|
||||
} catch (err: any) {
|
||||
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
|
||||
}
|
||||
setTimeout(() => setWakeStatus(''), 5000);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>Speichern + Aktivieren</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{!!wakeStatus && (
|
||||
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* === Sprachausgabe (geraetelokal) === */}
|
||||
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>Sprachausgabe auf diesem Geraet</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Nur lokal — andere Geraete sind unabhaengig.
|
||||
Wenn aus, erscheint im Chat auch kein Mund-Button.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={ttsEnabled}
|
||||
onValueChange={(val) => {
|
||||
setTtsEnabled(val);
|
||||
AsyncStorage.setItem('aria_tts_enabled', String(val));
|
||||
}}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={ttsEnabled ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{ttsEnabled && (
|
||||
<View style={{marginTop: 20}}>
|
||||
<Text style={styles.toggleLabel}>Puffer vor Wiedergabestart</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wie viel Audio gesammelt wird bevor die Wiedergabe startet.
|
||||
Hoeher = robuster gegen Render-Pausen, aber mehr Startverzoegerung.
|
||||
Default: {TTS_PREROLL_DEFAULT_SEC.toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(TTS_PREROLL_MIN_SEC, Math.round((ttsPrerollSec - 0.5) * 10) / 10);
|
||||
setTtsPrerollSec(next);
|
||||
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={ttsPrerollSec <= TTS_PREROLL_MIN_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−0.5</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{ttsPrerollSec.toFixed(1)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(TTS_PREROLL_MAX_SEC, Math.round((ttsPrerollSec + 0.5) * 10) / 10);
|
||||
setTtsPrerollSec(next);
|
||||
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={ttsPrerollSec >= TTS_PREROLL_MAX_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Sprechgeschwindigkeit</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wie schnell ARIA spricht. 1.0 = Normal. Niedriger = langsamer, hoeher = schneller.
|
||||
Wird an F5-TTS als speed-Param uebergeben und pro Geraet gespeichert.
|
||||
Default: {TTS_SPEED_DEFAULT.toFixed(1)}x.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(TTS_SPEED_MIN, Math.round((ttsSpeed - 0.1) * 10) / 10);
|
||||
setTtsSpeed(next);
|
||||
AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={ttsSpeed <= TTS_SPEED_MIN}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−0.1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{ttsSpeed.toFixed(1)} x</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(TTS_SPEED_MAX, Math.round((ttsSpeed + 0.1) * 10) / 10);
|
||||
setTtsSpeed(next);
|
||||
AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={ttsSpeed >= TTS_SPEED_MAX}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+0.1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{ttsEnabled && (
|
||||
<View style={{marginTop: 20}}>
|
||||
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz —
|
||||
ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme.
|
||||
</Text>
|
||||
|
||||
{availableVoices.length === 0 ? (
|
||||
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
|
||||
Keine geklonten Stimmen vorhanden — unten "Eigene Stimme aufnehmen".
|
||||
</Text>
|
||||
) : (
|
||||
availableVoices.map(v => (
|
||||
<View key={v.name} style={[styles.voiceRow, xttsVoice === v.name && styles.voiceRowActive]}>
|
||||
<TouchableOpacity
|
||||
style={{flex: 1}}
|
||||
onPress={() => selectVoice(v.name)}
|
||||
>
|
||||
<Text style={[styles.voiceRowName, xttsVoice === v.name && styles.voiceRowNameActive]}>
|
||||
{v.name}
|
||||
</Text>
|
||||
<Text style={styles.voiceRowMeta}>{(v.size / 1024).toFixed(0)} KB</Text>
|
||||
</TouchableOpacity>
|
||||
{loadingVoice === v.name && (
|
||||
<ActivityIndicator size="small" color="#0096FF" style={{marginRight: 8}} />
|
||||
)}
|
||||
{xttsVoice === v.name && loadingVoice !== v.name && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
|
||||
<TouchableOpacity onPress={() => deleteVoice(v.name)} style={styles.voiceRowDelete}>
|
||||
<Text style={styles.voiceRowDeleteIcon}>X</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 12}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {flex: 1}]}
|
||||
onPress={() => setVoiceCloneVisible(true)}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>{'\uD83C\uDFA4'} Eigene Stimme aufnehmen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, {flex: 0.4, marginTop: 0}]}
|
||||
onPress={() => rvs.send('xtts_list_voices' as any, {})}
|
||||
>
|
||||
<Text style={styles.clearButtonText}>Aktualisieren</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* === Speicher === */}
|
||||
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
|
||||
<View style={styles.card}>
|
||||
|
|
@ -601,11 +1032,21 @@ const SettingsScreen: React.FC = () => {
|
|||
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||||
<Text style={styles.aboutVersion}>Version 0.0.1.8 </Text>
|
||||
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
||||
<Text style={styles.aboutInfo}>
|
||||
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
|
||||
Gebaut mit React Native + TypeScript.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {marginTop: 12}]}
|
||||
onPress={() => {
|
||||
const updateService = require('../services/updater').default;
|
||||
updateService.checkForUpdate();
|
||||
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Platz am Ende */}
|
||||
|
|
@ -744,6 +1185,87 @@ const styles = StyleSheet.create({
|
|||
marginTop: 2,
|
||||
},
|
||||
|
||||
// XTTS Voice List
|
||||
voiceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginTop: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
voiceRowActive: {
|
||||
borderColor: '#0096FF',
|
||||
backgroundColor: '#0D1A2E',
|
||||
},
|
||||
voiceRowName: {
|
||||
color: '#CCCCDD',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
voiceRowNameActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
voiceRowMeta: {
|
||||
color: '#666680',
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
},
|
||||
voiceRowCheck: {
|
||||
color: '#34C759',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
voiceRowDelete: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(255,59,48,0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 4,
|
||||
},
|
||||
voiceRowDeleteIcon: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
// Stimmen
|
||||
voiceBtn: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#1E1E2E',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
voiceBtnActive: {
|
||||
borderColor: '#0096FF',
|
||||
backgroundColor: '#0D1A2E',
|
||||
},
|
||||
voiceBtnIcon: {
|
||||
fontSize: 28,
|
||||
marginBottom: 4,
|
||||
},
|
||||
voiceBtnText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
voiceBtnTextActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
voiceBtnHint: {
|
||||
color: '#555570',
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Speicher
|
||||
storagePathText: {
|
||||
color: '#0096FF',
|
||||
|
|
@ -882,6 +1404,56 @@ const styles = StyleSheet.create({
|
|||
bottomSpacer: {
|
||||
height: 40,
|
||||
},
|
||||
|
||||
prerollRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 12,
|
||||
gap: 16,
|
||||
},
|
||||
prerollButton: {
|
||||
backgroundColor: '#2A2A3E',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
minWidth: 72,
|
||||
alignItems: 'center',
|
||||
},
|
||||
prerollButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
prerollValue: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
minWidth: 80,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
|
||||
*/
|
||||
|
||||
import { Platform, PermissionsAndroid } from 'react-native';
|
||||
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
|
||||
import Sound from 'react-native-sound';
|
||||
import RNFS from 'react-native-fs';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import AudioRecorderPlayer, {
|
||||
AudioEncoderAndroidType,
|
||||
AudioSourceAndroidType,
|
||||
|
|
@ -16,6 +17,38 @@ import AudioRecorderPlayer, {
|
|||
OutputFormatAndroidType,
|
||||
} from 'react-native-audio-recorder-player';
|
||||
|
||||
// Base64-Encoder fuer Binary-Strings (Header-Bytes → Base64)
|
||||
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
function btoaSafe(bin: string): string {
|
||||
let out = '';
|
||||
const len = bin.length;
|
||||
for (let i = 0; i < len; i += 3) {
|
||||
const b1 = bin.charCodeAt(i) & 0xff;
|
||||
const b2 = i + 1 < len ? bin.charCodeAt(i + 1) & 0xff : 0;
|
||||
const b3 = i + 2 < len ? bin.charCodeAt(i + 2) & 0xff : 0;
|
||||
out += B64_CHARS[b1 >> 2];
|
||||
out += B64_CHARS[((b1 & 0x03) << 4) | (b2 >> 4)];
|
||||
out += i + 1 < len ? B64_CHARS[((b2 & 0x0f) << 2) | (b3 >> 6)] : '=';
|
||||
out += i + 2 < len ? B64_CHARS[b3 & 0x3f] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
|
||||
const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
||||
AudioFocus?: {
|
||||
requestDuck: () => Promise<boolean>;
|
||||
requestExclusive: () => Promise<boolean>;
|
||||
release: () => Promise<boolean>;
|
||||
};
|
||||
PcmStreamPlayer?: {
|
||||
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
|
||||
writeChunk: (base64Pcm: string) => Promise<boolean>;
|
||||
end: () => Promise<boolean>;
|
||||
stop: () => Promise<boolean>;
|
||||
};
|
||||
};
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export interface RecordingResult {
|
||||
|
|
@ -41,7 +74,91 @@ const AUDIO_ENCODING = 'audio/wav';
|
|||
|
||||
// VAD (Voice Activity Detection) — Stille-Erkennung
|
||||
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
|
||||
const VAD_SILENCE_DURATION_MS = 1800; // ms Stille bevor Auto-Stop
|
||||
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
|
||||
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
|
||||
|
||||
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
|
||||
// die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings.
|
||||
export const VAD_SILENCE_DEFAULT_SEC = 2.8;
|
||||
export const VAD_SILENCE_MIN_SEC = 1.0;
|
||||
export const VAD_SILENCE_MAX_SEC = 8.0;
|
||||
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
||||
|
||||
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
|
||||
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
|
||||
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
|
||||
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
|
||||
export const CONV_WINDOW_MIN_SEC = 3.0;
|
||||
export const CONV_WINDOW_MAX_SEC = 20.0;
|
||||
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
|
||||
|
||||
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
|
||||
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
|
||||
export const TTS_SPEED_DEFAULT = 1.0;
|
||||
export const TTS_SPEED_MIN = 0.1;
|
||||
export const TTS_SPEED_MAX = 5.0;
|
||||
export const TTS_SPEED_STORAGE_KEY = 'aria_tts_speed';
|
||||
|
||||
export async function loadTtsSpeed(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) return n;
|
||||
}
|
||||
} catch {}
|
||||
return TTS_SPEED_DEFAULT;
|
||||
}
|
||||
|
||||
export async function loadConvWindowMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
return Math.round(n * 1000);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
|
||||
}
|
||||
|
||||
async function loadVadSilenceMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= VAD_SILENCE_MIN_SEC && n <= VAD_SILENCE_MAX_SEC) {
|
||||
return Math.round(n * 1000);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return Math.round(VAD_SILENCE_DEFAULT_SEC * 1000);
|
||||
}
|
||||
|
||||
// Max-Dauer einer Aufnahme (Notbremse gegen Runaway-Loops). Auf 2 Minuten
|
||||
// hochgezogen damit auch laengere Erklaerungen durchgehen.
|
||||
const MAX_RECORDING_MS = 120000;
|
||||
|
||||
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
|
||||
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
|
||||
export const TTS_PREROLL_DEFAULT_SEC = 3.5;
|
||||
export const TTS_PREROLL_MIN_SEC = 0; // 0 = sofort abspielen (F5-TTS ist schnell genug)
|
||||
export const TTS_PREROLL_MAX_SEC = 6.0;
|
||||
export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec';
|
||||
|
||||
async function loadPrerollSec(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return TTS_PREROLL_DEFAULT_SEC;
|
||||
}
|
||||
|
||||
// --- Audio-Service ---
|
||||
|
||||
|
|
@ -55,16 +172,104 @@ class AudioService {
|
|||
private recorder: AudioRecorderPlayer;
|
||||
private recordingPath: string = '';
|
||||
|
||||
// Audio-Queue fuer sequentielle TTS-Wiedergabe
|
||||
private audioQueue: string[] = [];
|
||||
private isPlaying: boolean = false;
|
||||
private preloadedSound: Sound | null = null;
|
||||
private preloadedPath: string = '';
|
||||
|
||||
// Sprach-Gate: Aufnahme erst senden wenn tatsaechlich gesprochen wurde
|
||||
private speechDetected: boolean = false;
|
||||
private speechStartTime: number = 0;
|
||||
|
||||
// PCM-Stream (XTTS): aktive Session + Cache-Puffer pro messageId
|
||||
private pcmStreamActive: boolean = false;
|
||||
private pcmMessageId: string = '';
|
||||
private pcmSampleRate: number = 24000;
|
||||
private pcmChannels: number = 1;
|
||||
private pcmBuffer: string[] = []; // base64-chunks zum spaeteren WAV-Build
|
||||
private pcmBytesCollected: number = 0;
|
||||
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
|
||||
|
||||
// AudioFocus wird verzoegert freigegeben — wenn ARIA eine zweite Antwort
|
||||
// direkt hinterherschickt (oder ein neuer Stream startet), bleibt Spotify
|
||||
// pausiert. Ohne diese Verzoegerung springt Spotify im Mikro-Sekunden-Gap
|
||||
// zwischen zwei Streams kurz wieder an.
|
||||
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly FOCUS_RELEASE_DELAY_MS = 800;
|
||||
|
||||
// Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER
|
||||
// wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir
|
||||
// den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt,
|
||||
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
|
||||
private _conversationFocusActive: boolean = false;
|
||||
|
||||
// VAD State
|
||||
private vadEnabled: boolean = false;
|
||||
private lastSpeechTime: number = 0;
|
||||
private vadTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
|
||||
private silenceFired: boolean = false;
|
||||
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.recorder = new AudioRecorderPlayer();
|
||||
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
|
||||
}
|
||||
|
||||
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
||||
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
|
||||
* eine zweite Antwort direkt hinterherschickt) kurz wieder an.
|
||||
* Im Conversation-Mode (Wake-Word conversing) wird das Release komplett
|
||||
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
|
||||
private _releaseFocusDeferred(): void {
|
||||
if (this._conversationFocusActive) {
|
||||
this._cancelDeferredFocusRelease();
|
||||
return;
|
||||
}
|
||||
this._cancelDeferredFocusRelease();
|
||||
this.focusReleaseTimer = setTimeout(() => {
|
||||
this.focusReleaseTimer = null;
|
||||
if (this._conversationFocusActive) return;
|
||||
AudioFocus?.release().catch(() => {});
|
||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
||||
}
|
||||
|
||||
private _cancelDeferredFocusRelease(): void {
|
||||
if (this.focusReleaseTimer) {
|
||||
clearTimeout(this.focusReleaseTimer);
|
||||
this.focusReleaseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
|
||||
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
|
||||
acquireConversationFocus(): void {
|
||||
if (this._conversationFocusActive) return;
|
||||
this._conversationFocusActive = true;
|
||||
this._cancelDeferredFocusRelease();
|
||||
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
|
||||
AudioFocus?.requestDuck().catch(() => {});
|
||||
}
|
||||
|
||||
/** Conversation-Mode endet → Focus darf wieder freigegeben werden
|
||||
* (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */
|
||||
releaseConversationFocus(): void {
|
||||
if (!this._conversationFocusActive) return;
|
||||
this._conversationFocusActive = false;
|
||||
console.log('[Audio] Conversation-Focus inaktiv');
|
||||
this._releaseFocusDeferred();
|
||||
}
|
||||
|
||||
/** TTS-Wiedergabe haart stoppen — z.B. wenn ein Anruf reinkommt.
|
||||
* Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
|
||||
haltAllPlayback(reason: string = ''): void {
|
||||
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
|
||||
this._conversationFocusActive = false;
|
||||
this.stopPlayback();
|
||||
}
|
||||
|
||||
// --- Berechtigungen ---
|
||||
|
||||
async requestMicrophonePermission(): Promise<boolean> {
|
||||
|
|
@ -91,8 +296,16 @@ class AudioService {
|
|||
|
||||
// --- Aufnahme ---
|
||||
|
||||
/** Mikrofon-Aufnahme starten */
|
||||
async startRecording(autoStop: boolean = false): Promise<boolean> {
|
||||
/** Mikrofon-Aufnahme starten.
|
||||
*
|
||||
* @param autoStop VAD aktivieren — Auto-Stop bei Stille
|
||||
* @param noSpeechTimeoutMs Wenn der User innerhalb dieser Zeit nichts sagt,
|
||||
* wird Stille gemeldet (Recording wird verworfen).
|
||||
* Fuer Conversation-Window: nach ARIA's Antwort
|
||||
* hast du nur N Sekunden um anzufangen, sonst
|
||||
* Gespraech zu Ende.
|
||||
*/
|
||||
async startRecording(autoStop: boolean = false, noSpeechTimeoutMs: number = 0): Promise<boolean> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] Aufnahme laeuft bereits');
|
||||
return false;
|
||||
|
|
@ -108,6 +321,10 @@ class AudioService {
|
|||
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||
this.stopPlayback();
|
||||
|
||||
// Aufraeumen: Alte aria_recording_ und aria_tts_ Files loeschen
|
||||
// (Schutz gegen Cache-Ueberlauf im Gespraechsmodus bei vielen Zyklen)
|
||||
this._cleanupStaleCacheFiles().catch(() => {});
|
||||
|
||||
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
|
||||
|
||||
// Aufnahme mit Metering starten
|
||||
|
|
@ -115,6 +332,8 @@ class AudioService {
|
|||
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
||||
AudioSourceAndroid: AudioSourceAndroidType.MIC,
|
||||
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
|
||||
AudioSamplingRateAndroid: 16000,
|
||||
AudioChannelsAndroid: 1,
|
||||
}, true); // meteringEnabled = true
|
||||
|
||||
// Metering-Callback
|
||||
|
|
@ -122,7 +341,21 @@ class AudioService {
|
|||
const db = e.currentMetering ?? -160;
|
||||
this.meterListeners.forEach(cb => cb(db));
|
||||
|
||||
// VAD: Stille erkennen
|
||||
// Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird
|
||||
if (db > VAD_SPEECH_THRESHOLD_DB) {
|
||||
if (!this.speechDetected && this.speechStartTime === 0) {
|
||||
this.speechStartTime = Date.now();
|
||||
}
|
||||
if (this.speechStartTime > 0 && Date.now() - this.speechStartTime >= VAD_SPEECH_MIN_MS) {
|
||||
this.speechDetected = true;
|
||||
}
|
||||
} else {
|
||||
if (!this.speechDetected) {
|
||||
this.speechStartTime = 0; // Reset wenn noch nicht als Sprache erkannt
|
||||
}
|
||||
}
|
||||
|
||||
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
|
||||
if (this.vadEnabled) {
|
||||
if (db > VAD_SILENCE_THRESHOLD_DB) {
|
||||
this.lastSpeechTime = Date.now();
|
||||
|
|
@ -132,18 +365,57 @@ class AudioService {
|
|||
|
||||
this.recordingStartTime = Date.now();
|
||||
this.lastSpeechTime = Date.now();
|
||||
this.speechDetected = false;
|
||||
this.speechStartTime = 0;
|
||||
this.setState('recording');
|
||||
|
||||
// VAD aktivieren
|
||||
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestExclusive().catch(() => {});
|
||||
|
||||
// VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar).
|
||||
// WICHTIG: jeder Trigger (VAD-Stille / Max-Dauer / No-Speech-Window)
|
||||
// disable SOFORT den VAD-Flag und clear den Timer, BEVOR die Listener
|
||||
// gefeuert werden. Sonst feuert das setInterval weiter alle 200ms und
|
||||
// ruft stopRecording parallel auf → audio-recorder-player crasht.
|
||||
this.vadEnabled = autoStop;
|
||||
this.silenceFired = false;
|
||||
const fireSilenceOnce = (reason: string) => {
|
||||
if (this.silenceFired) return;
|
||||
this.silenceFired = true;
|
||||
this.vadEnabled = false;
|
||||
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
|
||||
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
|
||||
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
|
||||
console.log('[Audio] Silence-Fire: %s', reason);
|
||||
this.silenceListeners.forEach(cb => {
|
||||
try { cb(); } catch (e) { console.warn('[Audio] silence listener err:', e); }
|
||||
});
|
||||
};
|
||||
if (autoStop) {
|
||||
const vadSilenceMs = await loadVadSilenceMs();
|
||||
console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms',
|
||||
vadSilenceMs, MAX_RECORDING_MS);
|
||||
this.vadTimer = setInterval(() => {
|
||||
const silenceDuration = Date.now() - this.lastSpeechTime;
|
||||
if (silenceDuration >= VAD_SILENCE_DURATION_MS) {
|
||||
console.log(`[Audio] VAD: ${silenceDuration}ms Stille — Auto-Stop`);
|
||||
this.silenceListeners.forEach(cb => cb());
|
||||
if (silenceDuration >= vadSilenceMs) {
|
||||
fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`);
|
||||
}
|
||||
}, 200);
|
||||
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
|
||||
this.maxDurationTimer = setTimeout(() => {
|
||||
fireSilenceOnce(`Max-Dauer ${MAX_RECORDING_MS}ms`);
|
||||
}, MAX_RECORDING_MS);
|
||||
}
|
||||
|
||||
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
|
||||
// anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie).
|
||||
if (noSpeechTimeoutMs > 0) {
|
||||
this.noSpeechTimer = setTimeout(() => {
|
||||
if (!this.speechDetected && this.recordingState === 'recording') {
|
||||
fireSilenceOnce(`Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache`);
|
||||
}
|
||||
}, noSpeechTimeoutMs);
|
||||
}
|
||||
|
||||
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
||||
|
|
@ -168,12 +440,33 @@ class AudioService {
|
|||
clearInterval(this.vadTimer);
|
||||
this.vadTimer = null;
|
||||
}
|
||||
if (this.maxDurationTimer) {
|
||||
clearTimeout(this.maxDurationTimer);
|
||||
this.maxDurationTimer = null;
|
||||
}
|
||||
if (this.noSpeechTimer) {
|
||||
clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.recorder.stopRecorder();
|
||||
this.recorder.removeRecordBackListener();
|
||||
|
||||
// Audio-Focus verzoegert freigeben — gleich kommt die TTS-Antwort,
|
||||
// im Gap soll Spotify nicht hochkommen.
|
||||
this._releaseFocusDeferred();
|
||||
|
||||
const durationMs = Date.now() - this.recordingStartTime;
|
||||
const hadSpeech = this.speechDetected;
|
||||
|
||||
// Sprach-Gate: Wenn keine Sprache erkannt → Aufnahme verwerfen
|
||||
if (!hadSpeech) {
|
||||
RNFS.unlink(this.recordingPath).catch(() => {});
|
||||
this.setState('idle');
|
||||
console.log('[Audio] Aufnahme verworfen — keine Sprache erkannt (nur Umgebungsgeraeusche)');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Audio-Datei als Base64 lesen
|
||||
const base64Data = await RNFS.readFile(this.recordingPath, 'base64');
|
||||
|
|
@ -182,7 +475,7 @@ class AudioService {
|
|||
RNFS.unlink(this.recordingPath).catch(() => {});
|
||||
|
||||
this.setState('idle');
|
||||
console.log(`[Audio] Aufnahme beendet (${durationMs}ms, ${Math.round(base64Data.length / 1024)}KB)`);
|
||||
console.log(`[Audio] Aufnahme beendet (${durationMs}ms, ${Math.round(base64Data.length / 1024)}KB, Sprache erkannt)`);
|
||||
|
||||
return {
|
||||
base64: base64Data,
|
||||
|
|
@ -198,47 +491,344 @@ class AudioService {
|
|||
|
||||
// --- Wiedergabe ---
|
||||
|
||||
/** Base64-kodiertes Audio abspielen (z.B. TTS-Antwort von ARIA) */
|
||||
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
|
||||
async playAudio(base64Data: string): Promise<void> {
|
||||
if (!base64Data) return;
|
||||
|
||||
// Laufende Wiedergabe stoppen
|
||||
this.stopPlayback();
|
||||
|
||||
try {
|
||||
// Base64 -> temporaere WAV-Datei -> Sound abspielen
|
||||
const tmpPath = `${RNFS.CachesDirectoryPath}/aria_tts_${Date.now()}.wav`;
|
||||
await RNFS.writeFile(tmpPath, base64Data, 'base64');
|
||||
|
||||
this.currentSound = new Sound(tmpPath, '', (error) => {
|
||||
if (error) {
|
||||
console.error('[Audio] Fehler beim Laden:', error);
|
||||
RNFS.unlink(tmpPath).catch(() => {});
|
||||
return;
|
||||
}
|
||||
this.currentSound?.play((success) => {
|
||||
if (success) {
|
||||
console.log('[Audio] Wiedergabe abgeschlossen');
|
||||
} else {
|
||||
console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
||||
}
|
||||
this.currentSound?.release();
|
||||
this.currentSound = null;
|
||||
RNFS.unlink(tmpPath).catch(() => {});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Audio] Wiedergabefehler:', err);
|
||||
this.audioQueue.push(base64Data);
|
||||
if (!this.isPlaying) {
|
||||
this._playNext();
|
||||
}
|
||||
}
|
||||
|
||||
/** Laufende Wiedergabe stoppen */
|
||||
/** Base64-Audio persistent speichern. Gibt file:// Pfad zurueck (oder leer bei Fehler). */
|
||||
async cacheAudio(base64Data: string, messageId: string): Promise<string> {
|
||||
if (!base64Data || !messageId) return '';
|
||||
try {
|
||||
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
|
||||
await RNFS.mkdir(dir).catch(() => {});
|
||||
const path = `${dir}/${messageId}.wav`;
|
||||
// Wenn Datei schon existiert (z.B. XTTS Chunks) → anhaengen statt ueberschreiben
|
||||
const exists = await RNFS.exists(path);
|
||||
if (exists) {
|
||||
// Bestehende + neue Base64 laden, zusammenkleben (fuer jetzt: ueberschreiben)
|
||||
// XTTS sendet mehrere Chunks — bei mehrfacher Ueberschreibung bleibt nur der letzte
|
||||
// Fuer eine echte Konkatenation muesste WAV-Header gemerged werden
|
||||
await RNFS.writeFile(path, base64Data, 'base64');
|
||||
} else {
|
||||
await RNFS.writeFile(path, base64Data, 'base64');
|
||||
}
|
||||
return `file://${path}`;
|
||||
} catch (err) {
|
||||
console.warn('[Audio] cacheAudio fehlgeschlagen:', err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen.
|
||||
* silent=true → nur cachen, nicht abspielen (z.B. wenn TTS geraetelokal gemutet).
|
||||
* Gibt bei final=true den Cache-Pfad zurueck (file://) oder '' wenn nicht gecached.
|
||||
*
|
||||
* Wrapper serialisiert aufeinanderfolgende Chunk-Calls via Promise-Queue —
|
||||
* sonst gabs bei kurzen Streams einen Race: final-Chunk konnte `end()` rufen
|
||||
* BEVOR der vorherige `start()` im Native-Modul fertig war. Der Writer-
|
||||
* Thread sah dann endRequested=true ohne jemals Chunks zu verarbeiten. */
|
||||
private _pcmChunkQueue: Promise<any> = Promise.resolve();
|
||||
async handlePcmChunk(payload: {
|
||||
base64: string;
|
||||
sampleRate?: number;
|
||||
channels?: number;
|
||||
messageId?: string;
|
||||
chunk?: number;
|
||||
final?: boolean;
|
||||
silent?: boolean;
|
||||
}): Promise<string> {
|
||||
const p = this._pcmChunkQueue.then(() => this._handlePcmChunkImpl(payload)).catch(err => {
|
||||
console.warn('[Audio] handlePcmChunk queued err:', err);
|
||||
return '';
|
||||
});
|
||||
// Chain only on the side effect — callers still get the per-call result
|
||||
this._pcmChunkQueue = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
private async _handlePcmChunkImpl(payload: {
|
||||
base64: string;
|
||||
sampleRate?: number;
|
||||
channels?: number;
|
||||
messageId?: string;
|
||||
chunk?: number;
|
||||
final?: boolean;
|
||||
silent?: boolean;
|
||||
}): Promise<string> {
|
||||
const silent = !!payload.silent;
|
||||
if (!silent && !PcmStreamPlayer) {
|
||||
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
||||
return '';
|
||||
}
|
||||
// Debug-Log bei Chunk 0 eines neuen Streams — damit man im adb logcat
|
||||
// sieht warum der Auto-Playback greift oder nicht.
|
||||
if ((payload.chunk ?? 0) === 0 && !this.pcmStreamActive) {
|
||||
console.log('[Audio] PCM-Stream start: silent=%s messageId=%s sr=%s ch=%s',
|
||||
silent, payload.messageId || '(none)',
|
||||
payload.sampleRate, payload.channels);
|
||||
}
|
||||
|
||||
const messageId = payload.messageId || '';
|
||||
const sampleRate = payload.sampleRate || 24000;
|
||||
const channels = payload.channels || 1;
|
||||
const base64 = payload.base64 || '';
|
||||
const isFinal = !!payload.final;
|
||||
|
||||
// Neuer Stream? (messageId Wechsel oder nicht aktiv)
|
||||
if (!this.pcmStreamActive || this.pcmMessageId !== messageId) {
|
||||
if (this.pcmStreamActive && !silent) {
|
||||
try { await PcmStreamPlayer!.stop(); } catch {}
|
||||
this.pcmBuffer = [];
|
||||
this.pcmBytesCollected = 0;
|
||||
}
|
||||
this.pcmStreamActive = true;
|
||||
this.pcmMessageId = messageId;
|
||||
this.pcmSampleRate = sampleRate;
|
||||
this.pcmChannels = channels;
|
||||
this.pcmBuffer = [];
|
||||
this.pcmBytesCollected = 0;
|
||||
if (!silent) {
|
||||
const prerollSec = await loadPrerollSec();
|
||||
try {
|
||||
await PcmStreamPlayer!.start(sampleRate, channels, prerollSec);
|
||||
} catch (err) {
|
||||
console.error('[Audio] PcmStreamPlayer.start fehlgeschlagen:', err);
|
||||
this.pcmStreamActive = false;
|
||||
return '';
|
||||
}
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestDuck().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk — immer cachen, nur bei !silent auch abspielen
|
||||
if (base64) {
|
||||
if (!silent) {
|
||||
try { await PcmStreamPlayer!.writeChunk(base64); } catch (err) { console.warn('[Audio] writeChunk', err); }
|
||||
}
|
||||
if (messageId && this.pcmBytesCollected < this.PCM_MAX_CACHE_BYTES) {
|
||||
this.pcmBuffer.push(base64);
|
||||
this.pcmBytesCollected += Math.floor(base64.length * 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinal) {
|
||||
if (!silent) {
|
||||
// end() resolved jetzt erst wenn der native Writer-Thread fertig
|
||||
// ist (alle Samples ausgespielt) — danach AudioFocus verzoegert
|
||||
// freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei
|
||||
// ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb
|
||||
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen.
|
||||
try { await PcmStreamPlayer!.end(); } catch {}
|
||||
this._releaseFocusDeferred();
|
||||
}
|
||||
this.pcmStreamActive = false;
|
||||
|
||||
if (messageId && this.pcmBuffer.length > 0) {
|
||||
const audioPath = await this._savePcmBufferAsWav(messageId);
|
||||
this.pcmBuffer = [];
|
||||
this.pcmBytesCollected = 0;
|
||||
this.pcmMessageId = '';
|
||||
return audioPath;
|
||||
}
|
||||
this.pcmMessageId = '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Gesammelte PCM-Chunks als WAV speichern. Gibt file:// Pfad zurueck. */
|
||||
private async _savePcmBufferAsWav(messageId: string): Promise<string> {
|
||||
try {
|
||||
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
|
||||
await RNFS.mkdir(dir).catch(() => {});
|
||||
const path = `${dir}/${messageId}.wav`;
|
||||
|
||||
// WAV-Header fuer PCM s16le
|
||||
const sampleRate = this.pcmSampleRate;
|
||||
const channels = this.pcmChannels;
|
||||
const bitsPerSample = 16;
|
||||
const byteRate = sampleRate * channels * bitsPerSample / 8;
|
||||
const blockAlign = channels * bitsPerSample / 8;
|
||||
const dataSize = this.pcmBytesCollected;
|
||||
const fileSize = 36 + dataSize;
|
||||
|
||||
// Header als Base64 (44 bytes)
|
||||
const header = new Uint8Array(44);
|
||||
const dv = new DataView(header.buffer);
|
||||
// "RIFF"
|
||||
header[0] = 0x52; header[1] = 0x49; header[2] = 0x46; header[3] = 0x46;
|
||||
dv.setUint32(4, fileSize, true);
|
||||
// "WAVE"
|
||||
header[8] = 0x57; header[9] = 0x41; header[10] = 0x56; header[11] = 0x45;
|
||||
// "fmt "
|
||||
header[12] = 0x66; header[13] = 0x6d; header[14] = 0x74; header[15] = 0x20;
|
||||
dv.setUint32(16, 16, true); // fmt chunk size
|
||||
dv.setUint16(20, 1, true); // PCM format
|
||||
dv.setUint16(22, channels, true);
|
||||
dv.setUint32(24, sampleRate, true);
|
||||
dv.setUint32(28, byteRate, true);
|
||||
dv.setUint16(32, blockAlign, true);
|
||||
dv.setUint16(34, bitsPerSample, true);
|
||||
// "data"
|
||||
header[36] = 0x64; header[37] = 0x61; header[38] = 0x74; header[39] = 0x61;
|
||||
dv.setUint32(40, dataSize, true);
|
||||
|
||||
// Header als base64
|
||||
let headerB64 = '';
|
||||
const chunk = 1024;
|
||||
for (let i = 0; i < header.length; i += chunk) {
|
||||
headerB64 += String.fromCharCode(...Array.from(header.slice(i, i + chunk)));
|
||||
}
|
||||
headerB64 = btoaSafe(headerB64);
|
||||
|
||||
// Datei schreiben: Header + alle PCM-Chunks
|
||||
await RNFS.writeFile(path, headerB64, 'base64');
|
||||
for (const b64 of this.pcmBuffer) {
|
||||
await RNFS.appendFile(path, b64, 'base64');
|
||||
}
|
||||
console.log(`[Audio] PCM-Cache geschrieben: ${path} (${(dataSize / 1024).toFixed(0)}KB, ${this.pcmBuffer.length} chunks)`);
|
||||
return `file://${path}`;
|
||||
} catch (err) {
|
||||
console.warn('[Audio] _savePcmBufferAsWav fehlgeschlagen:', err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen. */
|
||||
async playFromPath(filePath: string): Promise<void> {
|
||||
if (!filePath) return;
|
||||
try {
|
||||
const cleanPath = filePath.replace(/^file:\/\//, '');
|
||||
if (!(await RNFS.exists(cleanPath))) {
|
||||
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
|
||||
return;
|
||||
}
|
||||
const b64 = await RNFS.readFile(cleanPath, 'base64');
|
||||
this.playAudio(b64);
|
||||
} catch (err) {
|
||||
console.warn('[Audio] playFromPath fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Callback wenn alle Audio-Teile abgespielt sind
|
||||
private playbackFinishedListeners: (() => void)[] = [];
|
||||
|
||||
onPlaybackFinished(callback: () => void): () => void {
|
||||
this.playbackFinishedListeners.push(callback);
|
||||
return () => {
|
||||
this.playbackFinishedListeners = this.playbackFinishedListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Naechstes Audio aus der Queue abspielen */
|
||||
private async _playNext(): Promise<void> {
|
||||
if (this.audioQueue.length === 0) {
|
||||
this.isPlaying = false;
|
||||
// Audio-Focus verzoegert abgeben → wenn gleich noch eine Antwort kommt,
|
||||
// bleibt Spotify pausiert.
|
||||
this._releaseFocusDeferred();
|
||||
// Alle Audio-Teile abgespielt → Listener benachrichtigen
|
||||
this.playbackFinishedListeners.forEach(cb => cb());
|
||||
return;
|
||||
}
|
||||
|
||||
// Beim ersten Playback-Start: andere Apps ducken
|
||||
if (!this.isPlaying) {
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestDuck().catch(() => {});
|
||||
}
|
||||
this.isPlaying = true;
|
||||
|
||||
// Preloaded Sound verwenden wenn verfuegbar, sonst neu laden
|
||||
let sound: Sound;
|
||||
let soundPath: string;
|
||||
|
||||
if (this.preloadedSound) {
|
||||
sound = this.preloadedSound;
|
||||
soundPath = this.preloadedPath;
|
||||
this.preloadedSound = null;
|
||||
this.preloadedPath = '';
|
||||
// Daten aus Queue entfernen (wurde schon preloaded)
|
||||
this.audioQueue.shift();
|
||||
} else {
|
||||
const base64Data = this.audioQueue.shift()!;
|
||||
try {
|
||||
soundPath = `${RNFS.CachesDirectoryPath}/aria_tts_${Date.now()}.wav`;
|
||||
await RNFS.writeFile(soundPath, base64Data, 'base64');
|
||||
sound = await new Promise<Sound>((resolve, reject) => {
|
||||
const s = new Sound(soundPath, '', (err) => err ? reject(err) : resolve(s));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Audio] Laden fehlgeschlagen:', err);
|
||||
this._playNext();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentSound = sound;
|
||||
|
||||
// Naechstes Audio schon vorbereiten waehrend dieses abspielt
|
||||
this._preloadNext();
|
||||
|
||||
sound.play((success) => {
|
||||
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
||||
sound.release();
|
||||
this.currentSound = null;
|
||||
RNFS.unlink(soundPath).catch(() => {});
|
||||
this._playNext();
|
||||
});
|
||||
}
|
||||
|
||||
/** Naechstes Audio im Hintergrund vorladen (verhindert Stottern) */
|
||||
private async _preloadNext(): Promise<void> {
|
||||
if (this.audioQueue.length === 0 || this.preloadedSound) return;
|
||||
|
||||
const base64Data = this.audioQueue[0]; // Nicht shift — bleibt in Queue
|
||||
try {
|
||||
const tmpPath = `${RNFS.CachesDirectoryPath}/aria_tts_pre_${Date.now()}.wav`;
|
||||
await RNFS.writeFile(tmpPath, base64Data, 'base64');
|
||||
this.preloadedSound = await new Promise<Sound>((resolve, reject) => {
|
||||
const s = new Sound(tmpPath, '', (err) => err ? reject(err) : resolve(s));
|
||||
});
|
||||
this.preloadedPath = tmpPath;
|
||||
} catch {
|
||||
this.preloadedSound = null;
|
||||
this.preloadedPath = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||
stopPlayback(): void {
|
||||
this.audioQueue = [];
|
||||
this.isPlaying = false;
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.release();
|
||||
this.currentSound = null;
|
||||
}
|
||||
if (this.preloadedSound) {
|
||||
this.preloadedSound.release();
|
||||
this.preloadedSound = null;
|
||||
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
|
||||
this.preloadedPath = '';
|
||||
}
|
||||
// PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch)
|
||||
if (this.pcmStreamActive) {
|
||||
PcmStreamPlayer?.stop().catch(() => {});
|
||||
this.pcmStreamActive = false;
|
||||
this.pcmBuffer = [];
|
||||
this.pcmBytesCollected = 0;
|
||||
this.pcmMessageId = '';
|
||||
}
|
||||
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.release().catch(() => {});
|
||||
}
|
||||
|
||||
// --- Status & Callbacks ---
|
||||
|
|
@ -277,6 +867,46 @@ class AudioService {
|
|||
this.stateListeners.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
|
||||
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen (>30s alt). */
|
||||
private async _cleanupStaleCacheFiles(): Promise<void> {
|
||||
try {
|
||||
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||||
const now = Date.now();
|
||||
for (const f of files) {
|
||||
if (!f.isFile()) continue;
|
||||
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
|
||||
const age = now - (f.mtime ? f.mtime.getTime() : 0);
|
||||
if (age > 30000) {
|
||||
await RNFS.unlink(f.path).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silent — cleanup ist best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** Alte TTS-Cache-Dateien loeschen die nicht mehr referenziert sind (>30 Tage). */
|
||||
async cleanupOldTTSCache(keepMessageIds: Set<string>, maxAgeDays = 30): Promise<void> {
|
||||
try {
|
||||
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
|
||||
if (!(await RNFS.exists(dir))) return;
|
||||
const files = await RNFS.readDir(dir);
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
for (const f of files) {
|
||||
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
|
||||
const messageId = f.name.replace(/\.wav$/, '');
|
||||
const age = now - (f.mtime ? f.mtime.getTime() : 0);
|
||||
// Loeschen wenn: nicht mehr referenziert UND aelter als X Tage
|
||||
if (!keepMessageIds.has(messageId) && age > maxAgeMs) {
|
||||
await RNFS.unlink(f.path).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt
|
||||
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
|
||||
*
|
||||
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen —
|
||||
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
|
||||
* (Audio kommt nicht zurueck), der User muesste die Antwort manuell
|
||||
* nochmal anfordern (Play-Button auf der Nachricht).
|
||||
*
|
||||
* Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden —
|
||||
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
|
||||
*/
|
||||
|
||||
import {
|
||||
NativeEventEmitter,
|
||||
NativeModules,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
ToastAndroid,
|
||||
} from 'react-native';
|
||||
import audioService from './audio';
|
||||
|
||||
interface PhoneCallNative {
|
||||
start(): Promise<boolean>;
|
||||
stop(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
|
||||
|
||||
type PhoneState = 'idle' | 'ringing' | 'offhook';
|
||||
|
||||
class PhoneCallService {
|
||||
private started: boolean = false;
|
||||
private subscription: { remove: () => void } | null = null;
|
||||
private lastState: PhoneState = 'idle';
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.started || !PhoneCall) return false;
|
||||
if (Platform.OS !== 'android') return false;
|
||||
|
||||
// Runtime-Permission holen (nur einmal noetig)
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
|
||||
{
|
||||
title: 'ARIA Cockpit — Anruf-Erkennung',
|
||||
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
|
||||
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
|
||||
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Spaeter',
|
||||
},
|
||||
);
|
||||
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
|
||||
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await PhoneCall.start();
|
||||
if (!ok) {
|
||||
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
|
||||
return false;
|
||||
}
|
||||
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
|
||||
this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => {
|
||||
this._onStateChanged(e.state);
|
||||
});
|
||||
this.started = true;
|
||||
console.log('[PhoneCall] Listener aktiv');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.warn('[PhoneCall] start gescheitert:', err?.message || err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started || !PhoneCall) return;
|
||||
try {
|
||||
this.subscription?.remove();
|
||||
this.subscription = null;
|
||||
await PhoneCall.stop();
|
||||
} catch {}
|
||||
this.started = false;
|
||||
this.lastState = 'idle';
|
||||
}
|
||||
|
||||
private _onStateChanged(state: PhoneState): void {
|
||||
if (state === this.lastState) return;
|
||||
console.log('[PhoneCall] State: %s → %s', this.lastState, state);
|
||||
this.lastState = state;
|
||||
if (state === 'ringing' || state === 'offhook') {
|
||||
audioService.haltAllPlayback(`Telefon-State: ${state}`);
|
||||
ToastAndroid.show(
|
||||
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
|
||||
ToastAndroid.SHORT,
|
||||
);
|
||||
}
|
||||
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern
|
||||
}
|
||||
}
|
||||
|
||||
const phoneCallService = new PhoneCallService();
|
||||
export default phoneCallService;
|
||||
|
|
@ -12,7 +12,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event';
|
||||
export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event' | 'update_available' | string;
|
||||
|
||||
export interface RVSMessage {
|
||||
type: MessageType;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* Auto-Update Service — prueft und installiert App-Updates via RVS
|
||||
*
|
||||
* Flow:
|
||||
* 1. App sendet "update_check" mit aktueller Version an RVS
|
||||
* 2. RVS vergleicht → sendet "update_available" mit Download-URL
|
||||
* 3. App zeigt Benachrichtigung → User bestaetigt → Download + Install
|
||||
*/
|
||||
|
||||
import { Alert, Linking, Platform, NativeModules } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import rvs, { RVSMessage } from './rvs';
|
||||
|
||||
// Version aus package.json (wird beim Build eingebettet)
|
||||
const packageJson = require('../../package.json');
|
||||
const APP_VERSION = packageJson.version || '0.0.0.0';
|
||||
|
||||
type UpdateCallback = (info: UpdateInfo) => void;
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
private listeners: UpdateCallback[] = [];
|
||||
private checking = false;
|
||||
private downloading = false;
|
||||
|
||||
constructor() {
|
||||
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
|
||||
// laeuft, sind frueher heruntergeladene APKs entweder schon installiert
|
||||
// oder unvollstaendig gewesen. Spart sonst pro Update 20-30MB auf dem Handy.
|
||||
this.cleanupOldApks().catch(() => {});
|
||||
|
||||
// Auf update_available Nachrichten lauschen
|
||||
rvs.onMessage((msg: RVSMessage) => {
|
||||
if (msg.type === 'update_available' as any) {
|
||||
const info: UpdateInfo = {
|
||||
version: (msg.payload.version as string) || '',
|
||||
downloadUrl: (msg.payload.downloadUrl as string) || '',
|
||||
size: (msg.payload.size as number) || 0,
|
||||
};
|
||||
if (info.version && this.isNewer(info.version)) {
|
||||
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
|
||||
this.listeners.forEach(cb => cb(info));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Raeumt alte heruntergeladene APK-Dateien aus dem Cache auf. */
|
||||
private async cleanupOldApks(): Promise<void> {
|
||||
try {
|
||||
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||||
const apks = files.filter(f => /\.apk$/i.test(f.name));
|
||||
let freed = 0;
|
||||
for (const f of apks) {
|
||||
try {
|
||||
const size = parseInt(f.size as any, 10) || 0;
|
||||
await RNFS.unlink(f.path);
|
||||
freed += size;
|
||||
console.log(`[Update] Alte APK geloescht: ${f.name} (${(size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.name} (${err?.message || err})`);
|
||||
}
|
||||
}
|
||||
if (apks.length > 0) {
|
||||
console.log(`[Update] Cleanup fertig: ${apks.length} APKs entfernt, ${(freed / 1024 / 1024).toFixed(1)}MB freigegeben`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Update] Cleanup-Fehler: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bei App-Start Update pruefen */
|
||||
checkForUpdate(): void {
|
||||
if (this.checking) return;
|
||||
this.checking = true;
|
||||
|
||||
console.log(`[Update] Pruefe auf Updates (aktuell: ${APP_VERSION})`);
|
||||
rvs.send('update_check' as any, { version: APP_VERSION });
|
||||
|
||||
setTimeout(() => { this.checking = false; }, 10000);
|
||||
}
|
||||
|
||||
/** Callback registrieren */
|
||||
onUpdateAvailable(callback: UpdateCallback): () => void {
|
||||
this.listeners.push(callback);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Update-Dialog anzeigen */
|
||||
promptUpdate(info: UpdateInfo): void {
|
||||
const sizeMB = (info.size / 1024 / 1024).toFixed(1);
|
||||
Alert.alert(
|
||||
'ARIA Update verfuegbar',
|
||||
`Version ${info.version} (${sizeMB} MB)\n\nAktuell: ${APP_VERSION}\n\nJetzt herunterladen und installieren?`,
|
||||
[
|
||||
{ text: 'Spaeter', style: 'cancel' },
|
||||
{
|
||||
text: 'Installieren',
|
||||
onPress: () => this.downloadAndInstall(info),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** APK ueber WebSocket herunterladen und installieren */
|
||||
async downloadAndInstall(info: UpdateInfo): Promise<void> {
|
||||
if (this.downloading) return;
|
||||
this.downloading = true;
|
||||
|
||||
try {
|
||||
console.log(`[Update] Fordere APK v${info.version} an...`);
|
||||
Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`);
|
||||
|
||||
// APK ueber WebSocket anfordern
|
||||
rvs.send('update_download' as any, {});
|
||||
|
||||
// Auf update_data warten (einmalig)
|
||||
const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000);
|
||||
const unsub = rvs.onMessage((msg: RVSMessage) => {
|
||||
if ((msg.type as string) === 'update_data') {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
if (msg.payload.error) {
|
||||
reject(new Error(msg.payload.error as string));
|
||||
} else {
|
||||
resolve({
|
||||
base64: msg.payload.base64 as string,
|
||||
fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
|
||||
// Updates in einer Session gezogen werden
|
||||
await this.cleanupOldApks();
|
||||
|
||||
// Base64 als APK-Datei speichern
|
||||
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
||||
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
||||
const fileSize = await RNFS.stat(destPath);
|
||||
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
||||
|
||||
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const { ApkInstaller } = NativeModules;
|
||||
await ApkInstaller.install(destPath);
|
||||
} catch (installErr: any) {
|
||||
Alert.alert(
|
||||
'APK heruntergeladen',
|
||||
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Update] Fehler: ${err.message}`);
|
||||
Alert.alert('Update fehlgeschlagen', err.message);
|
||||
} finally {
|
||||
this.downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Versionsvergleich */
|
||||
private isNewer(remote: string): boolean {
|
||||
const r = remote.split('.').map(Number);
|
||||
const l = APP_VERSION.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(r.length, l.length); i++) {
|
||||
const diff = (r[i] || 0) - (l[i] || 0);
|
||||
if (diff > 0) return true;
|
||||
if (diff < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getCurrentVersion(): string {
|
||||
return APP_VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
const updateService = new UpdateService();
|
||||
export default updateService;
|
||||
|
|
@ -1,96 +1,262 @@
|
|||
/**
|
||||
* Wake Word Service — "ARIA" Erkennung
|
||||
* Gespraechsmodus / Wake Word Service
|
||||
*
|
||||
* Nutzt react-native-live-audio-stream fuer kontinuierliches Mikrofon-Monitoring.
|
||||
* Erkennt Sprache per Energie-Schwellwert und sendet kurze Audio-Clips
|
||||
* zur serverseitigen Wake-Word-Pruefung (openwakeword in der Bridge).
|
||||
* Wake-Word-Engine: openWakeWord (https://github.com/dscripka/openWakeWord),
|
||||
* komplett on-device via ONNX Runtime in Native-Kotlin (siehe
|
||||
* OpenWakeWordModule.kt + assets/openwakeword/). Kein API-Key, kein Cloud-
|
||||
* Roundtrip, kein Cent Lizenzgebuehren.
|
||||
*
|
||||
* Architektur:
|
||||
* App (Mikrofon) → Energie-Erkennung → Audio-Buffer
|
||||
* → RVS "wake_check" → Bridge → openwakeword → Bestaetigung
|
||||
* → App startet Aufnahme
|
||||
* Drei Zustaende:
|
||||
* off — Ohr aus, nichts laeuft
|
||||
* armed — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word.
|
||||
* Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus.
|
||||
* conversing — Wake-Word getriggert (oder Ohr-Tap manuell):
|
||||
* aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei),
|
||||
* AudioRecorder uebernimmt fuer die Aufnahme.
|
||||
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
|
||||
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
|
||||
*
|
||||
* Aktuell (Phase 1): Einfacher Tap-to-Talk + Auto-Stop.
|
||||
* Spaeter (Phase 2): Porcupine on-device "ARIA" Keyword.
|
||||
* Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht
|
||||
* 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus).
|
||||
*/
|
||||
|
||||
import LiveAudioStream from 'react-native-live-audio-stream';
|
||||
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
type WakeWordCallback = () => void;
|
||||
type StateCallback = (state: WakeWordState) => void;
|
||||
|
||||
export type WakeWordState = 'off' | 'listening' | 'detected';
|
||||
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
||||
|
||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||
|
||||
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||
* werden — Diagnostic-Upload ist Phase 2. */
|
||||
export const WAKE_KEYWORDS = [
|
||||
'hey_jarvis',
|
||||
'computer',
|
||||
'alexa',
|
||||
'hey_mycroft',
|
||||
'hey_rhasspy',
|
||||
] as const;
|
||||
export type WakeKeyword = typeof WAKE_KEYWORDS[number];
|
||||
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';
|
||||
|
||||
/** Hilfs-Mapping fuer die Anzeige im UI. */
|
||||
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
||||
hey_jarvis: 'Hey Jarvis',
|
||||
computer: 'Computer',
|
||||
alexa: 'Alexa',
|
||||
hey_mycroft: 'Hey Mycroft',
|
||||
hey_rhasspy: 'Hey Rhasspy',
|
||||
};
|
||||
|
||||
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
|
||||
const DEFAULT_THRESHOLD = 0.5;
|
||||
const DEFAULT_PATIENCE = 2;
|
||||
const DEFAULT_DEBOUNCE_MS = 1500;
|
||||
|
||||
interface OpenWakeWordModule {
|
||||
init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise<boolean>;
|
||||
start(): Promise<boolean>;
|
||||
stop(): Promise<boolean>;
|
||||
dispose(): Promise<boolean>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
|
||||
|
||||
class WakeWordService {
|
||||
private state: WakeWordState = 'off';
|
||||
private wakeCallbacks: WakeWordCallback[] = [];
|
||||
private stateCallbacks: StateCallback[] = [];
|
||||
private isInitialized = false;
|
||||
|
||||
/** Wake Word Erkennung starten */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.state === 'listening') return true;
|
||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||
private nativeReady: boolean = false;
|
||||
private initInProgress: Promise<boolean> | null = null;
|
||||
private eventSub: { remove: () => void } | null = null;
|
||||
|
||||
/** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */
|
||||
async loadFromStorage(): Promise<void> {
|
||||
try {
|
||||
if (!this.isInitialized) {
|
||||
LiveAudioStream.init({
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
bitsPerSample: 16,
|
||||
audioSource: 6, // VOICE_RECOGNITION
|
||||
bufferSize: 4096,
|
||||
});
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
// Audio-Stream starten und auf Energie pruefen
|
||||
LiveAudioStream.start();
|
||||
|
||||
LiveAudioStream.on('data', (base64Chunk: string) => {
|
||||
if (this.state !== 'listening') return;
|
||||
|
||||
// Base64 → Int16 Array → RMS berechnen
|
||||
const raw = this._base64ToInt16(base64Chunk);
|
||||
const rms = this._calculateRMS(raw);
|
||||
|
||||
// Schwellwert: wenn laut genug → Wake Word erkannt
|
||||
// Phase 1: Einfache Energie-Erkennung (jemand spricht)
|
||||
// Phase 2: Porcupine "ARIA" Keyword
|
||||
if (rms > 2000) {
|
||||
this.setState('detected');
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
// Nach Detection kurz pausieren, Aufnahme uebernimmt das Mikrofon
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
|
||||
this.setState('listening');
|
||||
console.log('[WakeWord] Listening gestartet');
|
||||
return true;
|
||||
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
|
||||
const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword;
|
||||
this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD;
|
||||
await this.initNative();
|
||||
} catch (err) {
|
||||
console.error('[WakeWord] Start fehlgeschlagen:', err);
|
||||
return false;
|
||||
console.warn('[WakeWord] loadFromStorage', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wake Word Erkennung stoppen */
|
||||
stop(): void {
|
||||
if (this.state === 'off') return;
|
||||
try {
|
||||
LiveAudioStream.stop();
|
||||
} catch {}
|
||||
this.setState('off');
|
||||
console.log('[WakeWord] Gestoppt');
|
||||
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
|
||||
async configure(keyword: string): Promise<boolean> {
|
||||
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
|
||||
? (keyword as WakeKeyword)
|
||||
: DEFAULT_KEYWORD;
|
||||
this.keyword = next;
|
||||
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);
|
||||
|
||||
// Laufende Instanz stoppen + neu initialisieren
|
||||
await this.disposeNative();
|
||||
const ok = await this.initNative();
|
||||
if (!ok) {
|
||||
ToastAndroid.show(
|
||||
`Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`,
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Nach Aufnahme erneut starten */
|
||||
async resume(): Promise<void> {
|
||||
// Kurze Pause damit Aufnahme das Mikrofon freigeben kann
|
||||
private async initNative(): Promise<boolean> {
|
||||
if (!OpenWakeWord) {
|
||||
console.warn('[WakeWord] OpenWakeWord Native-Modul nicht verfuegbar — Direkt-Aufnahme-Fallback aktiv');
|
||||
this.nativeReady = false;
|
||||
return false;
|
||||
}
|
||||
if (this.initInProgress) return this.initInProgress;
|
||||
this.initInProgress = (async () => {
|
||||
try {
|
||||
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
// Subscribe nur einmal
|
||||
if (!this.eventSub) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
||||
this.eventSub = emitter.addListener('WakeWordDetected', () => {
|
||||
console.log('[WakeWord] Native Detection-Event empfangen');
|
||||
this.onWakeDetected().catch(err =>
|
||||
console.warn('[WakeWord] onWakeDetected crashed:', err));
|
||||
});
|
||||
}
|
||||
this.nativeReady = true;
|
||||
console.log('[WakeWord] Init OK (model=%s)', this.keyword);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err);
|
||||
this.nativeReady = false;
|
||||
return false;
|
||||
} finally {
|
||||
this.initInProgress = null;
|
||||
}
|
||||
})();
|
||||
return this.initInProgress;
|
||||
}
|
||||
|
||||
private async disposeNative(): Promise<void> {
|
||||
if (!OpenWakeWord) return;
|
||||
try { await OpenWakeWord.dispose(); } catch {}
|
||||
this.nativeReady = false;
|
||||
}
|
||||
|
||||
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.state !== 'off') return true;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
|
||||
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||
this.setState('armed');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:',
|
||||
err?.message || err);
|
||||
ToastAndroid.show(
|
||||
`Wake-Word-Start failed: ${err?.message || err}`,
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback');
|
||||
ToastAndroid.show(
|
||||
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
// Fallback: direkt in Konversation
|
||||
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
|
||||
this.setState('conversing');
|
||||
setTimeout(() => {
|
||||
if (this.state === 'off') {
|
||||
this.start();
|
||||
if (this.state === 'conversing') {
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}, 500);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Komplett ausschalten (Ohr abschalten) */
|
||||
async stop(): Promise<void> {
|
||||
console.log('[WakeWord] Ohr deaktiviert');
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
|
||||
ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT);
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
this.setState('conversing');
|
||||
setTimeout(() => {
|
||||
if (this.state === 'conversing') {
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** Konversation beenden — User hat im Window nichts gesagt.
|
||||
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
|
||||
* Ohne: zurueck zu 'off'.
|
||||
*/
|
||||
async endConversation(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
|
||||
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||
this.setState('armed');
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
|
||||
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
if (this.state === 'conversing') {
|
||||
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}
|
||||
|
||||
/** True solange das Ohr aktiv ist (armed ODER conversing). */
|
||||
isActive(): boolean {
|
||||
return this.state !== 'off';
|
||||
}
|
||||
|
||||
isConversing(): boolean {
|
||||
return this.state === 'conversing';
|
||||
}
|
||||
|
||||
hasWakeWord(): boolean {
|
||||
return this.nativeReady;
|
||||
}
|
||||
|
||||
getKeyword(): WakeKeyword {
|
||||
return this.keyword;
|
||||
}
|
||||
|
||||
// --- Callbacks ---
|
||||
|
|
@ -113,32 +279,12 @@ class WakeWordService {
|
|||
return this.state;
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen ---
|
||||
|
||||
private setState(state: WakeWordState): void {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.stateCallbacks.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
|
||||
private _base64ToInt16(base64: string): Int16Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new Int16Array(bytes.buffer);
|
||||
}
|
||||
|
||||
private _calculateRMS(samples: Int16Array): number {
|
||||
if (samples.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
sum += samples[i] * samples[i];
|
||||
}
|
||||
return Math.sqrt(sum / samples.length);
|
||||
}
|
||||
}
|
||||
|
||||
const wakeWordService = new WakeWordService();
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
# → localhost ist aria-core
|
||||
ARIA_CORE_WS=ws://127.0.0.1:18789
|
||||
|
||||
# Piper TTS Stimmen
|
||||
PIPER_RAMONA=/voices/de_DE-ramona-low.onnx
|
||||
PIPER_THORSTEN=/voices/de_DE-thorsten-high.onnx
|
||||
|
||||
# Wake-Word
|
||||
WAKE_WORD=aria
|
||||
|
||||
# Whisper STT — wird zur Laufzeit in der Diagnostic (Sektion "Whisper") umgeschaltet
|
||||
# und in /shared/config/voice_config.json gespeichert. Der Wert hier ist nur der
|
||||
# Initial-Default beim ersten Start.
|
||||
# Optionen: tiny | base | small | medium | large-v3
|
||||
WHISPER_MODEL=medium
|
||||
WHISPER_LANGUAGE=de
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# ════════════════════════════════════════════════
|
||||
# ARIA Voice Bridge — Dockerfile
|
||||
# Whisper STT + Piper TTS + Wake-Word
|
||||
# Whisper STT + Wake-Word (TTS via XTTS v2 remote)
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -91,6 +91,39 @@ _ACTIVATION_MAP: dict[str, Mode] = {
|
|||
mode.config.activation_phrase.lower(): mode for mode in Mode
|
||||
}
|
||||
|
||||
# ID-Mapping fuer API-Mode-Wechsel (z.B. App ModeSelector schickt 'normal')
|
||||
_ID_MAP: dict[str, Mode] = {
|
||||
"normal": Mode.NORMAL,
|
||||
"nicht_stoeren": Mode.DND,
|
||||
"dnd": Mode.DND,
|
||||
"fluester": Mode.WHISPER,
|
||||
"whisper": Mode.WHISPER,
|
||||
"hangar": Mode.HANGAR,
|
||||
"gaming": Mode.GAMING,
|
||||
}
|
||||
|
||||
|
||||
def mode_from_id(mode_id: str) -> Optional[Mode]:
|
||||
"""ID-basiertes Mapping fuer API-Mode-Wechsel (ohne Aktivierungsphrase)."""
|
||||
if not mode_id:
|
||||
return None
|
||||
return _ID_MAP.get(mode_id.strip().lower())
|
||||
|
||||
|
||||
# Kanonische IDs fuer Broadcasts (matchen die App-UI-IDs in ModeSelector)
|
||||
_CANONICAL_ID: dict[Mode, str] = {
|
||||
Mode.NORMAL: "normal",
|
||||
Mode.DND: "nicht_stoeren",
|
||||
Mode.WHISPER: "fluester",
|
||||
Mode.HANGAR: "hangar",
|
||||
Mode.GAMING: "gaming",
|
||||
}
|
||||
|
||||
|
||||
def canonical_id(mode: Mode) -> str:
|
||||
"""Kanonische ID die App + Diagnostic + Bridge gleichermassen kennen."""
|
||||
return _CANONICAL_ID.get(mode, mode.name.lower())
|
||||
|
||||
|
||||
def detect_mode_switch(text: str) -> Optional[Mode]:
|
||||
"""Erkennt ob ein Text eine Modus-Umschaltung enthaelt.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
# STT — Whisper (lokal, keine API noetig)
|
||||
faster-whisper
|
||||
|
||||
# TTS — Piper (offline, deutsche Stimmen)
|
||||
piper-tts
|
||||
# TTS: laeuft remote ueber XTTS v2 auf dem Gaming-PC (keine lokalen Deps noetig)
|
||||
|
||||
# WebSocket-Verbindung zu aria-core
|
||||
websockets
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/bin/bash
|
||||
# ARIA Docker Cleanup
|
||||
#
|
||||
# Standard: docker builder prune + image prune (sicher, loescht keine Volumes)
|
||||
# --full: Volle Reinigung inkl. --volumes (Vorsicht bei ungenutzten Volumes!)
|
||||
#
|
||||
# Usage:
|
||||
# ./cleanup.sh # sicherer Cleanup
|
||||
# ./cleanup.sh --full # aggressiver Cleanup (inkl. Volumes)
|
||||
|
||||
set -e
|
||||
|
||||
FULL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--full|-f) FULL=1 ;;
|
||||
-h|--help)
|
||||
grep '^#' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "── Docker Speicher VOR Cleanup ───────────────────"
|
||||
docker system df
|
||||
echo
|
||||
|
||||
if [ "$FULL" = "1" ]; then
|
||||
echo ">>> VOLLE Reinigung (inkl. ungenutzter Volumes)"
|
||||
read -p "Wirklich? [y/N] " -n 1 -r REPLY
|
||||
echo
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Abgebrochen."; exit 0; }
|
||||
docker system prune -a --volumes -f
|
||||
else
|
||||
echo ">>> Sicherer Cleanup (Build-Cache + ungenutzte Images)"
|
||||
docker builder prune -a -f
|
||||
docker image prune -a -f
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── Docker Speicher NACH Cleanup ──────────────────"
|
||||
docker system df
|
||||
echo
|
||||
df -h / | head -2
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,15 +37,76 @@ const state = {
|
|||
};
|
||||
const SESSION_KEY_FILE = "/data/active-session";
|
||||
// /data Verzeichnis sicherstellen (Volume Mount)
|
||||
try { fs.mkdirSync("/data", { recursive: true }); } catch {}
|
||||
try { fs.mkdirSync("/data", { recursive: true }); } catch (e) {
|
||||
console.error(`[startup] /data mkdir fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
// sessionFromFile zeigt an, ob der aktive Key aus der Datei kam.
|
||||
// Wenn true, darf resolveActiveSession NICHT mehr auto-picken (Wahl respektieren).
|
||||
let sessionFromFile = false;
|
||||
let activeSessionKey = (() => {
|
||||
try {
|
||||
const saved = fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim();
|
||||
if (saved) { console.log(`[startup] Gespeicherte Session geladen: '${saved}'`); return saved; }
|
||||
} catch {}
|
||||
if (saved) {
|
||||
console.log(`[startup] Gespeicherte Session geladen: '${saved}'`);
|
||||
sessionFromFile = true;
|
||||
return saved;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[startup] SESSION_KEY_FILE read: ${e.code || e.message}`);
|
||||
}
|
||||
console.log("[startup] Keine gespeicherte Session — Fallback 'main'");
|
||||
return "main";
|
||||
})();
|
||||
|
||||
// ── Runtime-Config: /shared/config/runtime.json ─────────────
|
||||
// ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang.
|
||||
// Bridge und ggf. andere Komponenten lesen dieselbe Datei.
|
||||
const RUNTIME_CONFIG_FILE = "/shared/config/runtime.json";
|
||||
const RUNTIME_CONFIG_FIELDS = [
|
||||
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
|
||||
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
|
||||
];
|
||||
function readRuntimeConfig() {
|
||||
const envDefaults = {
|
||||
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TOKEN,
|
||||
ARIA_AUTH_TOKEN: process.env.ARIA_AUTH_TOKEN || "",
|
||||
WHISPER_MODEL: process.env.WHISPER_MODEL || "medium",
|
||||
WHISPER_LANGUAGE: process.env.WHISPER_LANGUAGE || "de",
|
||||
};
|
||||
try {
|
||||
const raw = fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...envDefaults, ...parsed };
|
||||
} catch {
|
||||
return envDefaults;
|
||||
}
|
||||
}
|
||||
function writeRuntimeConfig(patch) {
|
||||
let current = {};
|
||||
try { current = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8")); } catch {}
|
||||
for (const key of Object.keys(patch)) {
|
||||
if (RUNTIME_CONFIG_FIELDS.includes(key)) current[key] = patch[key];
|
||||
}
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
const tmp = RUNTIME_CONFIG_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(current, null, 2));
|
||||
fs.renameSync(tmp, RUNTIME_CONFIG_FILE);
|
||||
}
|
||||
|
||||
// Atomic write: temp-file + rename, laute Logs bei Fehler.
|
||||
function persistActiveSession(key) {
|
||||
try {
|
||||
const tmp = SESSION_KEY_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, key);
|
||||
fs.renameSync(tmp, SESSION_KEY_FILE);
|
||||
sessionFromFile = true;
|
||||
console.log(`[session] Aktive Session persistiert: '${key}'`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[session] FEHLER beim Persistieren von '${key}': ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const logs = [];
|
||||
let gatewayWs = null;
|
||||
let rvsWs = null;
|
||||
|
|
@ -56,6 +117,12 @@ const browserClients = new Set();
|
|||
let pipelineActive = false;
|
||||
let pipelineStartTime = 0;
|
||||
|
||||
// Nach chat:final kommen oft noch Trailing Agent-Events. Waehrend dieses
|
||||
// Fensters unterdruecken wir agent_activity-Broadcasts, damit der
|
||||
// Thinking-Indicator nicht wieder anspringt.
|
||||
let lastChatFinalAt = 0;
|
||||
const SETTLED_WINDOW_MS = 3000;
|
||||
|
||||
function plog(message, level) {
|
||||
const elapsed = pipelineActive ? `+${Date.now() - pipelineStartTime}ms` : "";
|
||||
const entry = { ts: new Date().toISOString(), level: level || "info", source: "pipeline", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` };
|
||||
|
|
@ -91,6 +158,9 @@ function pipelineEnd(ok, detail) {
|
|||
}
|
||||
plog(`━━━ Pipeline Ende ━━━`);
|
||||
pipelineActive = false;
|
||||
// Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
pendingMessageTime = 0;
|
||||
}
|
||||
|
||||
// ── Auto-Restart bei Netzwerk-Namespace-Verlust ──────
|
||||
|
|
@ -257,8 +327,10 @@ async function connectGateway() {
|
|||
state.gateway.handshakeOk = false;
|
||||
gatewayWs = null;
|
||||
broadcastState();
|
||||
// Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Pipeline abkackt
|
||||
if (pipelineActive) pipelineEnd(false, `Gateway-Verbindung verloren (${code})`);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
checkGatewayHealth();
|
||||
// Auto-Reconnect nach 5s
|
||||
setTimeout(connectGateway, 5000);
|
||||
});
|
||||
|
||||
|
|
@ -325,17 +397,23 @@ function handleGatewayMessage(msg) {
|
|||
broadcast({ type: "chat_delta", delta, payload });
|
||||
}
|
||||
|
||||
// Nach chat:final trickeln noch Aufraeum-Events rein — unterdruecken,
|
||||
// damit der Thinking-Indicator nicht wieder anspringt.
|
||||
const settled = lastChatFinalAt && (Date.now() - lastChatFinalAt) < SETTLED_WINDOW_MS;
|
||||
|
||||
// Tool-Nutzung erkennen und broadcasten
|
||||
if (stream === "tool_use" || data.type === "tool_use") {
|
||||
const toolName = data.name || data.tool || payload.tool || "";
|
||||
if (toolName) {
|
||||
if (toolName && !settled) {
|
||||
broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data });
|
||||
log("info", "gateway", `Tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Genereller Activity-Heartbeat (ARIA denkt)
|
||||
broadcast({ type: "agent_activity", activity: stream || "thinking" });
|
||||
if (!settled) {
|
||||
broadcast({ type: "agent_activity", activity: stream || "thinking" });
|
||||
}
|
||||
updateAgentActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -348,10 +426,31 @@ function handleGatewayMessage(msg) {
|
|||
const runId = payload.runId || "";
|
||||
if (runId && seenFinalRuns.has(runId)) return; // Duplikat
|
||||
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
||||
|
||||
// NO_REPLY → ARIA signalisiert "nicht antworten", Pipeline beenden aber nichts zeigen
|
||||
const trimmed = (text || "").trim().replace(/^["'`*.\s]+|["'`*.\s]+$/g, "").toUpperCase();
|
||||
if (trimmed === "NO_REPLY" || trimmed.startsWith("NO_REPLY")) {
|
||||
log("info", "gateway", "NO_REPLY empfangen — still verworfen");
|
||||
lastChatFinalAt = Date.now();
|
||||
if (pipelineActive) pipelineEnd(true, "NO_REPLY (stumm)");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
pendingMessageTime = 0;
|
||||
updateAgentActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||
lastChatFinalAt = Date.now();
|
||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||
broadcast({ type: "chat_final", text, payload });
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
pendingMessageTime = 0; // Watchdog: Antwort erhalten
|
||||
updateAgentActivity();
|
||||
// Antwort in Backup-Log schreiben
|
||||
try {
|
||||
const entry = JSON.stringify({ ts: Date.now(), role: "assistant", text: text.slice(0, 2000), session: activeSessionKey }) + "\n";
|
||||
fs.appendFileSync("/shared/config/chat_backup.jsonl", entry);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -364,6 +463,7 @@ function handleGatewayMessage(msg) {
|
|||
const error = payload.error || text || "Unbekannt";
|
||||
log("error", "gateway", `Chat-Fehler: ${error}`);
|
||||
if (pipelineActive) pipelineEnd(false, error);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
broadcast({ type: "chat_error", error, payload });
|
||||
return;
|
||||
}
|
||||
|
|
@ -384,7 +484,9 @@ function handleGatewayMessage(msg) {
|
|||
if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
|
||||
const text = extractChatText(payload) || payload.text || "";
|
||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||
lastChatFinalAt = Date.now();
|
||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
broadcast({ type: "chat_final", text, payload });
|
||||
return;
|
||||
}
|
||||
|
|
@ -392,6 +494,7 @@ function handleGatewayMessage(msg) {
|
|||
const error = payload.error || payload.message || "Unbekannt";
|
||||
log("error", "gateway", `Chat-Fehler: ${error}`);
|
||||
if (pipelineActive) pipelineEnd(false, error);
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
broadcast({ type: "chat_error", error, payload });
|
||||
return;
|
||||
}
|
||||
|
|
@ -424,6 +527,13 @@ function sendToGateway(text, isPipeline) {
|
|||
const payload = JSON.stringify(msg);
|
||||
log("debug", "gateway", `RAW >>> ${payload}`);
|
||||
gatewayWs.send(payload);
|
||||
pendingMessageTime = Date.now(); // Watchdog: Nachricht gesendet
|
||||
// Nachricht sofort in Backup-Log schreiben (OpenClaw speichert erst nach Run-Ende)
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
const entry = JSON.stringify({ ts: Date.now(), role: "user", text, session: activeSessionKey }) + "\n";
|
||||
fs.appendFileSync("/shared/config/chat_backup.jsonl", entry);
|
||||
} catch {}
|
||||
log("info", "gateway", `chat.send [${reqId}]: "${text}"`);
|
||||
if (isPipeline) plog(`chat.send [${reqId}] an Gateway gesendet — warte auf ACK...`);
|
||||
|
||||
|
|
@ -499,8 +609,53 @@ function connectRVS(forcePlain) {
|
|||
pipelineEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`);
|
||||
}
|
||||
broadcast({ type: "rvs_chat", msg });
|
||||
} else if (msg.type === "file_saved" && msg.payload) {
|
||||
// Bild/Datei-Upload von der App — im Chat anzeigen
|
||||
const name = msg.payload.name || "?";
|
||||
const serverPath = msg.payload.serverPath || "";
|
||||
const mimeType = msg.payload.mimeType || "";
|
||||
log("info", "rvs", `Datei empfangen: ${name} (${serverPath})`);
|
||||
// Als User-Nachricht mit Pfad broadcasten (Diagnostic zeigt Bilder inline)
|
||||
broadcast({ type: "rvs_chat", msg: {
|
||||
type: "chat",
|
||||
payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" }
|
||||
}});
|
||||
} else if (msg.type === "heartbeat") {
|
||||
// ignorieren
|
||||
} else if (msg.type === "mode") {
|
||||
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
||||
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
||||
broadcast({ type: "mode", payload: msg.payload });
|
||||
} else if (msg.type === "voice_ready") {
|
||||
// XTTS-Bridge meldet Stimme fertig geladen → an Browser durchreichen
|
||||
const v = msg.payload?.voice || "";
|
||||
const err = msg.payload?.error;
|
||||
const ms = msg.payload?.loadMs;
|
||||
if (err) {
|
||||
log("warn", "rvs", `Voice-Ready Fehler fuer "${v}": ${err}`);
|
||||
} else {
|
||||
log("info", "rvs", `Voice "${v || "default"}" geladen${ms ? ` in ${(ms/1000).toFixed(1)}s` : ""}`);
|
||||
}
|
||||
broadcast({ type: "voice_ready", payload: msg.payload });
|
||||
} else if (msg.type === "service_status") {
|
||||
// Gamebox-Bridges (f5tts/whisper) melden ihren Lade-Status —
|
||||
// an Browser durchreichen fuer das Banner unten rechts
|
||||
const svc = msg.payload?.service || "?";
|
||||
const state = msg.payload?.state || "?";
|
||||
const model = msg.payload?.model || "";
|
||||
const sec = msg.payload?.loadSeconds;
|
||||
const err = msg.payload?.error;
|
||||
if (err) {
|
||||
log("warn", "rvs", `service_status ${svc}: ${err}`);
|
||||
} else if (state === "ready" && sec) {
|
||||
log("info", "rvs", `service_status ${svc} ready (${model}, ${sec.toFixed(1)}s)`);
|
||||
} else {
|
||||
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
|
||||
}
|
||||
broadcast({ type: "service_status", payload: msg.payload });
|
||||
} else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) {
|
||||
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
|
||||
_handlePreviewChunk(msg.payload);
|
||||
} else {
|
||||
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
|
||||
}
|
||||
|
|
@ -534,55 +689,60 @@ function connectRVS(forcePlain) {
|
|||
});
|
||||
}
|
||||
|
||||
function sendToRVS(text, isPipeline) {
|
||||
if (!RVS_HOST || !RVS_TOKEN) {
|
||||
log("error", "rvs", "Nicht konfiguriert");
|
||||
if (isPipeline) pipelineEnd(false, "RVS nicht konfiguriert");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Frische WebSocket-Verbindung fuer jede Nachricht (Zombie-Schutz)
|
||||
function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
|
||||
if (!RVS_HOST || !RVS_TOKEN) return;
|
||||
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
||||
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
|
||||
const msg = JSON.stringify({
|
||||
const freshWs = new WebSocket(url);
|
||||
const timeout = setTimeout(() => {
|
||||
try { freshWs.close(); } catch (_) {}
|
||||
clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() }));
|
||||
}, 15000);
|
||||
freshWs.on("open", () => {
|
||||
freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() }));
|
||||
});
|
||||
freshWs.on("message", (raw) => {
|
||||
try {
|
||||
const resp = JSON.parse(raw.toString());
|
||||
if (resp.type === expectType) {
|
||||
clearTimeout(timeout);
|
||||
clientWs.send(JSON.stringify(resp));
|
||||
setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 1000);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
freshWs.on("error", () => {});
|
||||
}
|
||||
|
||||
function sendToRVS_raw(msgObj) {
|
||||
if (!RVS_HOST || !RVS_TOKEN) return;
|
||||
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
||||
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
|
||||
const freshWs = new WebSocket(url);
|
||||
freshWs.on("open", () => {
|
||||
freshWs.send(JSON.stringify(msgObj));
|
||||
setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000);
|
||||
});
|
||||
freshWs.on("error", () => {});
|
||||
}
|
||||
|
||||
function sendToRVS(text, isPipeline) {
|
||||
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
|
||||
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
|
||||
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
|
||||
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
|
||||
|
||||
// 1. An Gateway senden (damit ARIA antwortet)
|
||||
const gatewayOk = sendToGateway(text, isPipeline);
|
||||
|
||||
// 2. An RVS senden (damit die App die Nachricht sieht)
|
||||
sendToRVS_raw({
|
||||
type: "chat",
|
||||
payload: { text, sender: "diagnostic" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
log("info", "rvs", `Sende via frische Verbindung: ${url.split('?')[0]}`);
|
||||
|
||||
const freshWs = new WebSocket(url);
|
||||
freshWs.on("open", () => {
|
||||
freshWs.send(msg);
|
||||
log("info", "rvs", `Gesendet via RVS: "${text}"`);
|
||||
// Verbindung offen lassen fuer Antwort-Empfang, nach 5min schliessen
|
||||
setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 300000);
|
||||
});
|
||||
freshWs.on("message", (raw) => {
|
||||
try {
|
||||
const resp = JSON.parse(raw.toString());
|
||||
if (resp.type === "chat" && resp.payload) {
|
||||
const sender = resp.payload.sender || "?";
|
||||
// Eigene Nachrichten nicht nochmal anzeigen (Echo von RVS)
|
||||
if (sender === "diagnostic") return;
|
||||
log("info", "rvs", `Chat von ${sender}: "${(resp.payload.text || "").slice(0, 100)}"`);
|
||||
if (pipelineActive && sender !== "diagnostic") {
|
||||
pipelineEnd(true, `Antwort via RVS von ${sender}: "${(resp.payload.text || "").slice(0, 120)}"`);
|
||||
}
|
||||
broadcast({ type: "rvs_chat", msg: resp });
|
||||
} else if (resp.type !== "heartbeat") {
|
||||
log("debug", "rvs", `Nachricht: ${JSON.stringify(resp).slice(0, 150)}`);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
freshWs.on("error", (err) => {
|
||||
log("error", "rvs", `Sende-Fehler: ${err.message}`);
|
||||
if (isPipeline) pipelineEnd(false, `RVS Fehler: ${err.message}`);
|
||||
});
|
||||
|
||||
if (isPipeline) plog(`Nachricht an RVS gesendet — warte auf Antwort via RVS...`);
|
||||
return true;
|
||||
return gatewayOk;
|
||||
}
|
||||
|
||||
// ── Claude Proxy Test ────────────────────────────────────
|
||||
|
|
@ -1006,6 +1166,111 @@ function waitForMessage(ws, timeoutMs) {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Watchdog: Stuck Run Erkennung ────────────────────────
|
||||
|
||||
let lastAgentActivity = Date.now();
|
||||
let watchdogWarned = false;
|
||||
let watchdogFixAttempted = false;
|
||||
let pendingMessageTime = 0; // Wann wurde die letzte Nachricht gesendet
|
||||
|
||||
function updateAgentActivity() {
|
||||
lastAgentActivity = Date.now();
|
||||
watchdogWarned = false;
|
||||
}
|
||||
|
||||
// ── Disk-Space Monitor ───────────────────────────────
|
||||
// Prueft regelmaessig die Host-Disk (via gemountetem /shared) und
|
||||
// broadcastet bei kritischen Schwellwerten ein disk_status Event.
|
||||
let lastDiskStatus = null;
|
||||
let currentDiskStatus = null; // Vollstaendig fuer neu verbundene Clients
|
||||
function checkDiskSpace() {
|
||||
const { exec } = require("child_process");
|
||||
exec("df -B1 /shared", (err, stdout) => {
|
||||
if (err) return;
|
||||
const lines = stdout.trim().split("\n");
|
||||
if (lines.length < 2) return;
|
||||
const cols = lines[1].split(/\s+/);
|
||||
// Filesystem Size Used Avail Use% MountedOn
|
||||
const total = parseInt(cols[1], 10);
|
||||
const used = parseInt(cols[2], 10);
|
||||
const avail = parseInt(cols[3], 10);
|
||||
if (!total) return;
|
||||
const pct = Math.round((used / total) * 100);
|
||||
let level = "ok";
|
||||
if (pct >= 95) level = "critical";
|
||||
else if (pct >= 85) level = "warn";
|
||||
else if (pct >= 70) level = "info";
|
||||
const status = {
|
||||
type: "disk_status",
|
||||
level,
|
||||
percent: pct,
|
||||
usedBytes: used,
|
||||
totalBytes: total,
|
||||
availBytes: avail,
|
||||
};
|
||||
currentDiskStatus = status;
|
||||
// Nur broadcasten wenn sich was geaendert hat (oder alle 60s Refresh)
|
||||
const key = `${level}-${pct}`;
|
||||
if (lastDiskStatus !== key) {
|
||||
lastDiskStatus = key;
|
||||
broadcast(status);
|
||||
if (level !== "ok") {
|
||||
log(level === "critical" ? "error" : "warn", "server",
|
||||
`Disk ${pct}% belegt (${(used/1024/1024/1024).toFixed(1)}GB von ${(total/1024/1024/1024).toFixed(1)}GB)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Beim Start + alle 30s
|
||||
setTimeout(checkDiskSpace, 2000);
|
||||
setInterval(checkDiskSpace, 30000);
|
||||
|
||||
// Watchdog prüft alle 30s ob ARIA nach einer gesendeten Nachricht reagiert
|
||||
setInterval(async () => {
|
||||
if (pendingMessageTime === 0) return; // Keine Nachricht gesendet
|
||||
const waitingMs = Date.now() - pendingMessageTime;
|
||||
|
||||
// Nach 2min ohne Agent-Activity: Warnung
|
||||
if (waitingMs > 120000 && !watchdogWarned) {
|
||||
watchdogWarned = true;
|
||||
log("warn", "server", `Watchdog: Keine ARIA-Aktivitaet seit ${Math.round(waitingMs / 1000)}s — moeglicherweise stuck`);
|
||||
broadcast({ type: "watchdog", status: "warning", waitingMs, message: "ARIA reagiert nicht — moeglicherweise stuck Run" });
|
||||
}
|
||||
|
||||
// Nach 5min: doctor --fix
|
||||
if (waitingMs > 300000 && watchdogWarned && !watchdogFixAttempted) {
|
||||
watchdogFixAttempted = true;
|
||||
log("error", "server", "Watchdog: 5min ohne Antwort — fuehre openclaw doctor --fix aus");
|
||||
broadcast({ type: "watchdog", status: "fixing", message: "Auto-Fix: openclaw doctor --fix" });
|
||||
try {
|
||||
await dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true");
|
||||
log("info", "server", "Watchdog: doctor --fix ausgefuehrt");
|
||||
broadcast({ type: "watchdog", status: "fixed", message: "doctor --fix ausgefuehrt — warte auf Antwort..." });
|
||||
} catch (err) {
|
||||
log("error", "server", `Watchdog: doctor --fix fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Nach 8min: Container neustarten
|
||||
if (waitingMs > 480000 && watchdogFixAttempted) {
|
||||
log("error", "server", "Watchdog: 8min ohne Antwort — starte aria-core + aria-proxy neu");
|
||||
broadcast({ type: "watchdog", status: "restarting", message: "Container-Restart: aria-core + aria-proxy" });
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
execSync("docker restart aria-core aria-proxy", { timeout: 60000 });
|
||||
log("info", "server", "Watchdog: Container neugestartet");
|
||||
broadcast({ type: "watchdog", status: "restarted", message: "Container neugestartet — warte auf Gateway-Reconnect..." });
|
||||
// Gateway wird sich automatisch neu verbinden
|
||||
} catch (err) {
|
||||
log("error", "server", `Watchdog: Container-Restart fehlgeschlagen: ${err.message}`);
|
||||
broadcast({ type: "watchdog", status: "error", message: `Restart fehlgeschlagen: ${err.message}` });
|
||||
}
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// ── HTTP Server + WebSocket fuer Browser ────────────────
|
||||
|
||||
const htmlPath = path.join(__dirname, "index.html");
|
||||
|
|
@ -1020,6 +1285,45 @@ const server = http.createServer((req, res) => {
|
|||
} else if (req.url === "/api/session") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ sessionKey: activeSessionKey }));
|
||||
} else if (req.url === "/api/runtime-config" && req.method === "GET") {
|
||||
// Zentrale Runtime-Config (ENV + Override aus /shared/config/runtime.json)
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readRuntimeConfig()));
|
||||
} else if (req.url === "/api/runtime-config" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", chunk => { body += chunk; if (body.length > 32768) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const patch = JSON.parse(body);
|
||||
writeRuntimeConfig(patch);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, config: readRuntimeConfig() }));
|
||||
log("info", "server", `Runtime-Config aktualisiert: ${Object.keys(patch).join(", ")}`);
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/onboarding") {
|
||||
// RVS-Credentials fuer QR-Code App-Onboarding
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
rvsHost: RVS_HOST,
|
||||
rvsPort: RVS_PORT,
|
||||
rvsTLS: RVS_TLS === "true" || RVS_TLS === true,
|
||||
rvsToken: RVS_TOKEN,
|
||||
}));
|
||||
} else if (req.url === "/api/cancel" && req.method === "POST") {
|
||||
log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)");
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen (App)");
|
||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} else if (req.url.startsWith("/shared/")) {
|
||||
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
|
||||
const filePath = decodeURIComponent(req.url);
|
||||
|
|
@ -1054,6 +1358,8 @@ wss.on("connection", (ws) => {
|
|||
browserClients.add(ws);
|
||||
// Initialen State + letzte Logs senden
|
||||
ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) }));
|
||||
// Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht
|
||||
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
|
|
@ -1092,6 +1398,80 @@ wss.on("connection", (ws) => {
|
|||
if (ws._sshSock) ws._sshSock.write(msg.data);
|
||||
} else if (msg.action === "live_ssh_close") {
|
||||
if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; }
|
||||
} else if (msg.action === "send_file") {
|
||||
// Datei von Diagnostic an Bridge via RVS senden
|
||||
sendToRVS_raw({
|
||||
type: "file",
|
||||
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
||||
} else if (msg.action === "cancel_request") {
|
||||
// Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
|
||||
log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||
} else if (msg.action === "voice_upload") {
|
||||
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
|
||||
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
|
||||
sendToRVS_withResponse("voice_upload", { name: msg.name, samples: msg.samples }, "xtts_voice_saved", ws);
|
||||
} else if (msg.action === "xtts_list_voices") {
|
||||
// Frische Verbindung die auf Antwort wartet
|
||||
sendToRVS_withResponse("xtts_list_voices", {}, "xtts_voices_list", ws);
|
||||
} else if (msg.action === "xtts_delete_voice") {
|
||||
// Weiterleiten an XTTS-Bridge, die antwortet mit neuer Liste
|
||||
sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
|
||||
log("info", "server", `Voice-Delete '${msg.name}' an XTTS-Bridge gesendet`);
|
||||
} else if (msg.action === "set_mode") {
|
||||
// Mode-Wechsel → Bridge bearbeitet und broadcastet an alle Clients
|
||||
sendToRVS_raw({ type: "mode", payload: { mode: msg.mode }, timestamp: Date.now() });
|
||||
log("info", "server", `Mode-Wechsel angefordert: ${msg.mode}`);
|
||||
} else if (msg.action === "get_voice_config") {
|
||||
handleGetVoiceConfig(ws);
|
||||
} else if (msg.action === "send_voice_config") {
|
||||
// Stimmen-Config persistent speichern + an Bridge via RVS senden
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(fs.readFileSync("/shared/config/voice_config.json", "utf-8")); } catch {}
|
||||
const voiceConfig = {
|
||||
...existing,
|
||||
ttsEnabled: msg.ttsEnabled !== false,
|
||||
xttsVoice: msg.xttsVoice || "",
|
||||
};
|
||||
if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel;
|
||||
// F5-TTS Tuning-Felder — immer mit dem vom User gesendeten Wert setzen,
|
||||
// auch leeren String. Leer = "reset auf Hard-Default". Sonst merkt die
|
||||
// Bridge nicht dass der User den Wert loeschen wollte (absent key war
|
||||
// vorher 'keep current' semantik → BigVGAN blieb drin obwohl User
|
||||
// leer eingetragen hatte).
|
||||
if (msg.f5ttsModel !== undefined) voiceConfig.f5ttsModel = msg.f5ttsModel || "";
|
||||
if (msg.f5ttsCkptFile !== undefined) voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile || "";
|
||||
if (msg.f5ttsVocabFile !== undefined) voiceConfig.f5ttsVocabFile = msg.f5ttsVocabFile || "";
|
||||
if (msg.f5ttsCfgStrength !== undefined && !isNaN(msg.f5ttsCfgStrength)) {
|
||||
voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength;
|
||||
}
|
||||
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
|
||||
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||
} catch {}
|
||||
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
|
||||
log("info", "server", `Voice-Config gespeichert: xttsVoice=${voiceConfig.xttsVoice || "default"}, whisper=${voiceConfig.whisperModel || "-"}`);
|
||||
} else if (msg.action === "get_triggers") {
|
||||
handleGetTriggers(ws);
|
||||
} else if (msg.action === "save_triggers") {
|
||||
handleSaveTriggers(ws, msg.triggers || []);
|
||||
} else if (msg.action === "test_tts") {
|
||||
handleTestTTS(ws, msg.text || "Test");
|
||||
} else if (msg.action === "preview_voice") {
|
||||
handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo.", msg.speed);
|
||||
} else if (msg.action === "check_tts") {
|
||||
handleCheckTTS(ws);
|
||||
} else if (msg.action === "check_desktop") {
|
||||
checkDesktopAvailable(ws);
|
||||
} else if (msg.action === "load_chat_history") {
|
||||
|
|
@ -1100,6 +1480,8 @@ wss.on("connection", (ws) => {
|
|||
handleListSessions(ws);
|
||||
} else if (msg.action === "read_session") {
|
||||
handleReadSession(ws, msg.sessionPath);
|
||||
} else if (msg.action === "export_session") {
|
||||
handleExportSession(ws, msg.sessionPath, msg.sessionKey);
|
||||
} else if (msg.action === "delete_session") {
|
||||
handleDeleteSession(ws, msg.sessionPath);
|
||||
} else if (msg.action === "set_active_session") {
|
||||
|
|
@ -1218,6 +1600,170 @@ function startLiveSSH(clientWs) {
|
|||
createReq.end(createBody);
|
||||
}
|
||||
|
||||
// ── Voice-Config laden ────────────────────────────────
|
||||
|
||||
function handleGetVoiceConfig(clientWs) {
|
||||
try {
|
||||
const configPath = "/shared/config/voice_config.json";
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
clientWs.send(JSON.stringify({ type: "voice_config", ...config }));
|
||||
} else {
|
||||
clientWs.send(JSON.stringify({ type: "voice_config", ttsEnabled: true, xttsVoice: "" }));
|
||||
}
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "voice_config", ttsEnabled: true, xttsVoice: "" }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Highlight-Trigger (legacy UI — wird nicht mehr ausgewertet seit Piper raus) ─
|
||||
const TRIGGERS_FILE = "/shared/config/highlight_triggers.json";
|
||||
|
||||
async function handleGetTriggers(clientWs) {
|
||||
try {
|
||||
const triggers = fs.existsSync(TRIGGERS_FILE)
|
||||
? JSON.parse(fs.readFileSync(TRIGGERS_FILE, "utf-8"))
|
||||
: [];
|
||||
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "trigger_list", triggers: [], error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTriggers(clientWs, triggers) {
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync(TRIGGERS_FILE, JSON.stringify(triggers, null, 2));
|
||||
log("info", "server", `${triggers.length} Highlight-Trigger gespeichert`);
|
||||
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
|
||||
} catch (err) {
|
||||
log("error", "server", `Trigger speichern fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS Diagnose (XTTS) ───────────────────────────────
|
||||
// ── Voice Preview ────────────────────────────────────────
|
||||
// Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV
|
||||
// und schickt sie base64-kodiert an den Browser-Client.
|
||||
//
|
||||
// Map requestId → { clientWs, chunks: [Buffer], sampleRate, channels }
|
||||
const _previewPending = new Map();
|
||||
|
||||
function _buildWavFromPcm(pcmBuf, sampleRate, channels) {
|
||||
const bitsPerSample = 16;
|
||||
const byteRate = sampleRate * channels * bitsPerSample / 8;
|
||||
const blockAlign = channels * bitsPerSample / 8;
|
||||
const dataSize = pcmBuf.length;
|
||||
const header = Buffer.alloc(44);
|
||||
header.write("RIFF", 0);
|
||||
header.writeUInt32LE(36 + dataSize, 4);
|
||||
header.write("WAVE", 8);
|
||||
header.write("fmt ", 12);
|
||||
header.writeUInt32LE(16, 16); // subchunk1 size
|
||||
header.writeUInt16LE(1, 20); // PCM
|
||||
header.writeUInt16LE(channels, 22);
|
||||
header.writeUInt32LE(sampleRate, 24);
|
||||
header.writeUInt32LE(byteRate, 28);
|
||||
header.writeUInt16LE(blockAlign, 32);
|
||||
header.writeUInt16LE(bitsPerSample, 34);
|
||||
header.write("data", 36);
|
||||
header.writeUInt32LE(dataSize, 40);
|
||||
return Buffer.concat([header, pcmBuf]);
|
||||
}
|
||||
|
||||
function _handlePreviewChunk(payload) {
|
||||
const reqId = payload?.requestId || "";
|
||||
const entry = _previewPending.get(reqId);
|
||||
if (!entry) return;
|
||||
if (payload.base64) {
|
||||
try { entry.chunks.push(Buffer.from(payload.base64, "base64")); } catch {}
|
||||
}
|
||||
if (!entry.sampleRate && payload.sampleRate) entry.sampleRate = payload.sampleRate;
|
||||
if (!entry.channels && payload.channels) entry.channels = payload.channels;
|
||||
if (payload.final) {
|
||||
_previewPending.delete(reqId);
|
||||
try {
|
||||
const pcm = Buffer.concat(entry.chunks);
|
||||
const wav = _buildWavFromPcm(pcm, entry.sampleRate || 24000, entry.channels || 1);
|
||||
const b64 = wav.toString("base64");
|
||||
if (entry.clientWs && entry.clientWs.readyState === 1) {
|
||||
entry.clientWs.send(JSON.stringify({
|
||||
type: "voice_preview_audio",
|
||||
base64: b64,
|
||||
size: wav.length,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
if (entry.clientWs && entry.clientWs.readyState === 1) {
|
||||
entry.clientWs.send(JSON.stringify({
|
||||
type: "voice_preview_audio",
|
||||
error: err.message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVoicePreview(clientWs, voice, text, speed) {
|
||||
try {
|
||||
// Speed clampen — Browser-Slider ist 0.1-5.0
|
||||
let spd = parseFloat(speed);
|
||||
if (!isFinite(spd) || spd < 0.1 || spd > 5.0) spd = 1.0;
|
||||
const requestId = crypto.randomUUID();
|
||||
_previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 });
|
||||
// Timeout safety net
|
||||
setTimeout(() => {
|
||||
if (_previewPending.has(requestId)) {
|
||||
_previewPending.delete(requestId);
|
||||
if (clientWs && clientWs.readyState === 1) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "voice_preview_audio",
|
||||
error: "Timeout (60s) — keine Antwort vom f5tts-bridge",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
log("info", "server", `Voice-Preview: voice="${voice}" speed=${spd.toFixed(1)}x text="${text.slice(0, 60)}"`);
|
||||
sendToRVS_raw({
|
||||
type: "xtts_request",
|
||||
payload: { text, language: "de", requestId, voice, speed: spd },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestTTS(clientWs, text) {
|
||||
try {
|
||||
log("info", "server", `TTS-Test via XTTS: "${text}"`);
|
||||
// Via RVS an die XTTS-Bridge: xtts_request mit Test-Text
|
||||
const requestId = crypto.randomUUID();
|
||||
sendToRVS_raw({
|
||||
type: "xtts_request",
|
||||
payload: { text, language: "de", requestId, voice: "" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clientWs.send(JSON.stringify({ type: "tts_result", ok: true, duration: "pending", size: "?" }));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "tts_result", ok: false, error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckTTS(clientWs) {
|
||||
try {
|
||||
// XTTS-Status ueber RVS abfragen (xtts_list_voices)
|
||||
sendToRVS_raw({ type: "xtts_list_voices", payload: {}, timestamp: Date.now() });
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "tts_status",
|
||||
ok: true,
|
||||
error: null,
|
||||
}));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "tts_status", ok: false, error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function checkDesktopAvailable(clientWs) {
|
||||
// Pruefen ob VNC auf der VM laeuft (Port 5900/5901)
|
||||
const checkSock = net.connect({ host: "host.docker.internal", port: 5901 }, () => {
|
||||
|
|
@ -1254,17 +1800,17 @@ async function handleListSessions(clientWs) {
|
|||
try {
|
||||
log("info", "server", "Lade Sessions aus aria-core...");
|
||||
|
||||
// sessions.json als Index lesen + Datei-Details holen
|
||||
// sessions.json als Index lesen + Datei-Details holen (inkl. .reset.* Archive)
|
||||
const raw = await dockerExec("aria-core", `
|
||||
cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' &&
|
||||
echo '===FILE_DETAILS===' &&
|
||||
for f in ${SESSIONS_DIR}/*.jsonl; do
|
||||
for f in ${SESSIONS_DIR}/*.jsonl ${SESSIONS_DIR}/*.jsonl.reset.*; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
lines=$(wc -l < "$f" 2>/dev/null || echo 0)
|
||||
msgs=$(grep -cE '"role":"(user|assistant)"' "$f" 2>/dev/null || echo 0)
|
||||
size=$(du -h "$f" 2>/dev/null | cut -f1)
|
||||
modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0)
|
||||
echo "FILE:$name|LINES:$lines|SIZE:$size|MODIFIED:$modified"
|
||||
echo "FILE:$name|LINES:$msgs|SIZE:$size|MODIFIED:$modified"
|
||||
done
|
||||
`.trim());
|
||||
|
||||
|
|
@ -1319,8 +1865,29 @@ async function handleListSessions(clientWs) {
|
|||
delete fileDetails[filename];
|
||||
}
|
||||
|
||||
// Dateien die nicht im Index stehen (Waisen / Reset-Files)
|
||||
// Dateien die nicht im Index stehen (Waisen ODER Reset-Archive)
|
||||
for (const [filename, details] of Object.entries(fileDetails)) {
|
||||
// .jsonl.reset.<ISO-Timestamp>Z → archivierte Session (OpenClaw-Reset)
|
||||
// Format: 528f4d70-...jsonl.reset.2026-04-18T09-49-44.814Z
|
||||
const resetMatch = filename.match(/^([a-f0-9-]+)\.jsonl\.reset\.(.+Z)$/);
|
||||
if (resetMatch) {
|
||||
const id = resetMatch[1];
|
||||
// Timestamp ISO-8601 parsen: 2026-04-18T09-49-44.814Z → 2026-04-18T09:49:44.814Z
|
||||
const tsStr = resetMatch[2].replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
|
||||
const resetAt = Math.floor(new Date(tsStr).getTime() / 1000) || parseInt(details.MODIFIED) || 0;
|
||||
sessions.push({
|
||||
path: `${SESSIONS_DIR}/${filename}`,
|
||||
sessionKey: id.slice(0, 8) + "… (archiv)",
|
||||
sessionId: id,
|
||||
lines: parseInt(details.LINES) || 0,
|
||||
size: details.SIZE || "?",
|
||||
modified: resetAt,
|
||||
archived: true,
|
||||
resetAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Echte Waisen (UUID.jsonl ohne Eintrag in sessions.json)
|
||||
const id = filename.replace(".jsonl", "");
|
||||
sessions.push({
|
||||
path: `${SESSIONS_DIR}/${filename}`,
|
||||
|
|
@ -1365,6 +1932,68 @@ async function handleReadSession(clientWs, sessionPath) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleExportSession(clientWs, sessionPath, sessionKey) {
|
||||
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
|
||||
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: "Ungueltiger Pfad" }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const safePath = sessionPath.replace(/'/g, "");
|
||||
const raw = await dockerExec("aria-core", `cat '${safePath}'`);
|
||||
const lines = raw.split("\n").filter(l => l.trim());
|
||||
|
||||
const blocks = [];
|
||||
for (const line of lines) {
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj.type !== "message" || !obj.message) continue;
|
||||
const role = obj.message.role;
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
let text = "";
|
||||
const content = obj.message.content;
|
||||
if (typeof content === "string") text = content;
|
||||
else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n");
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "user") {
|
||||
text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim();
|
||||
text = text.replace(/^\[.*?\]\s*/, "").trim();
|
||||
} else {
|
||||
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
const ts = obj.message.timestamp || obj.timestamp || 0;
|
||||
const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "";
|
||||
const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA";
|
||||
blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`);
|
||||
}
|
||||
|
||||
const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
const title = sessionKey || sessionPath.split("/").pop().replace(".jsonl", "");
|
||||
const markdown = [
|
||||
`# Session: ${title}`,
|
||||
``,
|
||||
`Exportiert: ${exportedAt} `,
|
||||
`Quelle: ${sessionPath}`,
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
blocks.join("\n\n---\n\n"),
|
||||
``,
|
||||
].join("\n");
|
||||
|
||||
const safeKey = (sessionKey || "session").replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const filename = `${exportedAt.slice(0, 10)}_${safeKey}.md`;
|
||||
clientWs.send(JSON.stringify({ type: "session_export", ok: true, filename, markdown }));
|
||||
log("info", "server", `Session exportiert: ${filename} (${blocks.length} Nachrichten)`);
|
||||
} catch (err) {
|
||||
log("error", "server", `Session-Export fehlgeschlagen: ${err.message}`);
|
||||
clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSession(clientWs, sessionPath) {
|
||||
if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) {
|
||||
clientWs.send(JSON.stringify({ type: "session_deleted", ok: false, error: "Ungueltiger Pfad" }));
|
||||
|
|
@ -1405,13 +2034,11 @@ async function handleDeleteSession(clientWs, sessionPath) {
|
|||
}
|
||||
|
||||
// ── Session-Aufloesung: letzte aktive Session finden ────
|
||||
// Wird nach Gateway-(Re-)Connect aufgerufen. Darf die explizit gewaehlte
|
||||
// Session NIE ueberschreiben — nur beim absoluten Erststart auto-picken.
|
||||
async function resolveActiveSession() {
|
||||
// Nur bei Fallback-Key "main" automatisch aufloesen — gespeicherte Wahl respektieren
|
||||
const hasSavedSession = (() => {
|
||||
try { return !!fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim(); } catch { return false; }
|
||||
})();
|
||||
if (hasSavedSession && activeSessionKey !== "main") {
|
||||
log("info", "server", `Gespeicherte Session '${activeSessionKey}' wird beibehalten`);
|
||||
if (sessionFromFile) {
|
||||
log("info", "server", `Session '${activeSessionKey}' aus /data — keine Auto-Wahl`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1430,10 +2057,19 @@ async function resolveActiveSession() {
|
|||
const keys = entries.map(e => (e.key || e.sessionKey || e.name || "?").replace(/^agent:main:/, ""));
|
||||
log("info", "server", `Verfuegbare Sessions: [${keys.join(", ")}]`);
|
||||
|
||||
// Neueste Session nehmen
|
||||
// Neueste Session nehmen — aber user-definierte bevorzugen.
|
||||
// aria-bridge / aria-diagnostic werden von den Services auto-erstellt;
|
||||
// bei erstem Start soll lieber eine "echte" Session gewaehlt werden,
|
||||
// falls vorhanden.
|
||||
const AUTO_KEYS = new Set(["aria-bridge", "aria-diagnostic"]);
|
||||
const normalise = (e) => (e.key || e.sessionKey || e.name || "").replace(/^agent:main:/, "");
|
||||
|
||||
const userEntries = entries.filter(e => !AUTO_KEYS.has(normalise(e)));
|
||||
const pool = userEntries.length > 0 ? userEntries : entries;
|
||||
|
||||
let newest = null;
|
||||
let newestTime = 0;
|
||||
for (const entry of entries) {
|
||||
for (const entry of pool) {
|
||||
const t = entry.updatedAt || entry.createdAt || 0;
|
||||
if (t >= newestTime) {
|
||||
newestTime = t;
|
||||
|
|
@ -1442,12 +2078,11 @@ async function resolveActiveSession() {
|
|||
}
|
||||
|
||||
if (newest) {
|
||||
const rawKey = newest.key || newest.sessionKey || newest.name || "";
|
||||
const key = rawKey.replace(/^agent:main:/, "");
|
||||
const key = normalise(newest);
|
||||
if (key) {
|
||||
activeSessionKey = key;
|
||||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
||||
log("info", "server", `Aktive Session auf neueste gewechselt: '${activeSessionKey}'`);
|
||||
persistActiveSession(activeSessionKey);
|
||||
log("info", "server", `Auto-Wahl Erststart: '${activeSessionKey}'`);
|
||||
for (const c of browserClients) {
|
||||
c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey }));
|
||||
}
|
||||
|
|
@ -1536,8 +2171,11 @@ function handleSetActiveSession(clientWs, sessionKey) {
|
|||
return;
|
||||
}
|
||||
activeSessionKey = sessionKey;
|
||||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
||||
log("info", "server", `Aktive Session: ${activeSessionKey}`);
|
||||
const ok = persistActiveSession(activeSessionKey);
|
||||
log("info", "server", `Aktive Session: ${activeSessionKey}${ok ? "" : " (WARN: nicht persistiert!)"}`);
|
||||
if (!ok) {
|
||||
clientWs.send(JSON.stringify({ type: "active_session", ok: false, sessionKey: activeSessionKey, error: "Persistierung fehlgeschlagen — /data Volume pruefen" }));
|
||||
}
|
||||
// Allen Clients mitteilen
|
||||
for (const c of browserClients) {
|
||||
c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey }));
|
||||
|
|
@ -1553,7 +2191,7 @@ async function handleCreateSession(clientWs, sessionName) {
|
|||
try {
|
||||
// Session wird automatisch erstellt wenn man die erste Nachricht sendet
|
||||
activeSessionKey = sessionName;
|
||||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
||||
persistActiveSession(activeSessionKey);
|
||||
log("info", "server", `Neue Session erstellt und aktiviert: ${sessionName}`);
|
||||
// Allen Clients mitteilen
|
||||
for (const c of browserClients) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ services:
|
|||
claude-max-api"
|
||||
volumes:
|
||||
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
||||
- ./aria-data/ssh:/root/.ssh:ro # SSH Keys fuer VM-Zugriff (aria-wohnung)
|
||||
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
|
||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
|
||||
|
|
@ -71,7 +72,6 @@ services:
|
|||
- aria
|
||||
network_mode: "service:aria" # Teilt Netzwerk mit aria-core → localhost:18789
|
||||
volumes:
|
||||
- ./aria-data/voices:/voices:ro # TTS Stimmen
|
||||
- ./aria-data/config/aria.env:/config/aria.env
|
||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Bridge <> Core)
|
||||
# Audio-Zugriff
|
||||
|
|
@ -99,7 +99,7 @@ services:
|
|||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
|
||||
- aria-shared:/shared:ro # Shared Volume (Uploads lesen fuer Vorschau)
|
||||
- aria-shared:/shared # Shared Volume (Uploads + Config)
|
||||
environment:
|
||||
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
||||
- PROXY_URL=http://proxy:3456
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ════════════════════════════════════════════════
|
||||
# ARIA — Piper Stimmen herunterladen
|
||||
# Ramona (Alltag) + Thorsten (epische Momente)
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
|
||||
VOICES_DIR="aria-data/voices"
|
||||
BASE_URL="https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE"
|
||||
|
||||
mkdir -p "$VOICES_DIR"
|
||||
cd "$VOICES_DIR"
|
||||
|
||||
echo "Lade ARIA Stimmen..."
|
||||
echo ""
|
||||
|
||||
echo "[1/4] Ramona (Modell)..."
|
||||
wget -q --show-progress "$BASE_URL/ramona/low/de_DE-ramona-low.onnx"
|
||||
|
||||
echo "[2/4] Ramona (Config)..."
|
||||
wget -q --show-progress "$BASE_URL/ramona/low/de_DE-ramona-low.onnx.json"
|
||||
|
||||
echo "[3/4] Thorsten (Modell)..."
|
||||
wget -q --show-progress "$BASE_URL/thorsten/high/de_DE-thorsten-high.onnx"
|
||||
|
||||
echo "[4/4] Thorsten (Config)..."
|
||||
wget -q --show-progress "$BASE_URL/thorsten/high/de_DE-thorsten-high.onnx.json"
|
||||
|
||||
echo ""
|
||||
echo "Stimmen geladen!"
|
||||
ls -lh *.onnx
|
||||
115
issue.md
115
issue.md
|
|
@ -1,7 +1,108 @@
|
|||
bildupload ghet noch nicht.
|
||||
sprachnachrichten werden nicht als zweite nachricht dargestellt, damit man weiß was man gesendet hat
|
||||
cache leeren, bilder werden nicht neu geladen beim antippen.
|
||||
autoload geht nicht
|
||||
wenn man auf das ohr zum hören klickt stürzt ab
|
||||
aria liest die nachrichten nicht vor
|
||||
autoscroll geht doch noch nicht zur letzten nachricht
|
||||
# ARIA Issues & Features
|
||||
|
||||
## Erledigt
|
||||
|
||||
- [x] Bildupload funktioniert (Shared Volume /shared/uploads/)
|
||||
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
|
||||
- [x] Cache leeren + Auto-Download von Anhaengen
|
||||
- [x] ARIA liest Nachrichten vor (TTS via Piper, später ersetzt)
|
||||
- [x] Autoscroll zur letzten Nachricht (inverted FlatList)
|
||||
- [x] Bilder im Chat groesser + Vollbild-Vorschau
|
||||
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
|
||||
- [x] Play-Button in ARIA-Nachrichten fuer Sprachwiedergabe
|
||||
- [x] Chat-Suche in der App (Lupe in Statusleiste)
|
||||
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
|
||||
- [x] Abbrechen-Button im Diagnostic Chat
|
||||
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
|
||||
- [x] Grosse Nachrichten satzweise aufteilen fuer TTS
|
||||
- [x] RVS Nachrichten vom Smartphone gehen durch
|
||||
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme — durch XTTS/F5-TTS ersetzt)
|
||||
- [x] Highlight-Trigger konfigurierbar in Diagnostic
|
||||
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning) — durch F5-TTS ersetzt
|
||||
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
|
||||
- [x] TTS Engine waehlbar (Piper/XTTS) — Piper raus, XTTS raus, jetzt nur F5-TTS
|
||||
- [x] Auto-Update System (APK via RVS WebSocket)
|
||||
- [x] Auto-Update: APK-Installation via FileProvider
|
||||
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
|
||||
- [x] Audio-Queue (sequentielle Wiedergabe, kein Ueberlappen)
|
||||
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
|
||||
- [x] Mehrere Anhaenge + Text vor dem Senden (Pending-Vorschau)
|
||||
- [x] Paste-Support fuer Bilder in Diagnostic Chat
|
||||
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
|
||||
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
|
||||
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
|
||||
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten
|
||||
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
|
||||
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
||||
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload
|
||||
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
||||
- [x] Streaming TTS: PCM-Stream → AudioTrack MODE_STREAM, keine WAV-Gaps
|
||||
- [x] Piper komplett entfernt
|
||||
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms)
|
||||
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) angezeigt + exportierbar
|
||||
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer Session-JSONL zu Markdown
|
||||
- [x] NO_REPLY-Filter in Bridge + Diagnostic
|
||||
- [x] Audio-Ducking + Exklusiv-Focus (Kotlin AudioFocusModule)
|
||||
- [x] TTS-Cleanup serverseitig: Code-Bloecke raus, Einheiten ausgeschrieben, Abkuerzungen buchstabiert, URLs zu "ein Link"
|
||||
- [x] QR-Code Onboarding: Diagnostic generiert QR, App scannt
|
||||
- [x] TTS-Audio-Cache im Filesystem: WAV pro messageId, Play-Button spielt aus Cache
|
||||
- [x] Config via Diagnostic: RVS-Credentials + Auth-Token persistiert in /shared/config/runtime.json
|
||||
- [x] Disk-Voll Banner in Diagnostic: rotes Overlay + copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||
- [x] cleanup.sh: kombinierter Docker-Aufraeum-Befehl (safe / --full)
|
||||
- [x] Streaming TTS Pre-Roll: AudioTrack play() startet erst wenn 2.5s gepuffert sind
|
||||
- [x] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
|
||||
- [x] Leading-Silence (200ms) am Stream-Anfang — AudioTrack faehrt sauber an
|
||||
- [x] Pre-Roll-Buffer einstellbar in App-Settings (1.0-6.0s, Default 3.5s)
|
||||
- [x] Fade-In auf erstem PCM-Chunk (120ms) — versteckt XTTS/F5-TTS Warmup-Glitches
|
||||
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
|
||||
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
|
||||
- [x] Voice-Auswahl funktioniert wieder: speaker_wav als Basename statt Pfad fuer daswer123 local-Mode
|
||||
- [x] Diagnostic-Voice-Wechsel resettet alle App-lokalen Voice-Overrides via type "config"
|
||||
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
|
||||
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
|
||||
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
|
||||
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic
|
||||
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
|
||||
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
|
||||
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
|
||||
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
|
||||
- [x] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s)
|
||||
- [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich
|
||||
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
|
||||
- [x] F5-TTS: Referenz-WAV-Preprocessing — Loudness-Normalisierung -16 LUFS + Silence-Trim + 10s Clip fuer konsistente Cloning-Quali
|
||||
- [x] F5-TTS: deutsches Fine-Tune (aihpi/F5-TTS-German, Vocos-Variante) via hf:// Pfad in Diagnostic konfigurierbar
|
||||
- [x] Whisper transkribiert Voice-Uploads nicht mehr mit hardcoded "small" — aktuelles Modell wird behalten, kein unnoetiger Modell-Swap
|
||||
- [x] RVS/WebSocket maxPayload 50MB: voice_upload mit WAV als base64 sprengt kein Frame-Limit mehr
|
||||
- [x] Dynamischer STT-Timeout in aria-bridge: 300s waehrend whisper-bridge 'loading', 45s wenn 'ready'
|
||||
- [x] service_status Broadcasts: f5tts/whisper melden Lade-Status, Banner in Diagnostic (unten rechts) + App (oben)
|
||||
- [x] config_request Pattern: Bridges fragen beim Connect die aktuelle Voice-Config an, aria-bridge antwortet
|
||||
- [x] F5-TTS Tuning via Diagnostic (Modell-ID, Checkpoint, cfg_strength, nfe_step) statt ENV-Vars — Hot-Reload bei Modell-Wechsel
|
||||
- [x] Conversation-Window: Gespraechsmodus endet nach X Sekunden Stille (1.0-20.0s, Default 8s, einstellbar in Settings)
|
||||
- [x] Porcupine Wake-Word-Integration in der App (Built-In Keywords + Custom spaeter, per Geraet einstellbar)
|
||||
- [x] HF-Cache als Bind-Mount statt Docker Volume — kein .vhdx-Bloat auf Docker Desktop / Windows
|
||||
- [x] cleanup-windows.ps1 / .bat: VHDX-Cleanup via diskpart (ohne Hyper-V) mit Self-Elevation
|
||||
- [x] App Mute-/Auto-Playback-Bug: Closure-Bug geloest (ttsCanPlayRef live-gespiegelt, nicht mehr stale)
|
||||
- [x] App Zombie-Recording: Ohr-aus kill laufende Aufnahme damit der Aufnahme-Button weiter funktioniert
|
||||
- [x] App Text-Rendering: Nachrichten selektierbar + Autolink fuer URLs/E-Mails/Telefonnummern (Browser/Mail/Dialer)
|
||||
- [x] TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings → 0.5-2.0x in 0.1-Schritten, Default 1.0)
|
||||
- [x] Diagnostic: Voice-Preview-Modal (Play-Icon vor Delete-X, Textfeld mit Default, WAV im Browser abspielen)
|
||||
|
||||
## Offen
|
||||
|
||||
### Bugs
|
||||
- [ ] App: Wake-Word "jarvis" triggert nicht zuverlaessig (Porcupine-Debugging via ADB-Logcat ausstehend)
|
||||
- [ ] App: Stuerzt beim Lauschen ab, eventuell bei Nebengeraeuschen (Porcupine + Mic-Race, errorCallback haelt's jetzt zurueck — Dauertest ausstehend)
|
||||
|
||||
### App Features
|
||||
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
||||
- [ ] Background Audio Service (TTS auch bei minimierter App)
|
||||
|
||||
### Architektur
|
||||
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
||||
- [ ] Auto-Compacting und Memory/Brain Verwaltung (SQLite?)
|
||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||
- [ ] Alle .env-Variablen ueber Diagnostic konfigurierbar machen (Fallback .env bleibt fuer initialen Bootstrap)
|
||||
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
||||
- [ ] Root-Cause OpenClaw Session-Reset: Herausfinden warum Sessions beim ersten chat.send nach Container-Restart verworfen werden
|
||||
|
|
|
|||
24
release.sh
24
release.sh
|
|
@ -76,8 +76,11 @@ echo -e " ${GREEN}✓${NC} SettingsScreen → Version $VERSION"
|
|||
echo ""
|
||||
|
||||
# ── APK bauen ─────────────────────────────────
|
||||
echo -e "${GREEN}[2/5] APK bauen...${NC}"
|
||||
echo -e "${GREEN}[2/5] APK bauen (Cache leeren + Build)...${NC}"
|
||||
cd android
|
||||
# Metro + Gradle Cache leeren damit neue Version sauber eingebettet wird
|
||||
rm -rf node_modules/.cache 2>/dev/null
|
||||
cd android && ./gradlew clean 2>/dev/null; cd ..
|
||||
./build.sh release
|
||||
cd ..
|
||||
|
||||
|
|
@ -170,6 +173,24 @@ else
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# ── Auto-Update: APK auf RVS-Server kopieren ─
|
||||
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
|
||||
if [ -n "$RVS_UPDATE_HOST" ]; then
|
||||
echo -e "${GREEN}[6/6] APK auf RVS-Server kopieren (Auto-Update)...${NC}"
|
||||
# Alte APKs auf dem RVS loeschen, dann neue hochladen
|
||||
ssh "$RVS_UPDATE_HOST" "rm -f ~/ARIA-AGENT/rvs/updates/ARIA-*.apk" 2>/dev/null
|
||||
scp "$APK_PATH" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/${APK_NAME}" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e " ${GREEN}✓${NC} APK auf RVS-Server kopiert (alte Versionen geloescht)"
|
||||
else
|
||||
echo -e " ${YELLOW}APK konnte nicht auf RVS kopiert werden (RVS_UPDATE_HOST=$RVS_UPDATE_HOST)${NC}"
|
||||
echo -e " ${YELLOW}Manuell: scp $APK_PATH $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/${APK_NAME}${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}Auto-Update uebersprungen (RVS_UPDATE_HOST nicht gesetzt)${NC}"
|
||||
echo -e "${YELLOW}Setze RVS_UPDATE_HOST in .env fuer automatische Verteilung${NC}"
|
||||
fi
|
||||
|
||||
# ── Fertig ────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
|
|
@ -177,4 +198,5 @@ echo -e "${GREEN}║ Release $TAG ist live!$(printf '%*s' $((27 - ${#TAG})) ''
|
|||
echo -e "${GREEN}╠═══════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG"
|
||||
echo -e "${GREEN}║${NC} APK: $APK_NAME ($APK_SIZE)"
|
||||
echo -e "${GREEN}║${NC} Auto-Update: ${RVS_UPDATE_HOST:-nicht konfiguriert}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ services:
|
|||
ports:
|
||||
- "${RVS_PORT:-443}:3000"
|
||||
restart: always
|
||||
volumes:
|
||||
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
||||
environment:
|
||||
- MAX_SESSIONS=10
|
||||
|
|
|
|||
126
rvs/server.js
126
rvs/server.js
|
|
@ -1,15 +1,28 @@
|
|||
"use strict";
|
||||
|
||||
const { WebSocketServer } = require("ws");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ── Konfiguration aus Umgebungsvariablen ────────────────────────────
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
||||
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
|
||||
// Kein Polling — APK wird manuell per git pull bereitgestellt
|
||||
|
||||
// Erlaubte Nachrichtentypen — alles andere wird verworfen
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
|
||||
"file_request", "file_response", "file_saved", "stt_result",
|
||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||
"update_check", "update_available", "update_download", "update_data",
|
||||
"agent_activity", "cancel_request",
|
||||
"audio_pcm",
|
||||
"xtts_delete_voice",
|
||||
"voice_preload", "voice_ready",
|
||||
"stt_request", "stt_response",
|
||||
"service_status",
|
||||
"config_request",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
|
|
@ -42,10 +55,16 @@ function cleanupRooms() {
|
|||
|
||||
// ── WebSocket-Server starten ────────────────────────────────────────
|
||||
|
||||
const wss = new WebSocketServer({ port: PORT });
|
||||
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
||||
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
||||
// Default-Limit war der Killer fuer die voice_upload Pipeline.
|
||||
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
|
||||
|
||||
wss.on("listening", () => {
|
||||
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
|
||||
// Beim Start pruefen ob eine APK da ist
|
||||
const apkInfo = getLatestAPK();
|
||||
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
});
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
|
|
@ -107,6 +126,52 @@ function registerClient(ws, token) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Update-Check: direkt an den anfragenden Client antworten (nicht relay'en)
|
||||
if (msg.type === "update_check") {
|
||||
const clientVersion = msg.payload?.version || "0.0.0.0";
|
||||
const apkInfo = getLatestAPK();
|
||||
if (apkInfo && compareVersions(apkInfo.version, clientVersion) > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "update_available",
|
||||
payload: {
|
||||
version: apkInfo.version,
|
||||
downloadUrl: `/update/latest.apk`,
|
||||
size: fs.statSync(apkInfo.path).size,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update-Download: APK als Base64 ueber WebSocket senden
|
||||
if (msg.type === "update_download") {
|
||||
const apkInfo = getLatestAPK();
|
||||
if (!apkInfo) {
|
||||
ws.send(JSON.stringify({ type: "update_data", payload: { error: "Keine APK verfuegbar" }, timestamp: Date.now() }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = fs.readFileSync(apkInfo.path);
|
||||
const base64 = data.toString("base64");
|
||||
const sizeMB = (data.length / 1024 / 1024).toFixed(1);
|
||||
log(`APK sende: v${apkInfo.version} (${sizeMB}MB) an Client`);
|
||||
ws.send(JSON.stringify({
|
||||
type: "update_data",
|
||||
payload: {
|
||||
version: apkInfo.version,
|
||||
base64,
|
||||
size: data.length,
|
||||
fileName: `ARIA-v${apkInfo.version}.apk`,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: "update_data", payload: { error: err.message }, timestamp: Date.now() }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// An alle anderen Clients im Raum weiterleiten
|
||||
for (const client of room.clients) {
|
||||
if (client !== ws && client.readyState === 1) {
|
||||
|
|
@ -167,6 +232,63 @@ wss.on("close", () => {
|
|||
clearInterval(cleanup);
|
||||
});
|
||||
|
||||
// ── Auto-Update: APK-Erkennung + Push ──────────────────────────────
|
||||
|
||||
let latestVersion = null;
|
||||
|
||||
function getLatestAPK() {
|
||||
try {
|
||||
if (!fs.existsSync(UPDATES_DIR)) return null;
|
||||
const files = fs.readdirSync(UPDATES_DIR)
|
||||
.filter(f => f.endsWith(".apk"))
|
||||
.map(f => {
|
||||
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
||||
const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/);
|
||||
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
|
||||
})
|
||||
.filter(f => f.version)
|
||||
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
||||
|
||||
return files[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = a.split(".").map(Number);
|
||||
const pb = b.split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function notifyClientsAboutUpdate(apkInfo) {
|
||||
const msg = JSON.stringify({
|
||||
type: "update_available",
|
||||
payload: {
|
||||
version: apkInfo.version,
|
||||
downloadUrl: `/update/latest.apk`,
|
||||
size: fs.statSync(apkInfo.path).size,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// An alle Clients in allen Rooms senden
|
||||
for (const [, room] of rooms) {
|
||||
for (const client of room.clients) {
|
||||
if (client.readyState === 1) {
|
||||
client.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`Update-Benachrichtigung gesendet: v${apkInfo.version} (${rooms.size} Raum/Raeume)`);
|
||||
}
|
||||
|
||||
// Kein Polling — Update-Check passiert on-demand (update_check Message von App)
|
||||
|
||||
// ── Sauberes Herunterfahren ─────────────────────────────────────────
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Exportiert ein OpenClaw Session-JSONL (auch .reset.*) als Markdown.
|
||||
*
|
||||
* Nutzung:
|
||||
* node export-jsonl-to-md.js <input.jsonl> [output.md]
|
||||
*
|
||||
* Oder direkt aus dem aria-core Container:
|
||||
* docker exec aria-core cat /home/node/.openclaw/agents/main/sessions/<ID>.jsonl.reset.<TS> \
|
||||
* | node export-jsonl-to-md.js - > output.md
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const inputArg = process.argv[2];
|
||||
const outputArg = process.argv[3];
|
||||
|
||||
if (!inputArg) {
|
||||
console.error("Usage: export-jsonl-to-md.js <input.jsonl|-> [output.md]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = inputArg === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(inputArg, "utf-8");
|
||||
const lines = raw.split("\n").filter(l => l.trim());
|
||||
|
||||
const blocks = [];
|
||||
for (const line of lines) {
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj.type !== "message" || !obj.message) continue;
|
||||
const role = obj.message.role;
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
let text = "";
|
||||
const content = obj.message.content;
|
||||
if (typeof content === "string") text = content;
|
||||
else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n");
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "user") {
|
||||
text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim();
|
||||
text = text.replace(/^\[.*?\]\s*/, "").trim();
|
||||
} else {
|
||||
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
const ts = obj.message.timestamp || obj.timestamp || 0;
|
||||
const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "";
|
||||
const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA";
|
||||
blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`);
|
||||
}
|
||||
|
||||
const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
const title = inputArg === "-" ? "Session" : inputArg.split("/").pop().replace(/\.jsonl.*/, "");
|
||||
const md = [
|
||||
`# Session: ${title}`,
|
||||
``,
|
||||
`Exportiert: ${exportedAt} `,
|
||||
`Quelle: ${inputArg === "-" ? "stdin" : inputArg}`,
|
||||
`Nachrichten: ${blocks.length}`,
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
blocks.join("\n\n---\n\n"),
|
||||
``,
|
||||
].join("\n");
|
||||
|
||||
if (outputArg) {
|
||||
fs.writeFileSync(outputArg, md);
|
||||
console.error(`OK: ${blocks.length} Nachrichten → ${outputArg}`);
|
||||
} else {
|
||||
process.stdout.write(md);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# ════════════════════════════════════════════════
|
||||
# ARIA XTTS v2 — Konfiguration
|
||||
# Kopieren nach .env und anpassen
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM)
|
||||
RVS_HOST=mobil.hacker-net.de
|
||||
RVS_PORT=444
|
||||
RVS_TLS=true
|
||||
RVS_TLS_FALLBACK=true
|
||||
RVS_TOKEN=dein_token_hier
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# HuggingFace Model-Cache (Whisper + F5-TTS, geteilt zwischen den
|
||||
# beiden Bridges via Bind-Mount, kann mehrere GB werden)
|
||||
hf-cache/
|
||||
|
||||
# Voice-Samples (lokal, gehoert nicht ins Repo)
|
||||
voices/
|
||||
|
||||
# Docker .env
|
||||
.env
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# ════════════════════════════════════════════════
|
||||
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
|
||||
# Laeuft auf dem Gaming-PC (RTX 3060)
|
||||
# Verbindet sich zum RVS fuer TTS/STT-Requests
|
||||
# ════════════════════════════════════════════════
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - Docker Desktop mit WSL2
|
||||
# - NVIDIA Container Toolkit
|
||||
# - .env mit RVS-Verbindungsdaten
|
||||
#
|
||||
# Start: docker compose up -d
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
services:
|
||||
|
||||
# ─── F5-TTS Bridge (GPU) ──────────────────────
|
||||
# Ersetzt den frueheren XTTS-Stack. Empfaengt xtts_request via RVS,
|
||||
# rendert via F5-TTS mit Voice-Cloning, streamt PCM an die App.
|
||||
# Voice-Upload: speichert WAV und laesst whisper-bridge den Referenz-
|
||||
# text transkribieren — der User muss nichts eintippen.
|
||||
f5tts-bridge:
|
||||
build: ./f5tts
|
||||
container_name: aria-f5tts-bridge
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./voices:/voices # WAV + TXT Referenz
|
||||
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
|
||||
# Direkt sichtbar im xtts/hf-cache/,
|
||||
# einfach manuell zu loeschen, kein
|
||||
# Docker-Desktop .vhdx Bloat.
|
||||
# Wird mit whisper-bridge geteilt.
|
||||
environment:
|
||||
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
|
||||
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
- RVS_PORT=${RVS_PORT:-443}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||
- RVS_TOKEN=${RVS_TOKEN}
|
||||
- F5TTS_DEVICE=${F5TTS_DEVICE:-cuda}
|
||||
- VOICES_DIR=/voices
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Whisper STT (GPU) ────────────────────────
|
||||
# Faster-Whisper auf der Gamebox statt auf der VM (CPU) —
|
||||
# deutlich schneller. Verbindet sich selbst per WebSocket an
|
||||
# den RVS und nimmt dort stt_request Nachrichten der aria-bridge
|
||||
# entgegen, antwortet mit stt_response. Zusaetzlich nutzt die
|
||||
# f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
|
||||
# Voice-Uploads. Laedt das Modell beim Start vor; auf Config-
|
||||
# Broadcasts (Diagnostic → whisperModel) wird zur Laufzeit hot-
|
||||
# swapped.
|
||||
whisper-bridge:
|
||||
build: ./whisper
|
||||
container_name: aria-whisper-bridge
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
- RVS_PORT=${RVS_PORT:-443}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||
- RVS_TOKEN=${RVS_TOKEN}
|
||||
- WHISPER_MODEL=${WHISPER_MODEL:-small}
|
||||
- WHISPER_DEVICE=${WHISPER_DEVICE:-cuda}
|
||||
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
|
||||
- WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de}
|
||||
volumes:
|
||||
- ./hf-cache:/root/.cache/huggingface # gleicher Cache wie f5tts-bridge —
|
||||
# ein Modell muss nur einmal pro
|
||||
# Maschine geladen werden, kein
|
||||
# Re-Download bei Container-Restart.
|
||||
restart: unless-stopped
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip ffmpeg git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch CUDA-Wheels zuerst (f5-tts zieht sonst CPU-only Torch rein)
|
||||
RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
|
||||
--index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY bridge.py .
|
||||
|
||||
CMD ["python3", "bridge.py"]
|
||||
|
|
@ -0,0 +1,856 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
ARIA F5-TTS Bridge — laeuft auf der Gamebox (RTX 3060).
|
||||
|
||||
Empfaengt xtts_request via RVS → F5-TTS Voice Cloning auf GPU → streamt
|
||||
16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App.
|
||||
|
||||
Voice-Layout im VOICES_DIR:
|
||||
{name}.wav — Referenz-Audio (6-10s, 24kHz mono empfohlen)
|
||||
{name}.txt — Referenz-Text (UTF-8, was im WAV gesprochen wird)
|
||||
|
||||
Beim voice_upload senden wir intern einen stt_request an die whisper-bridge
|
||||
und legen die Transkription als .txt ab — der User muss keinen Text eingeben.
|
||||
|
||||
Env:
|
||||
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
||||
F5TTS_MODEL Default: F5TTS_v1_Base
|
||||
F5TTS_DEVICE Default: cuda
|
||||
VOICES_DIR Default: /voices
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import websockets
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("f5tts-bridge")
|
||||
# HuggingFace + Torch download-Logs etwas daempfen
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
|
||||
RVS_HOST = os.getenv("RVS_HOST", "").strip()
|
||||
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
|
||||
# F5-TTS Konfiguration
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Defaults sind hard-coded — bewusst KEINE ENV-Vars (ausser F5TTS_DEVICE,
|
||||
# weil Hardware-Bootstrap). Alle Settings werden zur Laufzeit via RVS
|
||||
# config-Broadcast aus Diagnostic uebersteuert (Felder f5ttsModel,
|
||||
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
|
||||
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
|
||||
|
||||
DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base"
|
||||
DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF
|
||||
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
|
||||
# cfg_strength: wie stark der Generator am Referenz-Voice klebt.
|
||||
# Default F5-TTS = 2.0. Bei nicht-EN/CN Sprachen (Deutsch!) hilft 2.5+,
|
||||
# damit das Modell nicht in eine andere Sprache abrutscht.
|
||||
DEFAULT_F5TTS_CFG_STRENGTH = 2.5
|
||||
DEFAULT_F5TTS_NFE_STEP = 32
|
||||
|
||||
VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
|
||||
|
||||
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
|
||||
TARGET_SR = 24000 # F5-TTS native
|
||||
# F5-TTS hat ein 12s Hard-Limit fuer Referenz-Audio. Laengere WAVs werden
|
||||
# vom Modell stumm abgeschnitten — aber unser ref_text bleibt lang und passt
|
||||
# dann nicht mehr zum gekuerzten Audio (Quali leidet, warmup-Render ist
|
||||
# unnoetig lange). Wir clippen explizit auf 10s + re-transkribieren den Text
|
||||
# damit beide synchron bleiben.
|
||||
REF_MAX_SECONDS = 10.0
|
||||
|
||||
# Wird in einer Uebergangsphase als "ungueltige Referenz" erkannt (alte voices,
|
||||
# die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung
|
||||
# loeschen wir die .txt und ziehen den echten Text nach.
|
||||
_LEGACY_PLACEHOLDER_REF = "Das ist ein Referenz Audio."
|
||||
|
||||
# ── Lazy F5-TTS Loader ──────────────────────────────────────
|
||||
|
||||
_F5TTS_cls = None
|
||||
|
||||
|
||||
def _get_f5tts_cls():
|
||||
"""Lazy import damit Startup-Logs nicht durch Torch-Warnungen zumuellen."""
|
||||
global _F5TTS_cls
|
||||
if _F5TTS_cls is None:
|
||||
from f5_tts.api import F5TTS as _cls
|
||||
_F5TTS_cls = _cls
|
||||
return _F5TTS_cls
|
||||
|
||||
|
||||
def _resolve_hf_path(p: str) -> str:
|
||||
"""Wenn p mit 'hf://' anfaengt → aus HuggingFace Hub runterladen,
|
||||
lokalen Pfad zurueckgeben. Sonst unveraendert.
|
||||
|
||||
Format: hf://user/repo/path/to/file.ext
|
||||
Beispiel: hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors
|
||||
"""
|
||||
if not p or not p.startswith("hf://"):
|
||||
return p
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
rest = p[5:]
|
||||
parts = rest.split("/", 2)
|
||||
if len(parts) < 3:
|
||||
logger.warning("Ungueltiges hf:// Format: %s (erwarte hf://user/repo/path)", p)
|
||||
return p
|
||||
repo_id = f"{parts[0]}/{parts[1]}"
|
||||
filename = parts[2]
|
||||
logger.info("HF-Download: %s aus %s", filename, repo_id)
|
||||
local = hf_hub_download(repo_id=repo_id, filename=filename)
|
||||
logger.info("HF-Download fertig: %s", local)
|
||||
return local
|
||||
except Exception as e:
|
||||
logger.exception("HF-Download fehlgeschlagen fuer %s: %s", p, e)
|
||||
return p
|
||||
|
||||
|
||||
class F5Runner:
|
||||
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking).
|
||||
|
||||
Live-Settings (Modell, cfg_strength, nfe_step) werden ueber update_config()
|
||||
aus dem Diagnostic-Config-Broadcast gesetzt; bei Modell-Wechsel wird
|
||||
automatisch neu geladen.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.model = None
|
||||
self._lock = asyncio.Lock()
|
||||
# Aktuelle Werte — gestartet mit Hard-Defaults, ueberschrieben von Diagnostic
|
||||
self.model_id: str = DEFAULT_F5TTS_MODEL
|
||||
self.ckpt_file: str = DEFAULT_F5TTS_CKPT_FILE
|
||||
self.vocab_file: str = DEFAULT_F5TTS_VOCAB_FILE
|
||||
self.cfg_strength: float = DEFAULT_F5TTS_CFG_STRENGTH
|
||||
self.nfe_step: int = DEFAULT_F5TTS_NFE_STEP
|
||||
# Last load-time fuer service_status Broadcast
|
||||
self.last_load_seconds: float = 0.0
|
||||
self._load_started_at: float = 0.0
|
||||
|
||||
def _load_blocking(self) -> None:
|
||||
cls = _get_f5tts_cls()
|
||||
ckpt_resolved = _resolve_hf_path(self.ckpt_file) if self.ckpt_file else ""
|
||||
vocab_resolved = _resolve_hf_path(self.vocab_file) if self.vocab_file else ""
|
||||
logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...",
|
||||
self.model_id, F5TTS_DEVICE, ckpt_resolved or "default")
|
||||
self._load_started_at = time.time()
|
||||
kwargs = {"model": self.model_id, "device": F5TTS_DEVICE}
|
||||
if ckpt_resolved:
|
||||
kwargs["ckpt_file"] = ckpt_resolved
|
||||
if vocab_resolved:
|
||||
kwargs["vocab_file"] = vocab_resolved
|
||||
self.model = cls(**kwargs)
|
||||
elapsed = time.time() - self._load_started_at
|
||||
logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)",
|
||||
elapsed, self.cfg_strength, self.nfe_step)
|
||||
# Wird von outside (run_loop) gelesen um service_status auf 'ready' zu setzen
|
||||
self.last_load_seconds = elapsed
|
||||
|
||||
async def ensure_loaded(self) -> None:
|
||||
async with self._lock:
|
||||
if self.model is not None:
|
||||
return
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._load_blocking)
|
||||
|
||||
async def update_config(self, payload: dict) -> None:
|
||||
"""Liest f5tts*-Felder aus einem config-Broadcast.
|
||||
Bei Modell-relevantem Wechsel wird neu geladen.
|
||||
|
||||
Semantik:
|
||||
- key fehlt in payload → aktuellen Wert behalten
|
||||
- key da, nicht-leerer str → diesen Wert nehmen
|
||||
- key da, leerer string → RESET auf Hard-Default (User hat Feld
|
||||
in Diagnostic geleert und Apply geklickt)
|
||||
"""
|
||||
if "f5ttsModel" in payload:
|
||||
v = (payload.get("f5ttsModel") or "").strip()
|
||||
new_model = v if v else DEFAULT_F5TTS_MODEL
|
||||
else:
|
||||
new_model = self.model_id
|
||||
|
||||
if "f5ttsCkptFile" in payload:
|
||||
v = payload.get("f5ttsCkptFile") or ""
|
||||
new_ckpt = v.strip() if isinstance(v, str) else ""
|
||||
else:
|
||||
new_ckpt = self.ckpt_file
|
||||
|
||||
if "f5ttsVocabFile" in payload:
|
||||
v = payload.get("f5ttsVocabFile") or ""
|
||||
new_vocab = v.strip() if isinstance(v, str) else ""
|
||||
else:
|
||||
new_vocab = self.vocab_file
|
||||
try:
|
||||
new_cfg = float(payload.get("f5ttsCfgStrength", self.cfg_strength))
|
||||
except (TypeError, ValueError):
|
||||
new_cfg = self.cfg_strength
|
||||
try:
|
||||
new_nfe = int(payload.get("f5ttsNfeStep", self.nfe_step))
|
||||
except (TypeError, ValueError):
|
||||
new_nfe = self.nfe_step
|
||||
|
||||
# Settings die KEINEN Modell-Reload brauchen (zur naechsten Synthese aktiv)
|
||||
self.cfg_strength = new_cfg
|
||||
self.nfe_step = new_nfe
|
||||
|
||||
# Settings die einen Reload triggern
|
||||
model_changed = (new_model != self.model_id
|
||||
or new_ckpt != self.ckpt_file
|
||||
or new_vocab != self.vocab_file)
|
||||
if model_changed:
|
||||
logger.info("F5-TTS Config-Wechsel: model=%s ckpt=%s vocab=%s — Reload",
|
||||
new_model, new_ckpt or "default", new_vocab or "default")
|
||||
self.model_id = new_model
|
||||
self.ckpt_file = new_ckpt
|
||||
self.vocab_file = new_vocab
|
||||
async with self._lock:
|
||||
old = self.model
|
||||
self.model = None
|
||||
# Alte Instanz freigeben
|
||||
try:
|
||||
if old is not None:
|
||||
del old
|
||||
except Exception:
|
||||
pass
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._load_blocking)
|
||||
else:
|
||||
logger.info("F5-TTS Live-Config: cfg_strength=%.2f nfe=%d", new_cfg, new_nfe)
|
||||
|
||||
def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str,
|
||||
speed: float = 1.0) -> tuple[np.ndarray, int]:
|
||||
logger.info("infer() text=%d chars, speed=%.2f, cfg=%.2f, nfe=%d",
|
||||
len(gen_text), speed, self.cfg_strength, self.nfe_step)
|
||||
wav, sr, _ = self.model.infer(
|
||||
ref_file=ref_wav,
|
||||
ref_text=ref_text,
|
||||
gen_text=gen_text,
|
||||
remove_silence=True,
|
||||
seed=-1,
|
||||
cfg_strength=self.cfg_strength,
|
||||
nfe_step=self.nfe_step,
|
||||
speed=speed,
|
||||
)
|
||||
# F5-TTS gibt float32 1D-Array — auf 24kHz sample-rate standard
|
||||
if not isinstance(wav, np.ndarray):
|
||||
wav = np.asarray(wav, dtype=np.float32)
|
||||
if wav.ndim > 1:
|
||||
wav = wav.squeeze()
|
||||
return wav.astype(np.float32), int(sr)
|
||||
|
||||
async def synthesize(self, gen_text: str, ref_wav: str, ref_text: str,
|
||||
speed: float = 1.0) -> tuple[np.ndarray, int]:
|
||||
await self.ensure_loaded()
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._infer_blocking, gen_text, ref_wav, ref_text, speed)
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+")
|
||||
|
||||
|
||||
def split_sentences(text: str, max_len: int = 350) -> list[str]:
|
||||
"""Teilt langen Text an Satzgrenzen. Kurze Texte bleiben als-is."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= max_len:
|
||||
return [text]
|
||||
parts = [p.strip() for p in _SENTENCE_SPLIT.split(text) if p.strip()]
|
||||
# Zu kurze Fragmente mergen damit F5-TTS nicht an jedem Komma neu startet
|
||||
merged: list[str] = []
|
||||
buf = ""
|
||||
for p in parts:
|
||||
if len(buf) + len(p) + 1 <= max_len:
|
||||
buf = f"{buf} {p}".strip()
|
||||
else:
|
||||
if buf:
|
||||
merged.append(buf)
|
||||
buf = p
|
||||
if buf:
|
||||
merged.append(buf)
|
||||
return merged or [text]
|
||||
|
||||
|
||||
def float_to_pcm16(wav: np.ndarray) -> bytes:
|
||||
"""Float32 (-1..+1) → int16 little-endian bytes.
|
||||
|
||||
F5-TTS generiert gelegentlich NaN/Inf bei Instabilitaeten — ohne sanitize
|
||||
waere der Cast zu int16 undefiniert (RuntimeWarning + kaputter Sound).
|
||||
"""
|
||||
nan_count = int(np.isnan(wav).sum() + np.isinf(wav).sum())
|
||||
if nan_count > 0:
|
||||
logger.warning("F5-TTS Output enthaelt %d NaN/Inf samples — ersetze mit 0", nan_count)
|
||||
wav = np.nan_to_num(wav, nan=0.0, posinf=1.0, neginf=-1.0)
|
||||
wav = np.clip(wav, -1.0, 1.0)
|
||||
pcm = (wav * 32767.0).astype(np.int16)
|
||||
return pcm.tobytes()
|
||||
|
||||
|
||||
def sanitize_voice_name(name: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
||||
|
||||
|
||||
def voice_paths(name: str) -> tuple[Path, Path]:
|
||||
safe = sanitize_voice_name(name)
|
||||
return VOICES_DIR / f"{safe}.wav", VOICES_DIR / f"{safe}.txt"
|
||||
|
||||
|
||||
def normalize_ref_wav(src_wav: Path, max_seconds: float = REF_MAX_SECONDS) -> tuple[Path, bool]:
|
||||
"""Bringt die Referenz-WAV in F5-TTS-freundliche Form:
|
||||
|
||||
* 24kHz mono
|
||||
* max max_seconds Dauer
|
||||
* Stille am Anfang + Ende abgeschnitten (silenceremove-Filter)
|
||||
* Lautheit auf -16 LUFS normalisiert (loudnorm-Filter) damit
|
||||
das Modell konsistente Amplituden sieht
|
||||
|
||||
F5-TTS reagiert empfindlich auf leise / verrauschte / zerhackte
|
||||
Referenzen. Konsistente, saubere Input-Lautheit hilft der Quali.
|
||||
|
||||
Returns:
|
||||
(path, was_modified) — was_modified=True wenn die Datei wirklich
|
||||
geaendert wurde (Caller sollte dann den passenden .txt invalidieren).
|
||||
"""
|
||||
tmp_out = src_wav.with_suffix(".conv.wav")
|
||||
# silenceremove am Anfang: bis -50dB gesprochen wird
|
||||
# silenceremove am Ende: ueber -50dB rein, dann 0.5s stille als Cutoff
|
||||
# loudnorm: EBU R128, Ziel -16 LUFS
|
||||
af = ("silenceremove=start_periods=1:start_duration=0.05:start_threshold=-50dB,"
|
||||
"silenceremove=stop_periods=1:stop_duration=0.5:stop_threshold=-50dB,"
|
||||
"loudnorm=I=-16:TP=-1.5:LRA=11")
|
||||
cmd = ["ffmpeg", "-y", "-i", str(src_wav),
|
||||
"-af", af,
|
||||
"-ar", str(TARGET_SR), "-ac", "1",
|
||||
"-t", str(max_seconds),
|
||||
"-f", "wav", str(tmp_out)]
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
logger.warning("ffmpeg-Normalisierung von %s fehlgeschlagen: %s",
|
||||
src_wav, r.stderr.decode(errors="replace")[:300])
|
||||
try:
|
||||
tmp_out.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return src_wav, False
|
||||
os.replace(tmp_out, src_wav)
|
||||
try:
|
||||
info = sf.info(str(src_wav))
|
||||
logger.info("Referenz-WAV normalisiert: %s (%.1fs, %dHz mono, -16 LUFS, silence getrimmt)",
|
||||
src_wav.name, info.duration, info.samplerate)
|
||||
except Exception:
|
||||
logger.info("Referenz-WAV normalisiert: %s", src_wav.name)
|
||||
return src_wav, True
|
||||
|
||||
|
||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
"type": mtype,
|
||||
"payload": payload,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
||||
|
||||
|
||||
# ── Interne Transkription via whisper-bridge ────────────────
|
||||
|
||||
_pending_stt: dict[str, asyncio.Future] = {}
|
||||
_STT_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
async def request_transcription(ws, wav_path: Path, language: str = "de") -> Optional[str]:
|
||||
"""Sendet einen stt_request an die whisper-bridge (ueber RVS) und wartet auf stt_response."""
|
||||
try:
|
||||
with open(wav_path, "rb") as f:
|
||||
audio_b64 = base64.b64encode(f.read()).decode("ascii")
|
||||
except Exception as e:
|
||||
logger.error("Lesen %s fehlgeschlagen: %s", wav_path, e)
|
||||
return None
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
fut: asyncio.Future = loop.create_future()
|
||||
_pending_stt[request_id] = fut
|
||||
|
||||
try:
|
||||
await _send(ws, "stt_request", {
|
||||
"requestId": request_id,
|
||||
"audio": audio_b64,
|
||||
"mimeType": "audio/wav",
|
||||
# KEIN hardcoded model — whisper-bridge nimmt das bereits
|
||||
# geladene. Sonst wuerde hier ein Swap auf 'small' passieren und
|
||||
# danach muesste das in Diagnostic konfigurierte Modell (z.B.
|
||||
# large-v3) wieder geladen werden → doppelter Download.
|
||||
"language": language,
|
||||
})
|
||||
return await asyncio.wait_for(fut, timeout=_STT_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Transkription Timeout fuer %s", wav_path.name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Transkription Fehler: %s", e)
|
||||
return None
|
||||
finally:
|
||||
_pending_stt.pop(request_id, None)
|
||||
|
||||
|
||||
# ── TTS-Request Handler ─────────────────────────────────────
|
||||
|
||||
# Queue damit sich parallele Requests nicht ueberlappen (GPU-Throughput)
|
||||
_tts_queue: asyncio.Queue[tuple] = asyncio.Queue()
|
||||
|
||||
|
||||
async def _tts_worker(ws, runner: F5Runner) -> None:
|
||||
"""Serialisiert Synthesen — GPU kann sonst OOM gehen."""
|
||||
while True:
|
||||
text, voice, request_id, message_id, language, speed = await _tts_queue.get()
|
||||
try:
|
||||
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed)
|
||||
except Exception:
|
||||
logger.exception("TTS-Worker Fehler")
|
||||
finally:
|
||||
_tts_queue.task_done()
|
||||
|
||||
|
||||
async def _do_tts(ws, runner: F5Runner, text: str, voice: str,
|
||||
request_id: str, message_id: str, language: str,
|
||||
speed: float = 1.0) -> None:
|
||||
t0 = time.time()
|
||||
ref_wav_path, ref_txt_path = voice_paths(voice) if voice else (None, None)
|
||||
|
||||
# WAV zu lang? F5-TTS limitiert intern auf 12s, dann passt der txt nicht
|
||||
# mehr zum Audio. Wir clippen explizit auf 10s und invalidieren den txt,
|
||||
# damit er on-the-fly passend zum gekuerzten Audio neu transkribiert wird.
|
||||
if voice and ref_wav_path and ref_wav_path.exists():
|
||||
try:
|
||||
info = sf.info(str(ref_wav_path))
|
||||
if info.duration > REF_MAX_SECONDS + 0.5:
|
||||
logger.info("Voice '%s' WAV ist %.1fs (>%.0fs) → clippen + txt neu",
|
||||
voice, info.duration, REF_MAX_SECONDS)
|
||||
_, modified = normalize_ref_wav(ref_wav_path)
|
||||
if modified and ref_txt_path and ref_txt_path.exists():
|
||||
ref_txt_path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning("Konnte WAV-Dauer nicht pruefen: %s", e)
|
||||
|
||||
# Legacy-Platzhalter erkennen → behandeln als "kein txt" und neu transkribieren
|
||||
if voice and ref_txt_path and ref_txt_path.exists():
|
||||
try:
|
||||
existing = ref_txt_path.read_text(encoding="utf-8").strip()
|
||||
if existing == _LEGACY_PLACEHOLDER_REF or not existing:
|
||||
logger.info("Voice '%s' hat Legacy-Platzhalter → loesche, transkribiere neu", voice)
|
||||
ref_txt_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
has_custom = bool(voice and ref_wav_path and ref_wav_path.exists() and ref_txt_path.exists())
|
||||
if voice and not has_custom:
|
||||
# Wenn nur WAV da ist aber kein txt → on-the-fly transkribieren
|
||||
if ref_wav_path and ref_wav_path.exists() and (not ref_txt_path or not ref_txt_path.exists()):
|
||||
logger.info("Voice '%s' hat kein txt — transkribiere on-the-fly", voice)
|
||||
text_ref = await request_transcription(ws, ref_wav_path, language)
|
||||
if text_ref and text_ref.strip():
|
||||
try:
|
||||
ref_txt_path.write_text(text_ref.strip(), encoding="utf-8")
|
||||
has_custom = True
|
||||
logger.info("Referenz-Text nachgezogen: '%s'", text_ref[:60])
|
||||
except Exception as e:
|
||||
logger.warning("Referenz-Text speichern fehlgeschlagen: %s", e)
|
||||
if not has_custom:
|
||||
logger.warning("Voice '%s' nicht komplett (%s, txt=%s) — nehme Default",
|
||||
voice, ref_wav_path, (ref_txt_path and ref_txt_path.exists()))
|
||||
|
||||
if has_custom:
|
||||
ref_wav_str = str(ref_wav_path)
|
||||
ref_text = ref_txt_path.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
# Fallback: kein Custom-Voice. F5-TTS braucht IMMER eine Referenz,
|
||||
# wir nehmen default_ref.wav/txt falls vorhanden, sonst die erste
|
||||
# gefundene Voice im Ordner.
|
||||
default_wav = VOICES_DIR / "default_ref.wav"
|
||||
default_txt = VOICES_DIR / "default_ref.txt"
|
||||
if default_wav.exists() and default_txt.exists():
|
||||
ref_wav_str = str(default_wav)
|
||||
ref_text = default_txt.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
# Nimm irgendein vorhandenes voice-Paar
|
||||
pair = next(
|
||||
((w, t) for w, t in (
|
||||
(v, v.with_suffix(".txt")) for v in VOICES_DIR.glob("*.wav")
|
||||
) if t.exists()),
|
||||
None,
|
||||
)
|
||||
if not pair:
|
||||
logger.error("Keine Referenz-Stimme im VOICES_DIR — TTS abgebrochen")
|
||||
return
|
||||
ref_wav_str, ref_text = str(pair[0]), pair[1].read_text(encoding="utf-8").strip()
|
||||
|
||||
sentences = split_sentences(text)
|
||||
logger.info("F5-TTS: %d Satz(e), voice=%s, speed=%.2fx (%s)",
|
||||
len(sentences), voice or "default", speed, ref_wav_str)
|
||||
|
||||
chunk_index = 0
|
||||
pcm_sr = TARGET_SR
|
||||
for i, sent in enumerate(sentences):
|
||||
try:
|
||||
wav, sr = await runner.synthesize(sent, ref_wav_str, ref_text, speed)
|
||||
pcm_sr = sr
|
||||
pcm_bytes = float_to_pcm16(wav)
|
||||
# Erste PCM-Chunk des allerersten Satzes bekommt Fade-In (maskiert
|
||||
# eventuelle Warmup-Glitches). Alle anderen Chunks bleiben wie sind.
|
||||
if i == 0 and chunk_index == 0:
|
||||
pcm_bytes = _fade_in_pcm16(pcm_bytes, sr, 120)
|
||||
|
||||
# Stueckeln
|
||||
for off in range(0, len(pcm_bytes), PCM_CHUNK_BYTES):
|
||||
slice_ = pcm_bytes[off:off + PCM_CHUNK_BYTES]
|
||||
await _send(ws, "audio_pcm", {
|
||||
"requestId": request_id,
|
||||
"messageId": message_id,
|
||||
"base64": base64.b64encode(slice_).decode("ascii"),
|
||||
"format": "pcm_s16le",
|
||||
"sampleRate": sr,
|
||||
"channels": 1,
|
||||
"voice": voice or "default",
|
||||
"chunk": chunk_index,
|
||||
"final": False,
|
||||
})
|
||||
chunk_index += 1
|
||||
except Exception as e:
|
||||
logger.exception("F5-TTS Synthese-Fehler (Satz %d)", i)
|
||||
await _send(ws, "xtts_response", {
|
||||
"requestId": request_id,
|
||||
"error": str(e)[:200],
|
||||
})
|
||||
return
|
||||
|
||||
# Final-Marker
|
||||
await _send(ws, "audio_pcm", {
|
||||
"requestId": request_id,
|
||||
"messageId": message_id,
|
||||
"base64": "",
|
||||
"format": "pcm_s16le",
|
||||
"sampleRate": pcm_sr,
|
||||
"channels": 1,
|
||||
"voice": voice or "default",
|
||||
"chunk": chunk_index,
|
||||
"final": True,
|
||||
})
|
||||
|
||||
logger.info("TTS komplett: %d Chunks, %.2fs render (voice=%s, text=%d chars)",
|
||||
chunk_index, time.time() - t0, voice or "default", len(text))
|
||||
|
||||
|
||||
def _fade_in_pcm16(pcm: bytes, sr: int, fade_ms: int) -> bytes:
|
||||
"""Linear Fade-In auf erste fade_ms — maskiert Warmup-Glitches."""
|
||||
arr = np.frombuffer(pcm, dtype=np.int16).copy()
|
||||
fade_samples = min(int((fade_ms / 1000.0) * sr), len(arr))
|
||||
if fade_samples <= 0:
|
||||
return pcm
|
||||
ramp = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32)
|
||||
arr[:fade_samples] = (arr[:fade_samples].astype(np.float32) * ramp).astype(np.int16)
|
||||
return arr.tobytes()
|
||||
|
||||
|
||||
# ── Voice Management Handlers ───────────────────────────────
|
||||
|
||||
async def handle_voice_upload(ws, payload: dict) -> None:
|
||||
name = (payload.get("name") or "").strip()
|
||||
samples = payload.get("samples") or []
|
||||
if not name or not samples:
|
||||
logger.warning("voice_upload: ungueltig (name=%r, samples=%d)", name, len(samples))
|
||||
return
|
||||
logger.info("Voice-Upload: '%s' (%d Samples)", name, len(samples))
|
||||
|
||||
try:
|
||||
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
safe = sanitize_voice_name(name)
|
||||
wav_path = VOICES_DIR / f"{safe}.wav"
|
||||
txt_path = VOICES_DIR / f"{safe}.txt"
|
||||
|
||||
# Samples zusammenfuegen
|
||||
buffers = [base64.b64decode(s.get("base64", "")) for s in samples]
|
||||
with open(wav_path, "wb") as f:
|
||||
for b in buffers:
|
||||
f.write(b)
|
||||
size_kb = wav_path.stat().st_size / 1024
|
||||
logger.info("Voice WAV gespeichert: %s (%.0fKB)", wav_path, size_kb)
|
||||
|
||||
# Auf 24kHz mono clippen auf 10s (F5-TTS Hard-Limit ist 12s,
|
||||
# kuerzer = schnellerer Warmup + Text+Audio bleiben aligned)
|
||||
normalize_ref_wav(wav_path)
|
||||
|
||||
# Transkription ueber whisper-bridge anfragen
|
||||
logger.info("Transkribiere '%s' via whisper-bridge...", name)
|
||||
text = await request_transcription(ws, wav_path, language="de")
|
||||
if text and text.strip():
|
||||
txt_path.write_text(text.strip(), encoding="utf-8")
|
||||
logger.info("Voice '%s' komplett (txt: %s)", name, text[:80])
|
||||
ref_text_for_response = text.strip()
|
||||
else:
|
||||
# KEIN Platzhalter mehr schreiben! Beim ersten echten TTS-Use wird
|
||||
# on-the-fly nachtranskribiert. Wenn die whisper-bridge dann online
|
||||
# ist, klappt's — sonst koennte der User die .txt manuell anlegen.
|
||||
logger.warning("Voice '%s': Transkription fehlgeschlagen — .txt bleibt leer, "
|
||||
"wird on-the-fly bei erstem Render nachgezogen", name)
|
||||
ref_text_for_response = ""
|
||||
|
||||
await _send(ws, "xtts_voice_saved", {
|
||||
"name": name, "size": int(size_kb * 1024), "refText": ref_text_for_response,
|
||||
})
|
||||
# Liste aktualisieren
|
||||
await handle_list_voices(ws)
|
||||
except Exception as e:
|
||||
logger.exception("voice_upload Fehler")
|
||||
await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]})
|
||||
|
||||
|
||||
async def handle_list_voices(ws) -> None:
|
||||
try:
|
||||
voices = []
|
||||
if VOICES_DIR.exists():
|
||||
for wav in sorted(VOICES_DIR.glob("*.wav")):
|
||||
txt = wav.with_suffix(".txt")
|
||||
voices.append({
|
||||
"name": wav.stem,
|
||||
"file": wav.name,
|
||||
"size": wav.stat().st_size,
|
||||
"hasRefText": txt.exists(),
|
||||
})
|
||||
logger.info("Stimmen-Liste: %d", len(voices))
|
||||
await _send(ws, "xtts_voices_list", {"voices": voices})
|
||||
except Exception:
|
||||
logger.exception("handle_list_voices Fehler")
|
||||
|
||||
|
||||
async def handle_delete_voice(ws, payload: dict) -> None:
|
||||
name = (payload.get("name") or "").strip()
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
wav, txt = voice_paths(name)
|
||||
for p in (wav, txt):
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
logger.info("Voice geloescht: %s", p)
|
||||
await handle_list_voices(ws)
|
||||
except Exception:
|
||||
logger.exception("handle_delete_voice Fehler")
|
||||
|
||||
|
||||
# Letzte diagnostisch-gesetzte Voice (verhindert Endlos-Preload bei jedem config)
|
||||
_last_diag_voice = ""
|
||||
|
||||
|
||||
async def handle_voice_preload(ws, payload: dict, runner: F5Runner) -> None:
|
||||
voice = (payload.get("voice") or "").strip()
|
||||
request_id = payload.get("requestId", "")
|
||||
logger.info("Voice-Preload angefordert: '%s'", voice or "default")
|
||||
|
||||
try:
|
||||
ref_wav, ref_txt = voice_paths(voice) if voice else (None, None)
|
||||
if voice and (not ref_wav or not ref_wav.exists()):
|
||||
await _send(ws, "voice_ready", {"voice": voice, "requestId": request_id, "error": "voice-file-not-found"})
|
||||
return
|
||||
|
||||
# Ref-Text sicherstellen (falls nur WAV da ist)
|
||||
if voice and ref_txt and not ref_txt.exists():
|
||||
text = await request_transcription(ws, ref_wav, language="de")
|
||||
if text:
|
||||
ref_txt.write_text(text.strip(), encoding="utf-8")
|
||||
logger.info("Referenz-Text beim Preload nachgezogen")
|
||||
|
||||
# Dummy-Render zum Warmup
|
||||
t0 = time.time()
|
||||
await _do_tts(ws, runner, "ja.", voice, f"preload-{request_id}", "", "de")
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
await _send(ws, "voice_ready", {"voice": voice, "requestId": request_id, "loadMs": ms})
|
||||
except Exception as e:
|
||||
logger.exception("Voice-Preload Fehler")
|
||||
await _send(ws, "voice_ready", {"voice": voice, "requestId": request_id, "error": str(e)[:200]})
|
||||
|
||||
|
||||
# ── Haupt-Loop ──────────────────────────────────────────────
|
||||
|
||||
async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
"""Sendet service_status fuer das F5-TTS Modul.
|
||||
state: 'loading' | 'ready' | 'error'."""
|
||||
payload = {"service": "f5tts", "state": state}
|
||||
payload.update(extra)
|
||||
await _send(ws, "service_status", payload)
|
||||
|
||||
|
||||
async def run_loop(runner: F5Runner) -> None:
|
||||
use_tls = RVS_TLS
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
global _last_diag_voice
|
||||
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s", masked)
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
|
||||
# Status-Broadcast: erst loading, dann ready nach erfolgreichem Load.
|
||||
# Plus: config_request damit wir die persistierte Diagnostic-Config
|
||||
# bekommen, falls aria-bridge ihre nicht von alleine sendet.
|
||||
async def _load_with_status():
|
||||
try:
|
||||
if runner.model is not None:
|
||||
logger.info("Initial: broadcaste ready (Modell schon im RAM: %s)", runner.model_id)
|
||||
await _broadcast_status(ws, "ready",
|
||||
model=runner.model_id,
|
||||
loadSeconds=runner.last_load_seconds)
|
||||
else:
|
||||
logger.info("Initial: broadcaste loading + lade Modell '%s'", runner.model_id)
|
||||
await _broadcast_status(ws, "loading", model=runner.model_id)
|
||||
await runner.ensure_loaded()
|
||||
await _broadcast_status(ws, "ready",
|
||||
model=runner.model_id,
|
||||
loadSeconds=runner.last_load_seconds)
|
||||
logger.info("Initial: sende config_request an aria-bridge")
|
||||
await _send(ws, "config_request", {"service": "f5tts"})
|
||||
except Exception as e:
|
||||
logger.exception("Initial-Load crashed: %s", e)
|
||||
try:
|
||||
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.create_task(_load_with_status())
|
||||
|
||||
# TTS-Worker fuer diese Verbindung starten
|
||||
worker = asyncio.create_task(_tts_worker(ws, runner))
|
||||
|
||||
try:
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type", "")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
|
||||
if mtype == "xtts_request":
|
||||
try:
|
||||
speed = float(payload.get("speed") or 1.0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 1.0
|
||||
if not (0.1 <= speed <= 5.0):
|
||||
speed = 1.0
|
||||
await _tts_queue.put((
|
||||
payload.get("text", ""),
|
||||
payload.get("voice", "") or "",
|
||||
payload.get("requestId", ""),
|
||||
payload.get("messageId", ""),
|
||||
payload.get("language", "de"),
|
||||
speed,
|
||||
))
|
||||
elif mtype == "voice_upload":
|
||||
asyncio.create_task(handle_voice_upload(ws, payload))
|
||||
elif mtype == "xtts_list_voices":
|
||||
asyncio.create_task(handle_list_voices(ws))
|
||||
elif mtype == "xtts_delete_voice":
|
||||
asyncio.create_task(handle_delete_voice(ws, payload))
|
||||
elif mtype == "voice_preload":
|
||||
asyncio.create_task(handle_voice_preload(ws, payload, runner))
|
||||
elif mtype == "stt_response":
|
||||
# Antwort auf unseren internen Transkriptions-Request
|
||||
req_id = payload.get("requestId", "")
|
||||
fut = _pending_stt.get(req_id)
|
||||
if fut and not fut.done():
|
||||
if payload.get("error"):
|
||||
fut.set_result(None)
|
||||
else:
|
||||
fut.set_result(payload.get("text") or "")
|
||||
elif mtype == "config":
|
||||
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
|
||||
async def _update_with_status(p):
|
||||
# Schaut ob ein Modell-Wechsel ansteht — falls ja:
|
||||
# erst loading-Status, dann update, dann ready.
|
||||
old_model = (runner.model_id, runner.ckpt_file, runner.vocab_file)
|
||||
new_model_id = (p.get("f5ttsModel") or runner.model_id,
|
||||
p.get("f5ttsCkptFile", runner.ckpt_file) or "",
|
||||
p.get("f5ttsVocabFile", runner.vocab_file) or "")
|
||||
will_reload = old_model != new_model_id
|
||||
if will_reload:
|
||||
await _broadcast_status(ws, "loading", model=new_model_id[0])
|
||||
try:
|
||||
await runner.update_config(p)
|
||||
if will_reload:
|
||||
await _broadcast_status(ws, "ready",
|
||||
model=runner.model_id,
|
||||
loadSeconds=runner.last_load_seconds)
|
||||
except Exception as e:
|
||||
if will_reload:
|
||||
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||
asyncio.create_task(_update_with_status(payload))
|
||||
# Voice-Preload bei Wechsel
|
||||
v = (payload.get("xttsVoice") or "").strip()
|
||||
if v and v != _last_diag_voice:
|
||||
_last_diag_voice = v
|
||||
asyncio.create_task(handle_voice_preload(
|
||||
ws, {"voice": v, "source": "diagnostic"}, runner,
|
||||
))
|
||||
elif not v:
|
||||
_last_diag_voice = ""
|
||||
finally:
|
||||
worker.cancel()
|
||||
try:
|
||||
await worker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Verbindung verloren: %s", e)
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
|
||||
use_tls = False
|
||||
tls_fallback_tried = True
|
||||
continue
|
||||
await asyncio.sleep(min(retry_s, 30))
|
||||
retry_s = min(retry_s * 2, 30)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if not RVS_HOST:
|
||||
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
||||
sys.exit(1)
|
||||
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
runner = F5Runner()
|
||||
await run_loop(runner)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
f5-tts>=1.0.0
|
||||
websockets>=12.0
|
||||
numpy>=1.24
|
||||
soundfile>=0.12
|
||||
requests>=2.31
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY bridge.py .
|
||||
|
||||
CMD ["python3", "bridge.py"]
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
ARIA Whisper Bridge — laeuft auf der Gamebox (RTX 3060).
|
||||
|
||||
Empfaengt stt_request via RVS → FFmpeg-Konvertierung → faster-whisper auf GPU
|
||||
→ sendet stt_response zurueck an die aria-bridge.
|
||||
|
||||
Env:
|
||||
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
||||
WHISPER_MODEL Default: small
|
||||
WHISPER_DEVICE Default: cuda
|
||||
WHISPER_COMPUTE_TYPE Default: float16
|
||||
WHISPER_LANGUAGE Default: de
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import websockets
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("whisper-bridge")
|
||||
|
||||
RVS_HOST = os.getenv("RVS_HOST", "").strip()
|
||||
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
|
||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
||||
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
|
||||
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
|
||||
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
||||
|
||||
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
|
||||
|
||||
|
||||
class WhisperRunner:
|
||||
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.model_size: str = WHISPER_MODEL
|
||||
self.model: Optional[WhisperModel] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _load_blocking(self, size: str) -> None:
|
||||
logger.info(
|
||||
"Lade Whisper '%s' (device=%s, compute=%s)",
|
||||
size, WHISPER_DEVICE, WHISPER_COMPUTE_TYPE,
|
||||
)
|
||||
t0 = time.time()
|
||||
self.model = WhisperModel(
|
||||
size, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE_TYPE,
|
||||
)
|
||||
self.model_size = size
|
||||
logger.info("Whisper '%s' geladen in %.1fs", size, time.time() - t0)
|
||||
|
||||
async def ensure_loaded(self, desired_size: str) -> None:
|
||||
if desired_size not in ALLOWED_MODELS:
|
||||
logger.warning("Ungueltiges Whisper-Modell '%s' — nutze %s", desired_size, WHISPER_MODEL)
|
||||
desired_size = WHISPER_MODEL
|
||||
async with self._lock:
|
||||
if self.model is not None and self.model_size == desired_size:
|
||||
return
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._load_blocking, desired_size)
|
||||
|
||||
async def transcribe(self, audio: np.ndarray, language: str) -> tuple[str, float]:
|
||||
if self.model is None:
|
||||
return "", 0.0
|
||||
|
||||
def _run():
|
||||
segments, info = self.model.transcribe(
|
||||
audio, language=language, beam_size=5, vad_filter=True,
|
||||
)
|
||||
text = " ".join(seg.text.strip() for seg in segments)
|
||||
return text, info.duration
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _run)
|
||||
|
||||
|
||||
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
|
||||
"""Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM."""
|
||||
if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type:
|
||||
ext = ".mp4"
|
||||
elif "wav" in mime_type:
|
||||
ext = ".wav"
|
||||
elif "ogg" in mime_type or "opus" in mime_type:
|
||||
ext = ".ogg"
|
||||
else:
|
||||
ext = ".bin"
|
||||
|
||||
in_fh = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
|
||||
try:
|
||||
in_fh.write(base64.b64decode(audio_b64))
|
||||
in_fh.close()
|
||||
out_path = in_fh.name + ".raw"
|
||||
cmd = ["ffmpeg", "-y", "-i", in_fh.name, "-ar", "16000", "-ac", "1", "-f", "f32le", out_path]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
logger.error("FFmpeg Fehler: %s", result.stderr.decode(errors="replace")[:300])
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
try:
|
||||
return np.fromfile(out_path, dtype=np.float32)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.unlink(in_fh.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
"type": mtype,
|
||||
"payload": payload,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
||||
|
||||
|
||||
async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
|
||||
request_id = payload.get("requestId", "")
|
||||
audio_b64 = payload.get("audio", "")
|
||||
mime_type = payload.get("mimeType", "audio/mp4")
|
||||
# Modell-Auswahl:
|
||||
# payload.model gesetzt → nimm das (aria-bridge sendet's basierend auf Config)
|
||||
# sonst + Modell geladen → behalt das aktuelle (kein sinnloser Swap)
|
||||
# sonst → fallback auf ENV-Default
|
||||
model = payload.get("model") or (runner.model_size if runner.model is not None else WHISPER_MODEL)
|
||||
language = payload.get("language") or WHISPER_LANGUAGE
|
||||
|
||||
if not audio_b64:
|
||||
await _send(ws, "stt_response", {"requestId": request_id, "error": "no-audio"})
|
||||
return
|
||||
|
||||
try:
|
||||
t_load = time.time()
|
||||
# Falls Modell noch nicht geladen (Race-Condition: stt_request vor config)
|
||||
# → Status-Broadcast loading→ready damit der App-Banner aufpoppt
|
||||
needs_load = runner.model is None or runner.model_size != model
|
||||
if needs_load:
|
||||
await _broadcast_status(ws, "loading", model=model)
|
||||
await runner.ensure_loaded(model)
|
||||
load_ms = int((time.time() - t_load) * 1000)
|
||||
if needs_load:
|
||||
await _broadcast_status(ws, "ready",
|
||||
model=runner.model_size,
|
||||
loadSeconds=load_ms / 1000.0)
|
||||
|
||||
audio = ffmpeg_to_float32(audio_b64, mime_type)
|
||||
if audio.size == 0:
|
||||
await _send(ws, "stt_response", {"requestId": request_id, "error": "ffmpeg-failed"})
|
||||
return
|
||||
duration_s = len(audio) / 16000.0
|
||||
logger.info("STT-Request: %.1fs Audio, model=%s, lang=%s", duration_s, runner.model_size, language)
|
||||
|
||||
t_stt = time.time()
|
||||
text, detected_duration = await runner.transcribe(audio, language)
|
||||
stt_ms = int((time.time() - t_stt) * 1000)
|
||||
|
||||
logger.info("STT-Ergebnis (%dms): '%s'", stt_ms, text[:100])
|
||||
|
||||
await _send(ws, "stt_response", {
|
||||
"requestId": request_id,
|
||||
"text": text.strip(),
|
||||
"durationS": duration_s,
|
||||
"sttMs": stt_ms,
|
||||
"loadMs": load_ms,
|
||||
"model": runner.model_size,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("STT-Request fehlgeschlagen")
|
||||
await _send(ws, "stt_response", {
|
||||
"requestId": request_id,
|
||||
"error": str(e)[:200],
|
||||
})
|
||||
|
||||
|
||||
async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
"""Sendet service_status fuer das Whisper-Modul.
|
||||
state: 'loading' | 'ready' | 'error'."""
|
||||
payload = {"service": "whisper", "state": state}
|
||||
payload.update(extra)
|
||||
await _send(ws, "service_status", payload)
|
||||
|
||||
|
||||
async def run_loop(runner: WhisperRunner) -> None:
|
||||
use_tls = RVS_TLS
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s", masked)
|
||||
# max_size 50MB damit grosse stt_request (Voice-Cloning-WAVs als
|
||||
# base64 koennen mehrere MB werden) nicht das Frame-Limit sprengen
|
||||
# und die Verbindung mit 1009 'message too big' killen.
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
|
||||
# Initialer Status-Broadcast — uebertont alten "ready"-State
|
||||
# im App/Diagnostic Banner (sonst denkt der User noch alles ist
|
||||
# gut von vorher). Wenn Modell schon geladen → ready, sonst
|
||||
# loading mit aktuellem (Default-)Namen.
|
||||
# Plus: config_request an aria-bridge — wir wissen nicht ob
|
||||
# sie auch grad reconnected hat oder schon laenger online ist.
|
||||
async def _initial_handshake():
|
||||
try:
|
||||
if runner.model is not None:
|
||||
logger.info("Initial: broadcaste ready (Modell schon im RAM: %s)", runner.model_size)
|
||||
await _broadcast_status(ws, "ready", model=runner.model_size)
|
||||
else:
|
||||
init_model = runner.model_size or WHISPER_MODEL
|
||||
logger.info("Initial: broadcaste loading (model=%s)", init_model)
|
||||
await _broadcast_status(ws, "loading", model=init_model)
|
||||
logger.info("Initial: sende config_request an aria-bridge")
|
||||
await _send(ws, "config_request", {"service": "whisper"})
|
||||
except Exception as e:
|
||||
logger.exception("Initial-Handshake crashed: %s", e)
|
||||
asyncio.create_task(_initial_handshake())
|
||||
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type", "")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
|
||||
if mtype == "stt_request":
|
||||
req_id = payload.get("requestId", "?")
|
||||
audio_len = len(payload.get("audio", ""))
|
||||
logger.info("stt_request empfangen (id=%s, %dKB Audio)",
|
||||
req_id[:8] if req_id != "?" else "?", audio_len // 1365)
|
||||
asyncio.create_task(handle_stt_request(ws, payload, runner))
|
||||
elif mtype == "config":
|
||||
new_model = payload.get("whisperModel") or WHISPER_MODEL
|
||||
# Laden wenn (a) noch nix geladen, oder (b) Modell wechselt
|
||||
needs_load = (runner.model is None) or (new_model != runner.model_size)
|
||||
if needs_load:
|
||||
logger.info("Config-Broadcast: Whisper-Modell -> %s%s",
|
||||
new_model,
|
||||
" (initial)" if runner.model is None else " (Wechsel)")
|
||||
async def _swap_with_status(target):
|
||||
await _broadcast_status(ws, "loading", model=target)
|
||||
try:
|
||||
t0 = time.time()
|
||||
await runner.ensure_loaded(target)
|
||||
elapsed = time.time() - t0
|
||||
await _broadcast_status(ws, "ready",
|
||||
model=runner.model_size,
|
||||
loadSeconds=elapsed)
|
||||
except Exception as e:
|
||||
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||
asyncio.create_task(_swap_with_status(new_model))
|
||||
else:
|
||||
# Alle anderen Nachrichten debug-loggen — hilft beim Diagnostizieren,
|
||||
# ob stt_request ueberhaupt durch den RVS kommt
|
||||
logger.debug("Unbeachteter Type: %s", mtype)
|
||||
except Exception as e:
|
||||
logger.warning("Verbindung verloren: %s", e)
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
|
||||
use_tls = False
|
||||
tls_fallback_tried = True
|
||||
continue
|
||||
await asyncio.sleep(min(retry_s, 30))
|
||||
retry_s = min(retry_s * 2, 30)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if not RVS_HOST:
|
||||
logger.error("RVS_HOST ist nicht gesetzt — Abbruch")
|
||||
sys.exit(1)
|
||||
runner = WhisperRunner()
|
||||
await run_loop(runner)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
faster-whisper==1.0.3
|
||||
websockets>=12.0
|
||||
numpy>=1.24
|
||||
requests>=2.31
|
||||
Loading…
Reference in New Issue