Compare commits

..

60 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:45 +02:00
duffyduck 7ed70b876d updated image public path 2026-04-07 23:06:26 +02:00
duffyduck 3ca85da906 release: bump version to 0.0.2.5 2026-04-05 20:12:56 +02:00
22 changed files with 814 additions and 249 deletions
+37 -7
View File
@@ -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=
+69 -14
View File
@@ -103,16 +103,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
@@ -291,7 +306,8 @@ aria-core → Antwort → Gateway → Diagnostic → RVS → App
### Features
- **STT**: faster-whisper (lokal, offline, 16kHz mono)
- **TTS**: Piper (Ramona + Thorsten, offline)
- **TTS**: Piper (Ramona + Thorsten, offline) oder XTTS v2 (remote, GPU, Voice Cloning)
- **Markdown-Bereinigung**: Entfernt **fett**, *kursiv*, `code`, Links, Listen etc. vor TTS (natuerliche Sprache)
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM)
- **App-Audio**: Base64 Audio von App → FFmpeg → Whisper STT → Text an aria-core
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
@@ -352,15 +368,17 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- Text-Chat mit ARIA
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her, ohne Buttons druecken
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
- **STT (Speech-to-Text)**: Audio wird in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Piper oder XTTS v2)
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Piper oder XTTS v2), Audio-Queue mit Preloading
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
- **Datei- und Bild-Upload**: Bilder inline im Chat (Vollbild-Tap), Dateien mit Icon + Name + Groesse
- **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 Engine, Stimmen, Speed pro Stimme, Speicherort, Auto-Download, GPS
- **Auto-Update**: Prueft beim Start auf neue Version, Download + Installation ueber RVS
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
- GPS-Position (optional)
- QR-Code Scanner fuer Token-Pairing
@@ -530,38 +548,68 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
## XTTS v2 — GPU TTS Server (optional)
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060).
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig.
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
ueber verschiedene Netze hinweg.
### Architektur
```
Gaming-PC (Windows, RTX 3060, Docker Desktop + WSL2)
├── aria-xtts XTTS v2 GPU Server (Port 8020 intern)
└── aria-xtts-bridge RVS-Relay (empfaengt Requests, sendet Audio)
└── Beide teilen ./voices/ Volume fuer Voice Cloning
↕ RVS (Rechenzentrum, WebSocket Relay)
ARIA-VM
└── aria-bridge: tts_engine="xtts" → xtts_request via RVS → wartet auf xtts_response
```
### Voraussetzungen
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
- NVIDIA Container Toolkit
- GPU mit mindestens 4GB VRAM (6GB+ empfohlen)
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
### Setup
```bash
cd xtts
cp .env.example .env
# .env mit RVS-Verbindungsdaten fuellen (gleiche wie auf der ARIA-VM)
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
docker compose up -d
# Erster Start laedt ~2GB Model herunter
# Erster Start laedt ~2GB Model herunter (danach gecacht)
```
**Wichtig:** Der XTTS-Server laeuft intern auf Port **8020** (nicht 8000).
Das Model wird im Volume `xtts-models` gecacht und muss nur einmal geladen werden.
### Features
- **Natuerliche Stimmen**: Deutlich bessere Qualitaet als Piper
- **Voice Cloning**: Eigene Stimme mit 6-10s Audio-Sample
- **Voice Cloning**: Eigene Stimme mit 6-10s Audio-Sample (~2s Latenz auf RTX 3060)
- **16 Sprachen**: Deutsch, Englisch, Franzoesisch, etc.
- **RVS-Integration**: Bridge waehlt automatisch XTTS wenn verfuegbar
- **Fallback**: Wenn XTTS nicht erreichbar, nutzt die Bridge automatisch Piper
### TTS-Engine umschalten
In der Diagnostic unter Einstellungen → Sprachausgabe:
- **TTS aktiv**: Global An/Aus
- **TTS Engine**: Piper (lokal, CPU, schnell) oder XTTS v2 (remote, GPU, natuerlich)
- **Piper**: Standard-Stimme, Highlight-Stimme, Speed pro Stimme
- **XTTS**: Stimmen-Auswahl, Voice Cloning
### Stimme klonen
In der Diagnostic unter Einstellungen → Sprachausgabe → XTTS:
1. TTS Engine auf "XTTS v2" stellen
2. "Stimme klonen" → Audio-Dateien hochladen (WAV/MP3, min. 6-10s)
2. "Stimme klonen" → Audio-Dateien hochladen (WAV/MP3, 1-10 Dateien, min. 6-10s gesamt)
3. Name vergeben → "Stimme erstellen"
4. Neue Stimme in der Auswahl verfuegbar
4. "Laden" klicken → neue Stimme in der Auswahl
5. Stimme auswaehlen → Config wird automatisch gespeichert
> **Tipp:** Fuer beste Ergebnisse: saubere Aufnahme, eine Stimme, kein Hintergrund,
> 10-30 Sekunden Gesamtlaenge. Mehrere kurze Dateien werden zusammengefuegt.
---
@@ -633,6 +681,8 @@ docker exec aria-core ssh aria-wohnung hostname
- **Wake Word nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM.
In der App gibt es Energy-basierte Erkennung (Phase 1). On-device "ARIA"-Keyword (Porcupine) ist Phase 2.
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
- **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.
@@ -662,6 +712,11 @@ docker exec aria-core ssh aria-wohnung hostname
- [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)
### Phase 2 — ARIA wird produktiv
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 204
versionName "0.0.2.4"
versionCode 307
versionName "0.0.3.7"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -3,6 +3,7 @@
<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" />
<application
android:name=".MainApplication"
@@ -24,5 +25,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>
@@ -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()
}
}
@@ -18,8 +18,7 @@ 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())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
</paths>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.2.4",
"version": "0.0.3.7",
"private": true,
"scripts": {
"android": "react-native run-android",
+161 -93
View File
@@ -5,7 +5,7 @@
* Datei- und Kamera-Upload.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
View,
Text,
@@ -16,6 +16,7 @@ import {
Platform,
StyleSheet,
Image,
ScrollView,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -94,6 +95,7 @@ const ChatScreen: React.FC = () => {
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
@@ -273,12 +275,20 @@ const ChatScreen: React.FC = () => {
return () => { unsubUpdate(); clearTimeout(timer); };
}, []);
// Wake Word: "ARIA" Erkennung → Auto-Aufnahme starten
// Gespraechsmodus: Nach TTS-Wiedergabe automatisch Aufnahme starten
useEffect(() => {
const unsubPlayback = audioService.onPlaybackFinished(() => {
if (wakeWordService.isActive()) {
wakeWordService.resume();
}
});
return () => unsubPlayback();
}, []);
// Wake Word / Gespraechsmodus: Auto-Aufnahme starten
useEffect(() => {
const unsubWake = wakeWordService.onWakeWord(async () => {
console.log('[Chat] Wake Word erkannt — starte Auto-Aufnahme');
// TTS stoppen damit ARIA sich nicht selbst hoert
audioService.stopPlayback();
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
// Aufnahme mit Auto-Stop (VAD) starten
const started = await audioService.startRecording(true);
if (!started) {
@@ -359,22 +369,8 @@ const ChatScreen: React.FC = () => {
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
}, [messages]);
// Auto-Scroll wird ueber onContentSizeChange der FlatList gesteuert
const shouldAutoScroll = useRef(true);
const handleContentSizeChange = useCallback(() => {
if (shouldAutoScroll.current) {
flatListRef.current?.scrollToEnd({ animated: false });
}
}, []);
const handleScrollBeginDrag = useCallback(() => {
shouldAutoScroll.current = false;
}, []);
const handleScrollEndDrag = useCallback((e: any) => {
// Auto-Scroll wieder aktivieren wenn User ganz unten ist
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const isAtBottom = contentOffset.y + layoutMeasurement.height >= contentSize.height - 50;
shouldAutoScroll.current = isAtBottom;
}, []);
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);
// GPS-Position holen (optional)
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
@@ -400,6 +396,13 @@ const ChatScreen: React.FC = () => {
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
if (!text) return;
setInputText('');
@@ -419,7 +422,7 @@ const ChatScreen: React.FC = () => {
text,
...(location && { location }),
});
}, [inputText, getCurrentLocation]);
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments]);
// Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
@@ -441,88 +444,91 @@ const ChatScreen: React.FC = () => {
});
}, [getCurrentLocation]);
// Datei senden
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
setShowFileUpload(false);
const location = await getCurrentLocation();
setPendingAttachments(prev => [...prev, { file, isPhoto: false }]);
}, []);
const isImage = file.type.startsWith('image/');
const msgId = nextId();
let imageUri = isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri;
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: 'Anhang empfangen',
timestamp: Date.now(),
attachments: [{
type: isImage ? 'image' : 'file',
name: file.name,
size: file.size,
uri: imageUri,
mimeType: file.type,
}],
};
setMessages(prev => [...prev, userMsg]);
// Anhang auf Disk speichern fuer Persistenz
if (file.base64) {
persistAttachment(file.base64, msgId, file.name).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
));
}).catch(() => {});
}
rvs.send('file', {
name: file.name,
type: file.type,
size: file.size,
base64: file.base64,
...(location && { location }),
});
}, [getCurrentLocation]);
// Foto senden
// Foto auswaehlen → zur Pending-Liste hinzufuegen
const handlePhotoSelected = useCallback(async (photo: PhotoData) => {
setShowCameraUpload(false);
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
}, []);
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
const location = await getCurrentLocation();
const msgId = nextId();
const dataUri = photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined;
// Alle Attachments fuer die Chat-Nachricht sammeln
const attachments: Attachment[] = [];
for (const { file, isPhoto } of pendingAttachments) {
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri;
attachments.push({
type: isImage ? 'image' : 'file',
name,
size: file.size,
uri: imageUri,
mimeType,
});
}
// Chat-Nachricht mit allen Anhaengen
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: 'Anhang empfangen',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
timestamp: Date.now(),
attachments: [{
type: 'image',
name: photo.fileName,
uri: dataUri,
mimeType: photo.type,
}],
attachments,
};
setMessages(prev => [...prev, userMsg]);
// Foto auf Disk speichern fuer Persistenz
if (photo.base64) {
persistAttachment(photo.base64, msgId, photo.fileName).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
));
}).catch(() => {});
// Alle Dateien an RVS senden + auf Disk speichern
for (const { file, isPhoto } of pendingAttachments) {
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
// Auf Disk speichern
if (base64) {
persistAttachment(base64, msgId + '_' + name, name).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a =>
a.name === name && !a.uri?.startsWith('file://') ? { ...a, uri: filePath } : a
)} : m
));
}).catch(() => {});
}
// An RVS senden
rvs.send('file', {
name,
type: mimeType,
size: file.size,
base64,
...(isPhoto && file.width && { width: file.width, height: file.height }),
...(location && { location }),
});
}
rvs.send('file', {
name: photo.fileName,
type: photo.type,
base64: photo.base64,
width: photo.width,
height: photo.height,
...(location && { location }),
});
}, [getCurrentLocation]);
// Text als separate Nachricht (damit ARIA weiss was zu tun ist)
if (messageText) {
rvs.send('chat', {
text: messageText,
...(location && { location }),
});
}
setPendingAttachments([]);
setInputText('');
}, [pendingAttachments, getCurrentLocation]);
// --- Rendering ---
@@ -653,14 +659,12 @@ const ChatScreen: React.FC = () => {
{/* Nachrichtenliste */}
<FlatList
ref={flatListRef}
data={searchQuery ? messages.filter(m => m.text.toLowerCase().includes(searchQuery.toLowerCase())) : messages}
inverted
data={searchQuery ? messages.filter(m => m.text.toLowerCase().includes(searchQuery.toLowerCase())).reverse() : invertedMessages}
keyExtractor={item => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false}
onContentSizeChange={handleContentSizeChange}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
@@ -670,6 +674,40 @@ const ChatScreen: React.FC = () => {
}
/>
{/* Pending Anhaenge Vorschau */}
{pendingAttachments.length > 0 && (
<View style={styles.pendingBar}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{flex: 1}}>
{pendingAttachments.map((att, idx) => (
<View key={idx} style={styles.pendingItem}>
{att.file.type?.startsWith('image/') || att.isPhoto ? (
<Image
source={{ uri: att.file.base64
? `data:${att.file.type};base64,${att.file.base64}`
: att.file.uri }}
style={styles.pendingThumb}
/>
) : (
<View style={[styles.pendingThumb, {justifyContent: 'center', alignItems: 'center'}]}>
<Text style={{fontSize: 20}}>{'\uD83D\uDCC4'}</Text>
</View>
)}
<TouchableOpacity
style={styles.pendingRemove}
onPress={() => setPendingAttachments(prev => prev.filter((_, i) => i !== idx))}
>
<Text style={{color: '#fff', fontSize: 10, fontWeight: 'bold'}}>X</Text>
</TouchableOpacity>
</View>
))}
</ScrollView>
<Text style={{color: '#8888AA', fontSize: 11, marginLeft: 8}}>{pendingAttachments.length}</Text>
<TouchableOpacity onPress={() => setPendingAttachments([])}>
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>Alle X</Text>
</TouchableOpacity>
</View>
)}
{/* Eingabebereich */}
<View style={styles.inputContainer}>
{/* Datei-Buttons */}
@@ -692,7 +730,7 @@ const ChatScreen: React.FC = () => {
style={styles.textInput}
value={inputText}
onChangeText={setInputText}
placeholder="Nachricht an ARIA..."
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
placeholderTextColor="#555570"
multiline
maxLength={4000}
@@ -701,7 +739,7 @@ const ChatScreen: React.FC = () => {
/>
{/* Senden oder Sprache */}
{inputText.trim() ? (
{inputText.trim() || pendingAttachments.length > 0 ? (
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity>
@@ -932,6 +970,36 @@ const styles = StyleSheet.create({
wakeWordIcon: {
fontSize: 16,
},
pendingBar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E1E2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: '#2A2A3E',
},
pendingItem: {
position: 'relative',
marginRight: 8,
},
pendingThumb: {
width: 50,
height: 50,
borderRadius: 6,
backgroundColor: '#0D0D1A',
},
pendingRemove: {
position: 'absolute',
top: -4,
right: -4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#FF3B30',
justifyContent: 'center',
alignItems: 'center',
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
+11 -1
View File
@@ -748,11 +748,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.2.4 </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 */}
+98 -29
View File
@@ -55,6 +55,12 @@ 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 = '';
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
@@ -198,47 +204,110 @@ 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 */
// 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;
// Alle Audio-Teile abgespielt → Listener benachrichtigen
this.playbackFinishedListeners.forEach(cb => cb());
return;
}
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 = '';
}
}
// --- Status & Callbacks ---
+14 -5
View File
@@ -7,12 +7,13 @@
* 3. App zeigt Benachrichtigung → User bestaetigt → Download + Install
*/
import { Alert, Linking, Platform } from 'react-native';
import { Alert, Linking, Platform, NativeModules } from 'react-native';
import RNFS from 'react-native-fs';
import rvs, { RVSMessage } from './rvs';
// Aktuelle App-Version (aus package.json via Build)
const APP_VERSION = '0.0.2.3'; // TODO: aus nativer Build-Config lesen
// 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;
@@ -116,9 +117,17 @@ class UpdateService {
const fileSize = await RNFS.stat(destPath);
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
// APK installieren (oeffnet Android-Installer)
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
if (Platform.OS === 'android') {
await Linking.openURL(`file://${destPath}`);
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}`);
+29 -19
View File
@@ -1,10 +1,11 @@
/**
* Wake Word Service — "ARIA" Erkennung
* Gespraechsmodus — "Ohr-Button"
*
* Phase 1: Deaktiviert — react-native-live-audio-stream hat native Bridge-Probleme.
* Nutzt stattdessen Tap-to-Talk (VoiceButton) als primaeren Eingabemodus.
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
* Wie ein Walkie-Talkie / natuerliches Gespraech:
* ARIA spricht → Aufnahme startet → User spricht → VAD stoppt → ARIA antwortet → ...
*
* Phase 2: Porcupine on-device "ARIA" Keyword (geplant).
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
*/
type WakeWordCallback = () => void;
@@ -17,30 +18,39 @@ class WakeWordService {
private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = [];
/** Wake Word Erkennung starten */
/** Gespraechsmodus starten */
async start(): Promise<boolean> {
if (this.state === 'listening') return true;
try {
// Phase 1: LiveAudioStream deaktiviert (native Bridge instabil)
// Stattdessen: Tap-to-Talk als primaerer Modus
console.log('[WakeWord] Wake Word ist in Phase 1 noch nicht verfuegbar — nutze Tap-to-Talk');
this.setState('listening');
return true;
} catch (err) {
console.error('[WakeWord] Start fehlgeschlagen:', err);
return false;
}
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
this.setState('listening');
// Sofort erste Aufnahme starten
setTimeout(() => {
if (this.state === 'listening') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 500);
return true;
}
/** Wake Word Erkennung stoppen */
/** Gespraechsmodus stoppen */
stop(): void {
console.log('[WakeWord] Gespraechsmodus deaktiviert');
this.setState('off');
}
/** Nach Aufnahme erneut starten */
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
async resume(): Promise<void> {
// Nichts zu tun in Phase 1
if (this.state !== 'listening') return;
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
await new Promise(resolve => setTimeout(resolve, 800));
if (this.state === 'listening') {
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
this.wakeCallbacks.forEach(cb => cb());
}
}
isActive(): boolean {
return this.state === 'listening';
}
// --- Callbacks ---
+37 -6
View File
@@ -201,11 +201,23 @@ class VoiceEngine:
return None
try:
# Langen Text in Saetze aufteilen (Piper hat Limits bei langen Texten)
# Markdown + Sonderzeichen entfernen fuer natuerliche Sprache
import re
sentences = re.split(r'(?<=[.!?])\s+', text.strip())
# Markdown-Formatierung entfernen
sentences = [re.sub(r'\*\*([^*]+)\*\*', r'\1', s).strip() for s in sentences if s.strip()]
clean = text.strip()
clean = re.sub(r'\*\*([^*]+)\*\*', r'\1', clean) # **fett**
clean = re.sub(r'\*([^*]+)\*', r'\1', clean) # *kursiv*
clean = re.sub(r'`[^`]+`', '', clean) # `code`
clean = re.sub(r'```[\s\S]*?```', '', clean) # Code-Bloecke
clean = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', clean) # [text](url)
clean = re.sub(r'#{1,6}\s*', '', clean) # ### Ueberschriften
clean = re.sub(r'>\s*', '', clean) # > Zitate
clean = re.sub(r'[-*]\s+', '', clean) # Listen
clean = re.sub(r'\n{2,}', '. ', clean) # Absaetze
clean = re.sub(r'\n', ', ', clean) # Zeilenumbrueche
clean = re.sub(r'\s{2,}', ' ', clean) # Mehrfach-Leerzeichen
clean = re.sub(r'["""„]', '', clean) # Anfuehrungszeichen
sentences = re.split(r'(?<=[.!?])\s+', clean)
sentences = [s.strip() for s in sentences if s.strip()]
if not sentences:
return None
@@ -851,7 +863,7 @@ class ARIABridge:
tts_engine = getattr(self, 'tts_engine_type', 'piper')
if tts_engine == "xtts":
# XTTS: Request ueber RVS an Gaming-PC senden
# XTTS: Ganzen Text senden, XTTS-Bridge teilt satzweise auf
xtts_voice = getattr(self, 'xtts_voice', '')
try:
await self._send_to_rvs({
@@ -1045,6 +1057,11 @@ class ARIABridge:
sender = payload.get("sender", "")
if sender in ("aria", "stt"):
return
text = payload.get("text", "")
if text:
logger.info("[rvs] App-Chat: '%s'", text[:80])
await self.send_to_core(text, source="app")
return
elif msg_type == "xtts_response":
# XTTS-Audio vom Gaming-PC empfangen → an App weiterleiten
@@ -1354,10 +1371,24 @@ class ARIABridge:
pass
async def _send_to_rvs(self, message: dict) -> None:
"""Sendet eine Nachricht an die App (via RVS)."""
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check."""
if self.ws_rvs is None:
return
# Ping-Check: Verbindung wirklich aktiv?
try:
pong = await self.ws_rvs.ping()
await asyncio.wait_for(pong, timeout=5)
except Exception:
logger.warning("[rvs] Ping fehlgeschlagen — Verbindung tot, erzwinge Reconnect")
try:
await self.ws_rvs.close()
except Exception:
pass
self.ws_rvs = None
# Reconnect wird vom connect_to_rvs Loop uebernommen
return
try:
await self.ws_rvs.send(json.dumps(message))
except Exception:
+127 -27
View File
@@ -205,8 +205,14 @@
<span><span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text">ARIA denkt...</span></span>
<button class="btn secondary" onclick="cancelRequest()" style="padding:2px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;">Abbrechen</button>
</div>
<div id="diag-pending-attachments" style="display:none;padding:6px 10px;background:#1E1E2E;border-radius:6px 6px 0 0;margin-bottom:-4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
</div>
<div class="input-row">
<input type="text" id="chat-input" placeholder="Nachricht an ARIA...">
<label class="btn secondary" style="padding:6px 10px;cursor:pointer;font-size:14px;" title="Datei anhaengen">
&#x1F4CE;
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
</label>
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..." onpaste="handleDiagPaste(event)">
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
</div>
@@ -401,6 +407,12 @@
<div class="settings-section">
<h2>Sprachausgabe</h2>
<div class="card" style="max-width:500px;">
<!-- TTS aktiv (global fuer alle Engines) -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<label style="color:#8888AA;font-size:12px;">TTS aktiv:</label>
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
</div>
<!-- TTS Engine Auswahl -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<label style="color:#8888AA;font-size:12px;">TTS Engine:</label>
@@ -426,10 +438,6 @@
<option value="ramona">Ramona (weiblich)</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<label style="color:#8888AA;font-size:12px;">TTS aktiv:</label>
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
</div>
<div style="margin-bottom:4px;">
<label style="color:#8888AA;font-size:12px;">Ramona Speed: <span id="speed-ramona-label">1.0x</span></label>
</div>
@@ -744,7 +752,16 @@
document.getElementById('diag-speed-thorsten').value = st;
document.getElementById('speed-thorsten-label').textContent = st + 'x';
document.getElementById('diag-tts-engine').value = msg.ttsEngine || 'piper';
document.getElementById('diag-xtts-voice').value = msg.xttsVoice || '';
// XTTS-Voice setzen — Option hinzufuegen falls nicht vorhanden
const xttsSelect = document.getElementById('diag-xtts-voice');
const xttsVoice = msg.xttsVoice || '';
if (xttsVoice && !Array.from(xttsSelect.options).some(o => o.value === xttsVoice)) {
const opt = document.createElement('option');
opt.value = xttsVoice;
opt.textContent = xttsVoice;
xttsSelect.appendChild(opt);
}
xttsSelect.value = xttsVoice;
toggleXTTSPanel();
return;
}
@@ -928,21 +945,39 @@
}
}
function sendDiagAttachments() {
// Alle pending Dateien an RVS senden
for (const f of diagPendingFiles) {
send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64 });
}
if (diagPendingFiles.length > 0) {
addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei');
}
diagPendingFiles = [];
renderDiagPending();
}
function testGateway() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
addChat('sent', text, 'Gateway direkt');
send({ action: 'test_gateway', text });
if (!text && diagPendingFiles.length === 0) return;
if (diagPendingFiles.length > 0) sendDiagAttachments();
if (text) {
addChat('sent', text, 'Gateway direkt');
send({ action: 'test_gateway', text });
}
input.value = '';
}
function testRVS() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
addChat('sent', text, 'via RVS');
send({ action: 'test_rvs', text });
if (!text && diagPendingFiles.length === 0) return;
if (diagPendingFiles.length > 0) sendDiagAttachments();
if (text) {
addChat('sent', text, 'via RVS');
send({ action: 'test_rvs', text });
}
input.value = '';
}
@@ -1247,7 +1282,16 @@
}
function loadXTTSVoices() {
sendToRVS_raw({ type: 'xtts_list_voices', payload: {}, timestamp: Date.now() });
send({ action: 'xtts_list_voices' });
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 8192) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
}
return btoa(binary);
}
async function uploadVoiceSamples() {
@@ -1255,25 +1299,81 @@
const files = document.getElementById('xtts-clone-files').files;
if (!name) { alert('Bitte einen Namen eingeben'); return; }
if (!files || files.length === 0) { alert('Bitte Audio-Dateien auswaehlen'); return; }
if (files.length > 10) { alert('Maximal 10 Dateien'); return; }
document.getElementById('xtts-clone-status').textContent = `Lade ${files.length} Datei(en) hoch...`;
const status = document.getElementById('xtts-clone-status');
status.textContent = `Lade ${files.length} Datei(en)...`;
status.style.color = '#FFD60A';
const samples = [];
for (const file of files) {
const buffer = await file.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
samples.push({ base64, name: file.name, size: file.size });
try {
const samples = [];
for (let i = 0; i < files.length; i++) {
status.textContent = `Lese Datei ${i + 1}/${files.length}: ${files[i].name}...`;
const buffer = await files[i].arrayBuffer();
const base64 = arrayBufferToBase64(buffer);
samples.push({ base64, name: files[i].name, size: files[i].size });
}
const totalSize = samples.reduce((s, f) => s + f.size, 0);
status.textContent = `Sende ${samples.length} Sample(s) (${(totalSize / 1024).toFixed(0)}KB)...`;
send({ action: 'voice_upload', name, samples });
status.textContent = `Gesendet — warte auf Bestaetigung vom XTTS-Server...`;
} catch (err) {
status.textContent = `Fehler: ${err.message}`;
status.style.color = '#FF3B30';
}
}
const totalSize = samples.reduce((s, f) => s + f.size, 0);
document.getElementById('xtts-clone-status').textContent =
`Sende ${samples.length} Sample(s) (${(totalSize / 1024).toFixed(0)}KB) an XTTS-Server...`;
// ── Diagnostic Anhang-Handling ─────────────
let diagPendingFiles = [];
sendToRVS_raw({
type: 'voice_upload',
payload: { name, samples },
timestamp: Date.now(),
});
function handleDiagFileSelect(files) {
for (const file of files) {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
diagPendingFiles.push({ name: file.name, type: file.type, size: file.size, base64 });
renderDiagPending();
};
reader.readAsDataURL(file);
}
}
function handleDiagPaste(event) {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file') {
event.preventDefault();
const file = item.getAsFile();
if (file) handleDiagFileSelect([file]);
}
}
}
function renderDiagPending() {
const container = document.getElementById('diag-pending-attachments');
if (diagPendingFiles.length === 0) {
container.style.display = 'none';
return;
}
container.style.display = 'flex';
container.innerHTML = diagPendingFiles.map((f, i) => {
const isImage = f.type.startsWith('image/');
const preview = isImage ? `<img src="data:${f.type};base64,${f.base64}" style="width:40px;height:40px;border-radius:4px;object-fit:cover;">` : `<span style="font-size:20px;">&#x1F4C4;</span>`;
return `<div style="position:relative;display:inline-block;">
${preview}
<span onclick="removeDiagPending(${i})" style="position:absolute;top:-4px;right:-4px;width:16px;height:16px;border-radius:8px;background:#FF3B30;color:#fff;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;">X</span>
</div>`;
}).join('') + `<span style="color:#8888AA;font-size:11px;margin-left:4px;">${diagPendingFiles.length} Datei(en)</span>
<span onclick="diagPendingFiles=[];renderDiagPending();" style="color:#FF3B30;font-size:11px;cursor:pointer;margin-left:8px;">Alle X</span>`;
}
function removeDiagPending(idx) {
diagPendingFiles.splice(idx, 1);
renderDiagPending();
}
// ── Abbrechen ──────────────────────────────
+40
View File
@@ -560,6 +560,31 @@ function connectRVS(forcePlain) {
});
}
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 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";
@@ -1156,6 +1181,14 @@ 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");
@@ -1165,6 +1198,13 @@ wss.on("connection", (ws) => {
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 === "get_voice_config") {
handleGetVoiceConfig(ws);
} else if (msg.action === "send_voice_config") {
+1 -1
View File
@@ -18,7 +18,7 @@ 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
+26 -8
View File
@@ -6,9 +6,9 @@
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
- [x] Cache leeren + Auto-Download von Anhaengen
- [x] ARIA liest Nachrichten vor (TTS via Piper)
- [x] Autoscroll zur letzten Nachricht
- [x] Autoscroll zur letzten Nachricht (inverted FlatList)
- [x] Bilder im Chat groesser + Vollbild-Vorschau
- [x] Ohr-Button Absturz gefixt (LiveAudioStream entfernt, Phase 1 Placeholder)
- [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)
@@ -18,19 +18,37 @@
- [x] RVS Nachrichten vom Smartphone gehen durch
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme)
- [x] Highlight-Trigger konfigurierbar in Diagnostic
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning)
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
- [x] TTS Engine waehlbar (Piper/XTTS) in Diagnostic + App
- [x] Auto-Update System (APK via RVS WebSocket)
- [x] Auto-Update: APK-Installation via FileProvider
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
- [x] Audio-Queue (sequentielle Wiedergabe, kein Ueberlappen)
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
- [x] Mehrere Anhaenge + Text vor dem Senden (Pending-Vorschau)
- [x] Paste-Support fuer Bilder in Diagnostic Chat
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
## Offen
### TTS / Stimmen
- [ ] TTS Engine waehlbar: Piper (CPU, schnell) oder Coqui XTTS v2 (GPU, natuerlicher)
- [ ] Piper Voices Download ueber Diagnostic (neue Sprachen/Stimmen)
- [ ] Coqui XTTS v2 Integration (braucht GPU, bessere deutsche Stimme)
### Bugs (Prioritaet)
- [ ] Session-Persistenz: Bei Container-Restart wird immer aria-bridge geladen statt die zuletzt gewaehlte Session
- [ ] App: Audioausgabe hoert ab und zu einfach auf (mitten im Satz oder zwischen Chunks)
### App
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2)
### App Features
- [ ] Wake Word on-device (Porcupine "ARIA" Keyword, Phase 2 — passives Lauschen)
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Background Audio Service (TTS auch bei minimierter App)
### TTS / Audio
- [ ] XTTS Audio-Streaming (PCM-Stream statt WAV-Dateien, eliminiert Stottern komplett)
- [ ] Audio-Normalisierung (Lautstaerke zwischen Chunks angleichen)
- [ ] Piper Voices Download ueber Diagnostic (neue Sprachen/Stimmen)
### 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
+7 -2
View File
@@ -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 ..
@@ -174,9 +177,11 @@ fi
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 — Apps werden benachrichtigt"
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}"
+72 -28
View File
@@ -97,39 +97,83 @@ async function handleTTSRequest(payload) {
const { text, voice, requestId, language } = payload;
if (!text) return;
log(`TTS-Request: "${text.slice(0, 60)}..." (voice: ${voice || "default"}, lang: ${language || "de"})`);
// Markdown + Sonderzeichen entfernen fuer natuerliche Sprache
let cleanText = text
.replace(/\*\*([^*]+)\*\*/g, "$1") // **fett** → fett
.replace(/\*([^*]+)\*/g, "$1") // *kursiv* → kursiv
.replace(/`([^`]+)`/g, "$1") // `code` → code
.replace(/```[\s\S]*?```/g, "") // Code-Bloecke entfernen
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [text](url) → text
.replace(/#{1,6}\s*/g, "") // ### Ueberschriften → entfernen
.replace(/>\s*/g, "") // > Zitate → entfernen
.replace(/[-*]\s+/g, "") // - Listen → entfernen
.replace(/\n{2,}/g, ". ") // Mehrere Newlines → Punkt
.replace(/\n/g, ", ") // Einzelne Newlines → Komma
.replace(/\s{2,}/g, " ") // Mehrfach-Leerzeichen
.replace(/["""„]/g, "") // Anfuehrungszeichen entfernen
.replace(/\(\)/g, "") // Leere Klammern
.trim();
// Text in Saetze aufteilen, dann zu Chunks von 2-3 Saetzen zusammenfassen
// (mehr Kontext = konsistentere Stimme/Lautstaerke, aber nicht zu lang fuer WebSocket)
const sentences = cleanText.split(/(?<=[.!?])\s+/)
.map(s => s.trim())
.filter(s => s.length > 0)
.map(s => s.replace(/[.]+$/, '')); // Punkt am Ende entfernen
const MAX_CHUNK_CHARS = 150; // Max ~150 Zeichen pro Chunk (schnelles Rendering, Preloading reicht)
const chunks = [];
let currentChunk = '';
for (const sentence of sentences) {
if (currentChunk && (currentChunk.length + sentence.length + 2) > MAX_CHUNK_CHARS) {
chunks.push(currentChunk);
currentChunk = sentence;
} else {
currentChunk = currentChunk ? currentChunk + ', ' + sentence : sentence;
}
}
if (currentChunk) chunks.push(currentChunk);
if (chunks.length === 0) return;
log(`TTS-Request: "${cleanText.slice(0, 60)}..." (${sentences.length} Saetze → ${chunks.length} Chunks, voice: ${voice || "default"}, lang: ${language || "de"})`);
try {
// Voice-Sample Pfad bestimmen
const voiceSample = voice ? path.join(VOICES_DIR, `${voice}.wav`) : null;
const hasCustomVoice = voiceSample && fs.existsSync(voiceSample);
// XTTS API aufrufen
const audioBuffer = await callXTTSAPI(text, language || "de", hasCustomVoice ? voiceSample : null);
// Streaming: Chunk rendern → sofort senden → naechster Chunk
// App spielt mit Preloading-Queue nahtlos ab
let sentCount = 0;
if (audioBuffer && audioBuffer.length > 100) {
const base64 = audioBuffer.toString("base64");
log(`TTS fertig: ${audioBuffer.length} bytes (${(audioBuffer.length / 1024).toFixed(0)}KB)`);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
const audioBuffer = await callXTTSAPI(chunk, language || "de", hasCustomVoice ? voiceSample : null);
sendToRVS({
type: "xtts_response",
payload: {
requestId: requestId || "",
base64,
mimeType: "audio/wav",
voice: voice || "default",
engine: "xtts",
},
timestamp: Date.now(),
});
} else {
log("TTS: Leeres Audio erhalten");
sendToRVS({
type: "xtts_response",
payload: { requestId, error: "Leeres Audio" },
timestamp: Date.now(),
});
if (audioBuffer && audioBuffer.length > 100) {
log(`TTS [${i + 1}/${chunks.length}]: ${(audioBuffer.length / 1024).toFixed(0)}KB — "${chunk.slice(0, 50)}"`);
sendToRVS({
type: "xtts_response",
payload: {
requestId: `${requestId || ""}_${i}`,
base64: audioBuffer.toString("base64"),
mimeType: "audio/wav",
voice: voice || "default",
engine: "xtts",
part: i + 1,
totalParts: chunks.length,
},
timestamp: Date.now(),
});
sentCount++;
}
} catch (chunkErr) {
log(`TTS [${i + 1}/${chunks.length}] Fehler: ${chunkErr.message} — ueberspringe`);
}
}
log(`TTS komplett: ${sentCount}/${chunks.length} Chunks gestreamt`);
} catch (err) {
log(`TTS Fehler: ${err.message}`);
sendToRVS({
@@ -257,12 +301,12 @@ log(`RVS: ${RVS_HOST}:${RVS_PORT}`);
function waitForXTTS(callback, attempts) {
if (attempts <= 0) { log("XTTS API nicht erreichbar — starte trotzdem"); callback(); return; }
http.get(`${XTTS_API_URL}/docs`, (res) => {
log("XTTS API erreichbar");
log(`XTTS API erreichbar (HTTP ${res.statusCode})`);
callback();
}).on("error", () => {
log(`XTTS API noch nicht bereit — warte (${attempts} Versuche uebrig)...`);
setTimeout(() => waitForXTTS(callback, attempts - 1), 5000);
setTimeout(() => waitForXTTS(callback, attempts - 1), 10000); // 10s statt 5s (Model laden dauert)
});
}
waitForXTTS(() => connectRVS(), 24); // Max 2min warten
waitForXTTS(() => connectRVS(), 30); // Max 5min warten
+6 -4
View File
@@ -17,7 +17,7 @@ services:
# ─── XTTS v2 API Server (GPU) ─────────────────
xtts:
image: ghcr.io/daswer123/xtts-api-server:latest
image: daswer123/xtts-api-server:latest
container_name: aria-xtts
deploy:
resources:
@@ -27,9 +27,9 @@ services:
count: 1
capabilities: [gpu]
ports:
- "8000:8000"
- "8000:8020"
volumes:
- xtts-models:/root/.local/share/tts # Model-Cache (~2GB)
- xtts-models:/app/xtts_models # Model-Cache (~2GB)
- ./voices:/voices # Custom Voice Samples
environment:
- COQUI_TOS_AGREED=1
@@ -41,8 +41,10 @@ services:
container_name: aria-xtts-bridge
depends_on:
- xtts
volumes:
- ./voices:/voices # Shared mit XTTS-Server
environment:
- XTTS_API_URL=http://xtts:8000
- XTTS_API_URL=http://xtts:8020
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}