Compare commits

...
26 Commits
Author SHA1 Message Date
duffyduck 01df26e6df release: bump version to 0.2.3.4 2026-08-15 11:49:28 +02:00
duffyduckandClaude Opus 4.8 f226c91973 fix(voxtral): librosa als Dependency (Processor laedt WAV damit)
VoxtralProcessor.apply_transcription_request -> load_audio_as braucht librosa zum Einlesen der Audiodatei. Fehlte im Image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 11:32:44 +02:00
duffyduckandClaude Opus 4.8 3324d39d50 fix(voxtral): Audio als temp-WAV-Pfad an Processor (statt rohem Array)
VoxtralProcessor.apply_transcription_request verlangt bei rohen Arrays ein 'format'. Fix: Buffer in ein temp-WAV (PCM_16, 16kHz) schreiben und den Pfad uebergeben — Processor liest Format+Samplerate selbst. Temp-Datei wird nach dem Transkribieren geloescht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 11:29:51 +02:00
duffyduckandClaude Opus 4.8 fff2e7df34 feat(xtts): Voxtral als Default-STT, Whisper als opt-in Fallback-Profil
Profile getauscht: voxtral-bridge laeuft jetzt bei jedem 'docker compose up', whisper-bridge nur noch mit --profile whisper. Loest das 'nach down/up startet whisper statt voxtral'-Problem. Immer nur EIN STT gleichzeitig (sonst stt_*-Kollision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 11:24:45 +02:00
duffyduckandClaude Opus 4.8 0aac114142 fix(voxtral): PYTORCH_CUDA_ALLOC_CONF=expandable_segments gegen VRAM-OOM
Modell laedt knapp nicht (12GB-Karte hatte 3.13GB durch Fremdprozess belegt). Anti-Fragmentierungs-Schalter reduziert den Peak-Bedarf beim Warmup; zusaetzlich muss GPU 1 frei sein (whisper stoppen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 11:16:50 +02:00
duffyduckandClaude Opus 4.8 ba60f793fb feat(voxtral): STT-3B via Transformers (torch cu124, kein Treiber-Upgrade)
Backports brachte keinen neueren Treiber (bleibt 550/CUDA 12.4). Statt Upgrade: Voxtral-Mini-3B-2507 via Transformers mit torch 2.6.0+cu124 (F5-Trick) — laeuft auf 550, ~9 GB bf16 auf GPU 1. Chunked wie whisper mit dem adaptiven M0.1-Endpointer, RVS-Protokoll identisch (drop-in). Ersetzt den vLLM-Realtime-4B-Ansatz (der brauchte 16 GB). Compose: ein Container, GPU-1-gepinnt, Profil 'voxtral'. Transformers-API in einer Methode gekapselt (verify-on-run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 11:01:22 +02:00
duffyduck a7c2f07361 release: bump version to 0.2.3.3 2026-08-15 10:55:10 +02:00
duffyduckandClaude Opus 4.8 ccf6dd84fb feat(ai-box): opt-in Treiber-Upgrade via trixie-backports (--upgrade-driver)
Fuer Voxtral/modernes CUDA: --upgrade-driver zieht einen neueren nvidia-driver aus trixie-backports, baut das DKMS-Modul (Kernel-Header sind da), und weist auf den noetigen Reboot hin. Ohne den Flag bleibt der laufende 550er unangetastet. Fallback-Hinweis auf NVIDIAs CUDA-Repo, falls backports nichts Neueres hat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 10:52:37 +02:00
duffyduckandClaude Opus 4.8 75675ed3aa fix(settings): 'Stille-Toleranz' steuert aktiven Endpoint, dB-Regler raus
Der sichtbare 'Stille-Toleranz'-Regler verstellte den toten Legacy-dB-VAD-Pfad; jetzt steuert er STT_ENDPOINT_MS (die echte Streaming-Pausen-Toleranz, 1-4s). Der obsolete 'Stille-Pegel (dB)'-Regler ist entfernt — der aktive STT nutzt adaptiven Rausch-Boden, Rauschen-als-Wort verhindert der no_speech_prob-Filter des Modells.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 10:48:01 +02:00
duffyduckandClaude Opus 4.8 73fee27e90 fix(voice): Hard-Cap an 'Max. Aufnahmedauer'-Setting + Dauer an Bubble
Wake-Word/Barge-In waren hart auf 60s gecappt (schnitt lange Diktate bei 1 min ab). Jetzt lesen sie loadMaxRecordingMs() — der bestehende, aber vom Streaming-Pfad abgeklemmte 'Maximale Aufnahmedauer'-Regler (1-30 min) steuert nun wirklich. Voice-Bubbles zeigen die Aufnahmedauer (durationS aus stt_endpoint) als 'M:SS'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 10:48:01 +02:00
duffyduck 03021a6787 release: bump version to 0.2.3.2 2026-08-15 03:24:35 +02:00
duffyduckandClaude Opus 4.8 b6a5d7029f feat(m1): generative Flaeche — Orb + Karten-Renderer (present_view)
Visueller Renderer fuer aria_view: Orb (reanimated-Puls je Zustand), CardView (text/image/list/map/code, Sci-Fi-Chrome), AriaViewCanvas (pannbare Flaeche, 2-Finger-Pan + Pinch, Karten materialisieren gestaffelt). WorkspaceScreen blendet die Flaeche als Overlay ueber Chat/Cockpit ein, sobald ARIA fuers fokussierte Projekt eine Ansicht komponiert. v1/Vorgeschmack, tsc-clean; Markdown=Klartext, Map=Marker-Liste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 03:22:08 +02:00
duffyduckandClaude Opus 4.8 3a8202d2de fix(f5tts): torch fest auf 2.6.0+cu124 pinnen (neuester cu124-Build)
Adaptiver Re-Pin scheiterte: f5-tts zieht torch 2.13.0, aber cu124-Wheels enden bei 2.6.0 (torch 2.7+ nur noch cu126+, was Treiber 550/CUDA12.4 nicht kann). Jetzt: torch/torchaudio 2.6.0+cu124 vor f5-tts installiert, Constraint-Datei haelt f5-tts vom Hochziehen ab. Treiber-Upgrade bleibt der strategische Fix fuer Voxtral/modernes CUDA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 02:25:12 +02:00
duffyduckandClaude Opus 4.8 7bbb75481c fix(f5tts): torch auf cu124 re-pinnen (Treiber 550/CUDA 12.4)
f5-tts>=1.0.0 zieht als Dependency ein neueres torch mit zu neuem CUDA-Build und ueberschreibt den cu121-Pin → 'NVIDIA driver too old (found 12040)' auf Treiber 550. Nach der Installation wird dieselbe torch/torchaudio-Version als cu124-Build force-reinstalled (--no-deps), kompatibel mit 550/CUDA 12.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 02:21:23 +02:00
duffyduck ee6c4f34db release: bump version to 0.2.3.1 2026-08-15 02:01:12 +02:00
duffyduckandClaude Opus 4.8 75daadf72d fix(xtts): GPU-Pinning an 8GB+12GB-Realitaet anpassen
Die zwei 3060 sind ungleich (GPU0=8GB, GPU1=12GB), nicht 12+12. Groesstes Modell (STT, spaeter Voxtral-STT-3B ~9GB) muss auf die 12GB-Karte: whisper->GPU1. TTS(F5)+LLM auf die 8GB-Karte: ->GPU0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:53:59 +02:00
duffyduckandClaude Opus 4.8 37aaa90239 fix(ai-box): Kernel-Header vor nvidia-driver (DKMS-Modulbau)
Ohne linux-headers ueberspringt DKMS den Modulbau ('No kernel headers were found') → nvidia-smi kann nicht mit dem Treiber reden. Jetzt: linux-headers-amd64 + linux-headers-$(uname -r) vor dem Treiber, plus 'dkms autoinstall' als Reparatur, falls nvidia-kernel-dkms schon ohne Header installiert war.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:49:35 +02:00
duffyduckandClaude Opus 4.8 03e6d784b6 fix(ai-box): fragilen apt-cache-Kandidat-Check raus, direkt installieren
Der apt-cache-policy|grep-Check meldete faelschlich 'kein Kandidat' (locale-/pipefail-fragil), obwohl nvidia-driver installierbar war. Ersetzt durch direkten Install als Test: apt-get install; bei Fehlschlag volles apt-get update + zweiter Versuch, erst dann Abbruch mit Quellen-Diagnose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:42:34 +02:00
duffyduckandClaude Opus 4.8 762f1a2dd9 fix(ai-box): Self-Heal fuer nvidia-driver-Kandidat (volles apt update)
Das schnelle 'apt-get update -qq' indiziert non-free gelegentlich nicht sauber (InRelease-Cache). Wenn kein Kandidat gefunden wird, erzwingt das Script jetzt ein vollstaendiges 'apt-get update' und prueft erneut, bevor es mit klarer Meldung abbricht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:38:45 +02:00
duffyduckandClaude Opus 4.8 49f6b26ab8 fix(ai-box): robuste non-free-Aktivierung + Treiber-Kandidat-Check
add_component ersetzt durch add_components: ergaenzt contrib/non-free/non-free-firmware in JEDER Components:-Zeile aller .sources-Dateien (ganze-Wort-Adressen, idempotent), verifiziert die Aenderung per md5sum statt sie nur zu melden. Vor dem nvidia-driver-Install wird der apt-Kandidat geprueft — bei fehlendem non-free klare Fehlermeldung + Anzeige der aktiven Quellen statt kryptischem 'kein Installationskandidat'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:31:08 +02:00
duffyduckandClaude Opus 4.8 c340b9d683 docs(ai-box): Stimm-Daten neu-enrollen statt kopieren
Alte Gamebox ist retired (RAM zur ai-box gewandert) → voice-id/voices lassen sich nicht mehr kopieren. Hinweis angepasst: ohne Fingerprint laeuft Speaker-ID fail-open, Stimme + F5-Referenz einfach in der App neu anlegen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:24:26 +02:00
duffyduckandClaude Opus 4.8 a0429fc91e feat(ai-box): Bootstrap fuer die KI-Box (Debian Trixie)
Idempotentes Setup einer frischen Trixie-Box zum GPU-Satelliten-Host: non-free (deb822-Format), NVIDIA-Treiber, Docker+Compose, nvidia-container-toolkit, GPU-im-Container-Test, xtts/.env, optional Stack-Start (--up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 174d6d643d feat(voxtral): STT-Satellit (Profil) + 2-Karten-GPU-Pinning
Neuer xtts/voxtral-Container (RVS<->vLLM-Realtime-WS, adaptiver Endpointer), hinter Compose-Profil 'voxtral' (braucht >=16GB VRAM, startet nicht im Default). Bestehende Satelliten auf die zwei 3060 gepinnt: whisper->GPU0, f5tts->GPU1, llama-swap->GPU0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 fd6ba73f59 feat: GPS-Trigger-Kopplung + generatives Cockpit (aria_view)
GPS: near()/entered_near()/left_near()-Watcher schalten das Tracking automatisch an bzw. beim Loeschen des letzten wieder aus; gpsTracking.start() sichert jetzt die Background-Permission. Cockpit-Fundament: neues Brain-Tool present_view emittiert aria_view (Orb + Karten), Bridge/RVS leiten weiter, ariaView.ts haelt die Spec App-seitig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 f5b22253b2 fix(stt): adaptiver VAD-Schwellwert gegen Satz-Cutoff
Feste RMS-Grenze (0.012) durch rauschboden-relativen Schwellwert ersetzt (fast-down/slow-up, geklammert). Leises/entferntes Sprechen gilt nicht mehr faelschlich als Stille und wird nicht mitten im Satz gecuttet. audio.ts: Fallback-endpointMs 1500->2400 vereinheitlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 761f4c8903 fix(diagnostic): 'Sicher aufraeumen' ohne confirm() — Button feuerte nie
Ursache gefunden (WS-Direkttest beweist: Server-Pfad + Docker-Prune-API
funktionieren, aber der Klick kam nie an): runDiskCleanup() rief confirm()
VOR dem send(). Hatte der Browser Dialoge unterdrueckt ('Verhindern, dass
diese Seite weitere Dialoge erstellt' — bei dem vielen alert()/confirm()
im Diagnostic leicht passiert), gab confirm() automatisch false zurueck →
return vor send() → Button tat sichtbar nichts.

Fix: 'safe' laeuft OHNE confirm (Build-Cache + ungenutzte Images sind
ungefaehrlich, keine Volumes/Daten). Nur 'aggressive' (Volumes!) fragt
noch. Ergebnis wird am Button + Banner gezeigt, nicht nur per alert()
(das koennte genauso unterdrueckt sein).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 11:30:44 +02:00
24 changed files with 1698 additions and 98 deletions
+63
View File
@@ -0,0 +1,63 @@
# ai-box — KI-Box (gpubox) Bootstrap
Macht aus einem frisch installierten **Debian Trixie** (headless, nur SSH) einen
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM).
## Was das Script tut
1. Basis-Pakete (curl, gnupg, git …)
2. `contrib non-free non-free-firmware` aktivieren (Trixie-**deb822**-Format berücksichtigt)
3. **NVIDIA-Treiber** installieren (`nvidia-driver` + firmware)
4. **Docker** Engine + Compose-Plugin
5. **NVIDIA Container Toolkit** + Docker-Runtime auf NVIDIA konfigurieren
6. `xtts/.env` aus `.env.example` anlegen (RVS_TOKEN optional gleich setzen)
7. **GPU-im-Container-Test** (`docker run --gpus all … nvidia-smi`)
8. optional (`--up`): den `xtts`-Stack hochziehen
Alles **idempotent** — mehrfach ausführbar.
## Ablauf
```bash
git clone <repo> ARIA-AGENT
cd ARIA-AGENT/ai-box
sudo ./bootstrap.sh
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
sudo reboot
# … nach dem Boot:
cd ARIA-AGENT/ai-box
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
```
Der Treiber-Reboot ist normal (Kernel-Modul wird erst beim Boot geladen). Beim
zweiten Lauf überspringt das Script alles Erledigte und macht nur noch den
GPU-Test + Stack-Start.
## Optionen
| Option | Wirkung |
|---|---|
| `--up` | am Ende `docker compose up -d --build` (Default-Profil) |
| `--token <TOK>` | `RVS_TOKEN` in `xtts/.env` eintragen (auch via `RVS_TOKEN=…` env) |
| `--rvs-host <H>` | `RVS_HOST` setzen |
## Wichtig
- **Stimm-Daten** (nicht in git): falls von der alten Box noch vorhanden,
`xtts/voice-id/` (Speaker-Fingerprint) + `xtts/voices/` (F5-Referenz) herkopieren.
Sonst egal — in der App neu anlegen: Stimme neu enrollen (ohne Fingerprint läuft
die Speaker-ID fail-open, alles geht durch) + F5-Voice-Referenz neu hochladen.
- **Voxtral bleibt aus** auf der 3060 (braucht ≥16 GB VRAM). Das Default-Profil
fährt Whisper (mit dem M0.1-Fix) + F5-TTS + lokales LLM. Voxtral erst mit der
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
## Verifizieren
```bash
nvidia-smi # Host sieht die GPU
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
```
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
#
# ARIA KI-Box (gpubox) Bootstrap — frisches Debian Trixie → startklarer
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
#
# Ablauf nach `git clone`:
# cd ARIA-AGENT/ai-box
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
#
# Optionen:
# --up am Ende den xtts-Stack starten (Default-Profil, OHNE voxtral)
# --upgrade-driver NVIDIA-Treiber aus trixie-backports (modernes CUDA fuer Voxtral).
# Danach REBOOT noetig. Default-Bootstrap laesst 550 unangetastet.
# --token <TOK> RVS_TOKEN in xtts/.env eintragen (alternativ: env RVS_TOKEN=...)
# --rvs-host <H> RVS_HOST setzen (Default aus .env.example)
#
# IDEMPOTENT: bereits erledigte Schritte werden uebersprungen. Nach dem
# Treiber-Reboot einfach nochmal ausfuehren — der Rest laeuft dann durch.
#
set -euo pipefail
# ── CLI ──
DO_UP=0
DO_UPGRADE_DRIVER=0
RVS_TOKEN_ARG="${RVS_TOKEN:-}"
RVS_HOST_ARG="${RVS_HOST:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--up) DO_UP=1; shift ;;
--upgrade-driver) DO_UPGRADE_DRIVER=1; shift ;;
--token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;;
--rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1"; exit 1 ;;
esac
done
# ── Log-Helfer ──
c_g="\033[1;32m"; c_y="\033[1;33m"; c_r="\033[1;31m"; c_b="\033[1;34m"; c_0="\033[0m"
STEP=0
step() { STEP=$((STEP+1)); echo -e "\n${c_b}[${STEP}] $*${c_0}"; }
ok() { echo -e " ${c_g}${c_0} $*"; }
warn() { echo -e " ${c_y}!${c_0} $*"; }
die() { echo -e "${c_r}$*${c_0}" >&2; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
XTTS_DIR="$REPO_ROOT/xtts"
# ── Preflight ──
[[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausfuehren (sudo ./bootstrap.sh)."
command -v apt-get >/dev/null || die "Kein apt-get — dieses Script ist fuer Debian/Trixie."
export DEBIAN_FRONTEND=noninteractive
echo -e "${c_b}=== ARIA KI-Box Bootstrap ===${c_0}"
echo "Repo: $REPO_ROOT"
echo "xtts: $XTTS_DIR"
# ── 1. Basis-Pakete ──
step "Basis-Pakete"
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg git lsb-release >/dev/null
ok "ca-certificates, curl, gnupg, git"
# ── 2. non-free Repos aktivieren (Trixie deb822 + Legacy) ──
step "APT-Komponenten (contrib non-free non-free-firmware)"
# Ergaenzt die drei Komponenten in JEDER Components:-Zeile einer deb822-Datei —
# pro Zeile nur was fehlt, reihenfolge-robust, idempotent. Die Adressen pruefen
# ganze Woerter (non-free-firmware zaehlt NICHT als non-free).
add_components() {
sed -i -E '/^[Cc]omponents:/{
/(^|[[:space:]])contrib([[:space:]]|$)/!s/$/ contrib/
/(^|[[:space:]])non-free([[:space:]]|$)/!s/$/ non-free/
/(^|[[:space:]])non-free-firmware([[:space:]]|$)/!s/$/ non-free-firmware/
}' "$1"
}
ENABLED_ANY=0
shopt -s nullglob
for f in /etc/apt/sources.list.d/*.sources; do
grep -qE '^[Cc]omponents:' "$f" || continue
b="$(md5sum "$f")"; add_components "$f"; a="$(md5sum "$f")"
if [[ "$b" != "$a" ]]; then ENABLED_ANY=1; ok "aktualisiert: $(basename "$f")"; fi
done
shopt -u nullglob
# Legacy /etc/apt/sources.list (deb-Zeilen)
if [[ -f /etc/apt/sources.list ]] && grep -qE '^deb ' /etc/apt/sources.list; then
if ! grep -qE '^deb .*[[:space:]]non-free([[:space:]]|$)' /etc/apt/sources.list; then
sed -i -E '/^deb .*debian/ s/$/ contrib non-free non-free-firmware/' /etc/apt/sources.list
ENABLED_ANY=1; ok "aktualisiert: sources.list"
fi
fi
[[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv"
apt-get update -qq # immer neu einlesen, damit der Kandidat sicher da ist
# ── 2b. (opt-in) Treiber-Upgrade via trixie-backports — fuer Voxtral/modernes CUDA ──
# Der Trixie-Standardtreiber (550, CUDA 12.4) ist zu alt fuer den modernen Stack
# (torch 2.13, vLLM). Backports bringt einen neueren, apt-verwalteten Treiber.
# NUR mit --upgrade-driver, damit ein laufendes Setup nicht ungewollt angefasst wird.
if [[ "$DO_UPGRADE_DRIVER" -eq 1 ]]; then
step "NVIDIA-Treiber-Upgrade (trixie-backports)"
BP_FILE="/etc/apt/sources.list.d/backports.sources"
if ! grep -rqs "trixie-backports" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
cat > "$BP_FILE" <<'EOF'
Types: deb
URIs: http://deb.debian.org/debian
Suites: trixie-backports
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
ok "trixie-backports hinzugefuegt"
else
ok "trixie-backports bereits aktiv"
fi
apt-get update
apt-get install -y linux-headers-amd64 || true
apt-get install -y "linux-headers-$(uname -r)" || true
if apt-get install -y -t trixie-backports nvidia-driver; then
dkms autoinstall >/dev/null 2>&1 || true
NEWV="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || echo '?')"
ok "nvidia-driver aus backports installiert: ${NEWV}"
echo
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
echo -e "${c_y} sudo reboot && danach: cd ai-box && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
exit 0
else
warn "backports-Install fehlgeschlagen — 550er bleibt aktiv."
warn "Alternative fuer den neuesten Treiber: NVIDIAs CUDA-Repo fuer debian13"
warn " (developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64) → Paket 'cuda-drivers'."
die "Treiber-Upgrade nicht moeglich — siehe oben."
fi
fi
# ── 3. NVIDIA-Treiber ──
step "NVIDIA-Treiber"
if nvidia-smi >/dev/null 2>&1; then
ok "Treiber aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
DRIVER_ACTIVE=1
else
if dpkg -l | grep -q '^ii nvidia-driver '; then
warn "Treiber installiert, aber nvidia-smi antwortet nicht → REBOOT noetig."
DRIVER_ACTIVE=0
else
# KEIN separater Kandidaten-Check — die apt-cache-Ausgabe ist locale-/pipe-
# fragil (hat faelschlich "kein Kandidat" gemeldet). Der Install IST der Test:
# direkt installieren; schlaegt er fehl, einmal volles apt-get update + Retry,
# dann erst mit Diagnose abbrechen.
# Kernel-Header ZUERST — sonst ueberspringt DKMS den Modulbau ("No kernel
# headers were found") und nvidia-smi kann spaeter nicht mit dem Treiber reden.
warn "Kernel-Header + nvidia-driver installieren (DKMS-Build, dauert)…"
apt-get install -y linux-headers-amd64 || true
apt-get install -y "linux-headers-$(uname -r)" || true
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
warn "Install fehlgeschlagen — volles 'apt-get update' + zweiter Versuch…"
apt-get update
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
echo
warn "nvidia-driver liess sich nicht installieren. Aktive Quellen:"
grep -rhE '^[Cc]omponents:' /etc/apt/sources.list.d/*.sources 2>/dev/null | sed 's/^/ /' || true
grep -E '^deb ' /etc/apt/sources.list 2>/dev/null | sed 's/^/ /' || true
die "Pruefe 'apt-cache policy nvidia-driver' + Netz/non-free."
fi
fi
# Falls nvidia-kernel-dkms schon (ohne Header) installiert war: Modul jetzt bauen.
dkms autoinstall >/dev/null 2>&1 || true
ok "nvidia-driver + Kernel-Modul installiert"
DRIVER_ACTIVE=0
fi
fi
# ── 4. Docker Engine + Compose-Plugin ──
step "Docker"
if command -v docker >/dev/null 2>&1; then
ok "Docker vorhanden: $(docker --version)"
else
warn "Installiere Docker (offizielles get.docker.com)…"
curl -fsSL https://get.docker.com | sh >/dev/null
systemctl enable --now docker >/dev/null 2>&1 || true
ok "Docker installiert: $(docker --version)"
fi
if docker compose version >/dev/null 2>&1; then
ok "Compose-Plugin: $(docker compose version | head -1)"
else
warn "Compose-Plugin fehlt — installiere docker-compose-plugin…"
apt-get install -y -qq docker-compose-plugin >/dev/null || \
warn "Konnte docker-compose-plugin nicht via apt holen — get.docker.com bringt es normalerweise mit."
fi
# ── 5. NVIDIA Container Toolkit ──
step "NVIDIA Container Toolkit"
NCT_LIST="/etc/apt/sources.list.d/nvidia-container-toolkit.list"
NCT_KEY="/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg"
if ! dpkg -l | grep -q '^ii nvidia-container-toolkit '; then
[[ -f "$NCT_KEY" ]] || curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o "$NCT_KEY"
if [[ ! -f "$NCT_LIST" ]]; then
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed "s#deb https://#deb [signed-by=${NCT_KEY}] https://#g" > "$NCT_LIST"
fi
apt-get update -qq
apt-get install -y -qq nvidia-container-toolkit >/dev/null
ok "nvidia-container-toolkit installiert"
else
ok "nvidia-container-toolkit vorhanden"
fi
# Docker-Runtime auf NVIDIA konfigurieren (idempotent)
if ! grep -q '"nvidia"' /etc/docker/daemon.json 2>/dev/null; then
nvidia-ctk runtime configure --runtime=docker >/dev/null
systemctl restart docker
ok "Docker-Runtime auf NVIDIA konfiguriert + Docker neugestartet"
else
ok "Docker-Runtime bereits NVIDIA-konfiguriert"
fi
# ── 6. xtts/.env vorbereiten ──
step "xtts/.env"
if [[ -f "$XTTS_DIR/.env" ]]; then
ok ".env existiert bereits (unangetastet)"
else
cp "$XTTS_DIR/.env.example" "$XTTS_DIR/.env"
ok ".env aus .env.example erstellt"
fi
if [[ -n "$RVS_TOKEN_ARG" ]]; then
sed -i -E "s#^RVS_TOKEN=.*#RVS_TOKEN=${RVS_TOKEN_ARG}#" "$XTTS_DIR/.env"
ok "RVS_TOKEN eingetragen"
fi
if [[ -n "$RVS_HOST_ARG" ]]; then
sed -i -E "s#^RVS_HOST=.*#RVS_HOST=${RVS_HOST_ARG}#" "$XTTS_DIR/.env"
ok "RVS_HOST=${RVS_HOST_ARG} eingetragen"
fi
if grep -q '^RVS_TOKEN=dein_token_hier' "$XTTS_DIR/.env"; then
warn "RVS_TOKEN ist noch der Platzhalter — vor dem Start setzen:"
warn " nano $XTTS_DIR/.env (oder: sudo ./bootstrap.sh --token <TOKEN>)"
fi
warn "Stimm-Daten (nicht in git): falls von der alten Box noch vorhanden, xtts/voice-id/"
warn " + xtts/voices/ herkopieren. Sonst egal — in der App neu anlegen:"
warn " Sprache neu enrollen (Speaker-ID, sonst fail-open) + F5-Referenz neu hochladen."
# ── 7. GPU-im-Container verifizieren ──
step "GPU-im-Container Test"
if [[ "${DRIVER_ACTIVE:-0}" -eq 1 ]]; then
if docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi >/dev/null 2>&1; then
ok "Docker sieht die GPU — KI-Box ist einsatzbereit."
GPU_READY=1
else
warn "Host-Treiber ok, aber Container sieht die GPU nicht — Toolkit/Runtime pruefen."
GPU_READY=0
fi
else
warn "Treiber noch nicht aktiv → Test uebersprungen."
echo
echo -e "${c_y}==> REBOOT noetig, dann Script erneut ausfuehren:${c_0}"
echo -e "${c_y} sudo reboot && (nach dem Boot) sudo ./bootstrap.sh${c_0}"
exit 0
fi
# ── 8. Optional: xtts-Stack hochziehen ──
if [[ $DO_UP -eq 1 && "${GPU_READY:-0}" -eq 1 ]]; then
step "xtts-Stack starten (Default-Profil: Whisper + F5 + LLM — OHNE voxtral)"
warn "Erster Start laedt Modelle (mehrere GB via HuggingFace) — kann dauern."
( cd "$XTTS_DIR" && docker compose up -d --build )
ok "Stack laeuft. Logs: docker logs -f aria-whisper-bridge"
echo
echo " voxtral (STT via Voxtral) braucht >=16 GB VRAM → erst mit der 24-GB-Karte:"
echo " cd $XTTS_DIR && docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build"
fi
# ── Abschluss ──
echo
echo -e "${c_g}=== Fertig. KI-Box startklar. ===${c_0}"
if [[ $DO_UP -eq 0 ]]; then
echo "Naechster Schritt — Stack starten:"
echo " cd $XTTS_DIR && docker compose up -d --build"
echo "oder direkt: sudo ./bootstrap.sh --up"
fi
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20300
versionName "0.2.3.0"
versionCode 20304
versionName "0.2.3.4"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.3.0",
"version": "0.2.3.4",
"private": true,
"scripts": {
"android": "react-native run-android",
+35 -4
View File
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs } from '../services/audio';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@@ -93,6 +93,9 @@ interface ChatMessage {
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
* auch wenn mehrere Aufnahmen parallel offen sind. */
audioRequestId?: string;
/** Laenge der Sprachaufnahme in Sekunden (aus dem stt_endpoint) — fuer die
* Dauer-Anzeige an der Voice-Bubble. */
durationS?: number;
/** Skill-Created-Bubble: ARIA hat einen neuen Skill angelegt */
skillCreated?: {
name: string;
@@ -184,6 +187,14 @@ function stripSystemHints(text: string): string {
}
return out;
}
/** Sekunden → "M:SS" fuer die Sprachnachricht-Dauer. */
function formatDur(sec: number): string {
const s = Math.max(0, Math.round(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, '0')}`;
}
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
@@ -1668,7 +1679,9 @@ const ChatScreen: React.FC = () => {
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000,
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current,
});
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
@@ -1696,6 +1709,13 @@ const ChatScreen: React.FC = () => {
if (ev.text && ev.text.trim()) {
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
// Aufnahme-Dauer an die passende Voice-Bubble haengen (Anzeige). Der
// spaetere STT-Text-Update spreadet die Message, die Dauer bleibt.
if (ev.audioRequestId && typeof ev.durationS === 'number' && ev.durationS > 0) {
const dur = ev.durationS;
setMessages(prev => prev.map(m =>
m.audioRequestId === ev.audioRequestId ? { ...m, durationS: dur } : m));
}
// Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang
// zu 'conversing' damit der normale Flow greift (TTS, resume, etc.)
if (wakeWordService.getState() === 'listening') {
@@ -1771,7 +1791,8 @@ const ChatScreen: React.FC = () => {
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000,
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current,
});
if (ok) {
@@ -2255,7 +2276,7 @@ const ChatScreen: React.FC = () => {
// die Session auch app-seitig haben wir +2s Toleranz.
noSpeechTimeoutMs: 0,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 300000,
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current,
});
if (!ok) {
@@ -2647,6 +2668,16 @@ const ChatScreen: React.FC = () => {
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
</Text>
</TouchableOpacity>
) : att.type === 'audio' ? (
<View style={styles.attachmentFile}>
<Text style={styles.attachmentFileIcon}>{'🎙'}</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>
{att.name || 'Sprachaufnahme'}
</Text>
{typeof item.durationS === 'number' && item.durationS > 0 ? (
<Text style={styles.attachmentFileSize}>{formatDur(item.durationS)}</Text>
) : null}
</View>
) : (
<TouchableOpacity
style={styles.attachmentFile}
+30 -61
View File
@@ -67,6 +67,10 @@ import {
CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY,
STT_ENDPOINT_DEFAULT_MS,
STT_ENDPOINT_MIN_MS,
STT_ENDPOINT_MAX_MS,
STT_ENDPOINT_STORAGE_KEY,
MAX_RECORDING_DEFAULT_SEC,
MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC,
@@ -195,6 +199,9 @@ const SettingsScreen: React.FC = () => {
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
@@ -306,6 +313,14 @@ const SettingsScreen: React.FC = () => {
}
}
});
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseInt(saved, 10);
if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) {
setSttEndpointSec(n / 1000);
}
}
});
AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
@@ -1655,30 +1670,30 @@ const SettingsScreen: React.FC = () => {
<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.
Nachdenken (z.B. im Auto); niedriger = schnelleres Senden.
Default: {(STT_ENDPOINT_DEFAULT_MS / 1000).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));
const next = Math.max(STT_ENDPOINT_MIN_MS / 1000, Math.round((sttEndpointSec - 0.5) * 10) / 10);
setSttEndpointSec(next);
AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000)));
}}
disabled={vadSilenceSec <= VAD_SILENCE_MIN_SEC}
disabled={sttEndpointSec <= STT_ENDPOINT_MIN_MS / 1000}
>
<Text style={styles.prerollButtonText}>0.5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text>
<Text style={styles.prerollValue}>{sttEndpointSec.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));
const next = Math.min(STT_ENDPOINT_MAX_MS / 1000, Math.round((sttEndpointSec + 0.5) * 10) / 10);
setSttEndpointSec(next);
AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000)));
}}
disabled={vadSilenceSec >= VAD_SILENCE_MAX_SEC}
disabled={sttEndpointSec >= STT_ENDPOINT_MAX_MS / 1000}
>
<Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity>
@@ -1749,56 +1764,10 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity>
</View>
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
<Text style={styles.infoBtnText}>i</Text>
</TouchableOpacity>
</View>
<Text style={styles.toggleHint}>
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT - 1
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = vadSilenceDb == null
? VAD_SILENCE_DB_DEFAULT + 1
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
setVadSilenceDb(next);
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
}}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
{vadSilenceDb != null && (
<TouchableOpacity
onPress={() => {
setVadSilenceDb(null);
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
}}
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
>
<Text style={{color: '#0096FF', fontSize: 13}}> Auf automatisch zuruecksetzen</Text>
</TouchableOpacity>
)}
{/* "Stille-Pegel (dB)"-Regler entfernt: der aktive Streaming-STT nutzt
einen adaptiven Rausch-Boden (automatisch), ein manueller dB-Wert war
wirkungslos. Rauschen-als-Wort verhindert das STT-Modell selbst
(no_speech_prob-Filter), nicht die dB-Schwelle. */}
</View>
<Modal
+104
View File
@@ -0,0 +1,104 @@
/**
* ariaView — Empfaenger der von ARIA komponierten RAEUMLICHEN Ansichten (M1).
*
* Fluss: ARIA ruft im Brain `present_view` → Brain-Event `aria_view` → Bridge →
* RVS `aria_view` → hier gepuffert → WorkspaceCanvas rendert Orb + Karten, die
* auf der Flaeche materialisieren.
*
* Der Service haelt pro Projekt die AKTUELLE View-Spec, damit eine spaet
* gemountete Canvas-Kachel sofort den Ist-Stand bekommt. Muster wie
* services/codeFile.ts (Singleton, rvs.onMessage).
*
* Die Karten-Typen sind bewusst offen (string), damit spaetere Renderer (vnc,
* chart, file …) ohne Service-Aenderung dazukommen. Der jeweilige Client-Renderer
* entscheidet, was er mit einem unbekannten Typ macht (i.d.R. ignorieren).
*/
import rvs, { RVSMessage } from './rvs';
export type OrbState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'working';
export interface ViewMarker {
lat: number;
lon: number;
label?: string;
}
export interface ViewCard {
type: 'text' | 'image' | 'map' | 'code' | 'list' | string;
title?: string;
md?: string; // text/list
src?: string; // image
markers?: ViewMarker[]; // map
path?: string; // code
lang?: string; // code
// Zukuenftige Kartenfelder ohne Service-Aenderung:
[k: string]: any;
}
export interface ViewSpec {
cards: ViewCard[];
orb?: OrbState;
title?: string;
}
export interface AriaView {
projectId: string;
view: ViewSpec;
clientMsgId?: string;
ts: number;
}
type ViewSub = (v: AriaView) => void;
class AriaViewService {
private views = new Map<string, AriaView>();
private subs: ViewSub[] = [];
constructor() {
rvs.onMessage((m) => this.onMessage(m));
}
private onMessage(m: RVSMessage): void {
if (m.type !== 'aria_view') return;
const p = (m.payload || {}) as any;
const raw = (p.view || {}) as any;
const cards: ViewCard[] = Array.isArray(raw.cards) ? raw.cards : [];
if (cards.length === 0) return; // leere Ansicht ignorieren
const view: ViewSpec = {
cards,
orb: raw.orb || 'speaking',
title: raw.title || '',
};
const projectId: string = p.projectId || '';
const entry: AriaView = {
projectId,
view,
clientMsgId: p.clientMsgId || '',
ts: Date.now(),
};
this.views.set(projectId, entry);
this.subs.forEach((cb) => {
try { cb(entry); } catch {}
});
}
/** Aktuelle Ansicht eines Projekts (leer = Hauptchat). */
getView(projectId: string): AriaView | undefined {
return this.views.get(projectId || '');
}
/** Registriert einen Listener fuer neue Ansichten. */
subscribe(cb: ViewSub): () => void {
this.subs.push(cb);
return () => { this.subs = this.subs.filter((s) => s !== cb); };
}
/** Ansicht eines Projekts verwerfen (z.B. wenn der User sie wegwischt). */
clear(projectId: string): void {
this.views.delete(projectId || '');
}
}
const ariaView = new AriaViewService();
export default ariaView;
+1 -1
View File
@@ -1145,7 +1145,7 @@ class AudioService {
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
interrupted: !!opts.interrupted,
location: opts.location || null,
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : STT_ENDPOINT_DEFAULT_MS,
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
projectId: opts.projectId || '',
+11
View File
@@ -145,6 +145,17 @@ class GpsTrackingService {
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
const bgEnabled = await isBackgroundGpsEnabled();
if (bgEnabled) {
// Ohne ACCESS_BACKGROUND_LOCATION liefert watchPosition im Hintergrund
// NICHTS (Android 10+) → der Foreground-Service allein bringt nichts, und
// genau der Fall "Ankunft waehrend der Fahrt, Screen aus" faellt durch.
// Deshalb erst die Permission sicherstellen (oeffnet ggf. die Android-
// Settings fuer "Immer erlauben"), DANN den Location-Foreground-Service
// hochziehen — der haelt den Prozess wach, sodass watchPosition + der
// 60s-Heartbeat auch unter Doze weiterlaufen.
const bgOk = await ensureBackgroundLocationPermission();
if (!bgOk) {
console.warn('[gps-track] Background-Permission fehlt — Tracking nur im Vordergrund zuverlaessig');
}
try { await acquireBackgroundAudio('location'); } catch {}
}
try {
+175
View File
@@ -0,0 +1,175 @@
/**
* AriaViewCanvas — die pannbare Flaeche, auf der ARIAs komponierte Ansicht
* (aria_view) MATERIALISIERT: Orb oben, darunter die Karten. Erscheint als
* Overlay ueber dem Chat, sobald ARIA present_view aufruft ("sag was → Orb denkt
* → Karte fliegt rein"). Der erste, greifbare Vorgeschmack aufs generative
* Cockpit (M1).
*
* Bedienung (NoMachine-Prinzip): 2-Finger halten + schieben bewegt die Welt,
* Pinch zoomt. Ein-Finger-Touch geht an die Karten durch (Scrollen). Die Welt
* traegt gerenderte/gestreamte Inhalte — interaktive native Panels rasten
* spaeter bei Scale 1 ein (Chat bleibt separat darunter).
*
* Geraete-agnostisch gehalten: liest nur die ViewSpec, damit ein spaeterer Web-/
* AR-Renderer dieselbe Spec konsumieren kann.
*/
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, {
FadeInDown,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { ViewSpec } from '../services/ariaView';
import Orb from './Orb';
import CardView from './CardView';
const MIN_SCALE = 0.5;
const MAX_SCALE = 3;
interface Props {
view: ViewSpec;
onClose: () => void;
}
const AriaViewCanvas: React.FC<Props> = ({ view, onClose }) => {
const tx = useSharedValue(0);
const ty = useSharedValue(0);
const scale = useSharedValue(1);
const savedTx = useSharedValue(0);
const savedTy = useSharedValue(0);
const savedScale = useSharedValue(1);
const pan = Gesture.Pan()
.minPointers(2)
.maxPointers(2)
.onUpdate((e) => {
tx.value = savedTx.value + e.translationX;
ty.value = savedTy.value + e.translationY;
})
.onEnd(() => {
savedTx.value = tx.value;
savedTy.value = ty.value;
});
const pinch = Gesture.Pinch()
.onUpdate((e) => {
const next = savedScale.value * e.scale;
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, next));
})
.onEnd(() => {
savedScale.value = scale.value;
});
const composed = Gesture.Simultaneous(pan, pinch);
const worldStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: tx.value },
{ translateY: ty.value },
{ scale: scale.value },
],
}));
const resetCamera = () => {
tx.value = withTiming(0);
ty.value = withTiming(0);
scale.value = withTiming(1);
savedTx.value = 0;
savedTy.value = 0;
savedScale.value = 1;
};
const cards = Array.isArray(view.cards) ? view.cards : [];
return (
<View style={styles.overlay}>
<GestureDetector gesture={composed}>
<Animated.View style={[styles.world, worldStyle]}>
<View style={styles.orbWrap}>
<Orb state={view.orb} size={110} />
</View>
{!!view.title && <Text style={styles.worldTitle}>{view.title}</Text>}
<View style={styles.cards}>
{cards.map((c, i) => (
<Animated.View
key={i}
entering={FadeInDown.duration(420).delay(120 + i * 90)}
>
<CardView card={c} />
</Animated.View>
))}
</View>
</Animated.View>
</GestureDetector>
{/* Steuerung — ausserhalb des Transforms, immer bei Scale 1 bedienbar */}
<View style={styles.topBar} pointerEvents="box-none">
<TouchableOpacity style={styles.iconBtn} onPress={resetCamera}>
<Text style={styles.icon}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.iconBtn} onPress={onClose}>
<Text style={styles.icon}></Text>
</TouchableOpacity>
</View>
<View style={styles.hintWrap} pointerEvents="none">
<Text style={styles.hint}>2 Finger: schieben · Pinch: zoomen</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(6,6,16,0.94)',
zIndex: 50,
},
world: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
paddingTop: 48,
paddingHorizontal: 18,
},
orbWrap: { marginTop: 8, marginBottom: 6 },
worldTitle: {
color: '#C9C9FF',
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
textAlign: 'center',
},
cards: { width: '100%', maxWidth: 560 },
topBar: {
position: 'absolute',
top: 10,
right: 12,
flexDirection: 'row',
},
iconBtn: {
width: 40,
height: 40,
borderRadius: 20,
marginLeft: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(30,30,60,0.9)',
borderWidth: 1,
borderColor: 'rgba(123,92,255,0.4)',
},
icon: { color: '#C9C9FF', fontSize: 18 },
hintWrap: {
position: 'absolute',
bottom: 14,
alignSelf: 'center',
},
hint: {
color: '#6A6A90',
fontSize: 12,
},
});
export default AriaViewCanvas;
+114
View File
@@ -0,0 +1,114 @@
/**
* CardView — rendert EINE Karte einer aria_view-Spec (M1). Schaltet nach
* card.type auf den passenden Renderer. Unbekannte Typen werden als Text-
* Fallback gezeigt (nie crashen).
*
* Bewusst dependency-leicht (v1): Markdown wird als Klartext dargestellt, Map
* als Marker-Liste (kein Karten-Lib), Code als Monospace-Block. Spaeter koennen
* einzelne Renderer aufgebohrt werden, ohne die Spec/den Fluss zu aendern.
*/
import React from 'react';
import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ViewCard, ViewMarker } from '../services/ariaView';
const ImageBody: React.FC<{ src?: string }> = ({ src }) => {
const isUrl = !!src && /^https?:\/\//i.test(src);
if (isUrl) {
return <Image source={{ uri: src }} style={styles.image} resizeMode="contain" />;
}
return <Text style={styles.muted}>🖼 {src || '(kein Bild)'}</Text>;
};
const ListBody: React.FC<{ md?: string }> = ({ md }) => {
const lines = (md || '')
.split('\n')
.map((l) => l.replace(/^\s*[-*•]\s?/, '').trim())
.filter(Boolean);
if (lines.length === 0) return <Text style={styles.muted}>(leer)</Text>;
return (
<View>
{lines.map((l, i) => (
<View key={i} style={styles.listRow}>
<Text style={styles.bullet}></Text>
<Text style={styles.text}>{l}</Text>
</View>
))}
</View>
);
};
const MapBody: React.FC<{ markers?: ViewMarker[] }> = ({ markers }) => {
const ms = Array.isArray(markers) ? markers : [];
return (
<View style={styles.map}>
<Text style={styles.mapHint}>🗺 Karte ({ms.length} Orte)</Text>
{ms.map((m, i) => (
<Text key={i} style={styles.text}>
📍 {m.label || `${m.lat?.toFixed?.(4)}, ${m.lon?.toFixed?.(4)}`}
</Text>
))}
</View>
);
};
const CodeBody: React.FC<{ md?: string; path?: string; lang?: string }> = ({ md, path, lang }) => (
<View>
{(path || lang) && (
<Text style={styles.codeCaption}>
{path || ''}{lang ? ` · ${lang}` : ''}
</Text>
)}
<ScrollView horizontal style={styles.codeScroll}>
<Text style={styles.code}>{md || ''}</Text>
</ScrollView>
</View>
);
const CardView: React.FC<{ card: ViewCard }> = ({ card }) => {
return (
<View style={styles.card}>
{!!card.title && <Text style={styles.cardTitle}>{card.title}</Text>}
{card.type === 'image' ? (
<ImageBody src={card.src} />
) : card.type === 'list' ? (
<ListBody md={card.md} />
) : card.type === 'map' ? (
<MapBody markers={card.markers} />
) : card.type === 'code' ? (
<CodeBody md={card.md} path={card.path} lang={card.lang} />
) : (
<Text style={styles.text}>{card.md || ''}</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: 'rgba(18,18,42,0.92)',
borderColor: 'rgba(123,92,255,0.35)',
borderWidth: 1,
borderRadius: 14,
padding: 14,
marginVertical: 8,
shadowColor: '#7B5CFF',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 2 },
elevation: 6,
},
cardTitle: { color: '#C9C9FF', fontSize: 15, fontWeight: '700', marginBottom: 8 },
text: { color: '#E6E6F0', fontSize: 14, lineHeight: 20, flexShrink: 1 },
muted: { color: '#8A8AB0', fontSize: 13, fontStyle: 'italic' },
image: { width: '100%', height: 200, borderRadius: 8, backgroundColor: '#0D0D1A' },
listRow: { flexDirection: 'row', alignItems: 'flex-start', marginVertical: 2 },
bullet: { color: '#7B5CFF', marginRight: 8, fontSize: 14, lineHeight: 20 },
map: { backgroundColor: '#0D0D1A', borderRadius: 8, padding: 10 },
mapHint: { color: '#00B4D8', fontSize: 13, fontWeight: '600', marginBottom: 6 },
codeCaption: { color: '#8A8AB0', fontSize: 12, marginBottom: 6 },
codeScroll: { backgroundColor: '#0A0A14', borderRadius: 8, padding: 10 },
code: { color: '#B9F5C9', fontFamily: 'monospace', fontSize: 12.5, lineHeight: 18 },
});
export default React.memo(CardView);
+106
View File
@@ -0,0 +1,106 @@
/**
* Orb — ARIAs Praesenz-Avatar (M1). Zeigt ihren Zustand (idle/listening/
* thinking/speaking/working) als pulsierender Leucht-Kern und ist das
* verbindende Element ueber alle Oberflaechen (App/Web/spaeter Brille).
*
* Reine Optik, keine Logik — der Zustand kommt von aussen (aria_view.orb bzw.
* spaeter direkt von Audio/Wake-Word-Signalen). Dependency-leicht: nur
* reanimated (schon installiert), kein SVG/Gradient noetig.
*/
import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { OrbState } from '../services/ariaView';
const COLORS: Record<OrbState, string> = {
idle: '#3A6EA5',
listening: '#00B4D8',
thinking: '#7B5CFF',
speaking: '#34C759',
working: '#FF9500',
};
interface Props {
state?: OrbState;
size?: number;
}
const Orb: React.FC<Props> = ({ state = 'idle', size = 120 }) => {
const pulse = useSharedValue(1);
useEffect(() => {
const fast = state === 'thinking' || state === 'working';
cancelAnimation(pulse);
pulse.value = 1;
pulse.value = withRepeat(
withTiming(fast ? 1.14 : 1.07, {
duration: fast ? 620 : 1500,
easing: Easing.inOut(Easing.ease),
}),
-1,
true,
);
return () => cancelAnimation(pulse);
}, [state, pulse]);
const animStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] }));
const color = COLORS[state] || COLORS.idle;
return (
<View style={[styles.wrap, { width: size, height: size }]}>
<Animated.View
style={[
styles.glow,
{ width: size, height: size, borderRadius: size / 2, backgroundColor: color },
animStyle,
]}
/>
<Animated.View
style={[
styles.ring,
{
width: size * 0.72,
height: size * 0.72,
borderRadius: size * 0.36,
borderColor: color,
},
animStyle,
]}
/>
<View
style={[
styles.core,
{
width: size * 0.44,
height: size * 0.44,
borderRadius: size * 0.22,
backgroundColor: color,
shadowColor: color,
},
]}
/>
</View>
);
};
const styles = StyleSheet.create({
wrap: { alignItems: 'center', justifyContent: 'center' },
glow: { position: 'absolute', opacity: 0.22 },
ring: { position: 'absolute', borderWidth: 2, opacity: 0.55 },
core: {
shadowOpacity: 0.9,
shadowRadius: 16,
shadowOffset: { width: 0, height: 0 },
elevation: 12,
},
});
export default React.memo(Orb);
+37 -6
View File
@@ -9,6 +9,7 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { View } from 'react-native';
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
import codeFile from '../services/codeFile';
import brainApi from '../services/brainApi';
@@ -16,6 +17,8 @@ import viewMode, { ViewModeValue } from '../services/viewMode';
import ChatScreen from '../screens/ChatScreen';
import { TileId } from './layout';
import WorkspaceDeck from './WorkspaceDeck';
import ariaView, { AriaView } from '../services/ariaView';
import AriaViewCanvas from './AriaViewCanvas';
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
@@ -24,12 +27,21 @@ const WorkspaceScreen: React.FC = () => {
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
const [hasCode, setHasCode] = useState(false);
const [hasDesktop, setHasDesktop] = useState(false);
const [view, setView] = useState<AriaView | undefined>(undefined);
useEffect(() => viewMode.subscribe(setMode), []);
useEffect(() => projectFocus.subscribe(setFocus), []);
const pid = focus.focusedProjectId;
// aria_view: ARIAs komponierte Ansicht fuers fokussierte Projekt spiegeln.
useEffect(() => {
setView(ariaView.getView(pid));
return ariaView.subscribe((v) => {
if ((v.projectId || '') === (pid || '')) setView(v);
});
}, [pid]);
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
useEffect(() => {
setHasCode(codeFile.getFiles(pid).length > 0);
@@ -59,13 +71,32 @@ const WorkspaceScreen: React.FC = () => {
vnc: hasDesktop ? '#34C759' : undefined,
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
if (mode === 'compact') {
return <ChatScreen />;
}
// Kompakt-Ansicht: klassischer Vollbild-Chat; Cockpit: Workbench mit Dock.
const content =
mode === 'compact' ? (
<ChatScreen />
) : (
<WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />
);
// Cockpit: Workbench mit Dock.
return <WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />;
// Generative Flaeche als Overlay, sobald ARIA fuer dieses Projekt eine Ansicht
// komponiert hat (present_view → aria_view). Chat/Cockpit bleiben darunter.
const showView = !!view && (view.projectId || '') === (pid || '');
return (
<View style={{ flex: 1 }}>
{content}
{showView && view && (
<AriaViewCanvas
view={view.view}
onClose={() => {
ariaView.clear(pid);
setView(undefined);
}}
/>
)}
</View>
);
};
export default WorkspaceScreen;
+104 -6
View File
@@ -627,10 +627,11 @@ META_TOOLS = [
"name": "request_location_tracking",
"description": (
"Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu "
"deaktivieren. Default ist AUS (Akku-Schutz). Nutze das wenn du einen "
"GPS-basierten Watcher anlegst (z.B. `near(...)`), sonst hat die App "
"veraltete Position und der Watcher feuert nie. Auch wieder ausschalten "
"wenn der letzte GPS-Watcher geloescht wurde."
"deaktivieren. Default ist AUS (Akku-Schutz). HINWEIS: Beim Anlegen/Loeschen "
"eines Standort-Watchers (`near/entered_near/left_near`) schaltet das System "
"das Tracking bereits AUTOMATISCH mit an bzw. aus — dieses Tool brauchst du "
"dafuer nicht mehr. Nutze es nur fuer manuelle/explizite Faelle (z.B. Tracking "
"kurz einschalten ohne Watcher)."
),
"parameters": {
"type": "object",
@@ -642,6 +643,48 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "present_view",
"description": (
"Komponiere eine RAEUMLICHE Ansicht, die auf ARIAs Flaeche materialisiert "
"(Orb + Karten). Nutze das, wenn eine visuelle Anordnung besser ist als reiner "
"Text — z.B. eine Akte/Datei zeigen (Bild links, Text rechts, Karte unten), "
"einen Vergleich, eine Liste, eine Karte mit Orten, oder Code. NICHT fuer "
"normale Gespraechsantworten. Die Karten erscheinen ZUSAETZLICH zu deiner "
"(kurzen) Sprachantwort — halte die Antwort dann knapp, das Visuelle traegt."
),
"parameters": {
"type": "object",
"properties": {
"cards": {
"type": "array",
"description": "Die Karten der Ansicht, in sinnvoller Anordnung.",
"items": {
"type": "object",
"properties": {
"type": {"type": "string", "enum": ["text", "image", "map", "code", "list"],
"description": "Kartentyp"},
"title": {"type": "string", "description": "Optionaler Kartentitel"},
"md": {"type": "string", "description": "text/list: Markdown-Inhalt"},
"src": {"type": "string", "description": "image: Bild-URL oder /shared-Pfad"},
"markers": {"type": "array", "items": {"type": "object"},
"description": "map: Liste [{lat, lon, label}]"},
"path": {"type": "string", "description": "code: Datei-Pfad im Projekt"},
"lang": {"type": "string", "description": "code: Sprache fuers Highlighting"},
},
"required": ["type"],
},
},
"orb": {"type": "string", "enum": ["idle", "speaking", "working"],
"description": "Orb-Zustand nach dem Rendern (default: speaking)"},
"title": {"type": "string", "description": "Optionaler Titel der ganzen Ansicht"},
},
"required": ["cards"],
},
},
},
{
"type": "function",
"function": {
@@ -1355,6 +1398,17 @@ def _skill_to_tool(s: dict) -> dict:
}
# Standort-Funktionen in Watcher-Conditions — brauchen aktives GPS-Tracking.
_LOCATION_FUNCS = ("near(", "entered_near(", "left_near(")
def _condition_needs_location(condition: str) -> bool:
"""True, wenn die Watcher-Condition eine Standort-Funktion nutzt und damit
laufende GPS-Updates der App braucht."""
c = condition or ""
return any(fn in c for fn in _LOCATION_FUNCS)
class Agent:
# Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
# System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
@@ -2372,13 +2426,41 @@ class Agent:
"trigger": {"name": t["name"], "type": "watcher",
"condition": t["condition"], "message": t["message"]},
})
return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'."
# GPS-Kopplung: ein Standort-Watcher (near/entered_near/left_near)
# ist tot, wenn die App kein Tracking sendet. Frueher musste ARIA
# separat request_location_tracking aufrufen — wurde oft vergessen,
# dann feuerte der Trigger nie. Jetzt automatisch mit-anschalten.
extra = ""
if _condition_needs_location(t["condition"]):
self._pending_events.append({
"type": "location_tracking",
"on": True,
"reason": f"watcher:{t['name']}",
})
extra = " GPS-Tracking automatisch aktiviert."
return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'.{extra}"
if name == "trigger_cancel":
try:
triggers_mod.delete(arguments["name"])
return f"OK — Trigger '{arguments['name']}' geloescht."
except ValueError as e:
return f"FEHLER: {e}"
# GPS-Kopplung (Gegenstueck): existiert kein Standort-Watcher mehr,
# Tracking wieder ausschalten (Akku schonen).
extra = ""
remaining = triggers_mod.list_triggers(active_only=False)
still_needs_gps = any(
r.get("type") == "watcher"
and _condition_needs_location(r.get("condition") or "")
for r in remaining
)
if not still_needs_gps:
self._pending_events.append({
"type": "location_tracking",
"on": False,
"reason": "no-location-watchers",
})
extra = " GPS-Tracking deaktiviert (kein Standort-Watcher mehr)."
return f"OK — Trigger '{arguments['name']}' geloescht.{extra}"
if name == "request_location_tracking":
on = bool(arguments.get("on", False))
reason = (arguments.get("reason") or "").strip()
@@ -2388,6 +2470,22 @@ class Agent:
"reason": reason,
})
return f"OK — Tracking-Request gesendet (on={on}). App wird in Kuerze umschalten."
if name == "present_view":
cards = arguments.get("cards") or []
if not isinstance(cards, list) or not cards:
return "FEHLER: present_view braucht mindestens eine Karte in 'cards'."
view = {
"cards": cards,
"orb": arguments.get("orb") or "speaking",
"title": arguments.get("title") or "",
}
self._pending_events.append({
"type": "aria_view",
"view": view,
"project_id": project_id or "",
})
return (f"OK — Ansicht mit {len(cards)} Karte(n) an die Flaeche geschickt "
f"(Kontext: {project_id or 'Hauptchat'}).")
if name == "trigger_list":
items = triggers_mod.list_triggers(active_only=False)
if not items:
+16
View File
@@ -2080,6 +2080,22 @@ class ARIABridge:
proj = event.get("project") or {}
logger.info("[brain] Projekt %s: %s (id=%s)",
event.get("action") or "?", proj.get("name"), proj.get("id"))
elif etype == "aria_view":
# M1: ARIA hat via present_view eine raeumliche Ansicht komponiert.
# View-Spec (Orb + Karten) + Projekt-Kontext an App/Web/Diagnostic;
# deren Renderer materialisieren die Karten auf der Flaeche.
view = event.get("view") or {}
await self._send_to_rvs({
"type": "aria_view",
"payload": {
"view": view,
"projectId": event.get("project_id") or "",
"clientMsgId": client_msg_id or "",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[brain] ARIA hat eine Ansicht geschickt: %d Karte(n), orb=%s",
len(view.get("cards") or []), view.get("orb"))
# _process_core_response uebernimmt alles weitere:
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
+20 -8
View File
@@ -1767,14 +1767,21 @@
if (msg.type === 'disk_cleanup') {
const btn = document.getElementById('disk-clean-btn');
const dtext = document.getElementById('disk-banner-text');
if (msg.status === 'running') {
if (btn) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
} else if (msg.status === 'done') {
if (btn) { btn.disabled = false; btn.textContent = 'Sicher aufraeumen'; }
alert('Aufgeraeumt: ' + (msg.freed || '0 MB') + ' frei\n(' + (msg.steps || []).join(', ') + ')');
// Feedback NICHT nur per alert() (koennte unterdrueckt sein) —
// direkt am Button + im Banner sichtbar machen.
if (btn) {
btn.disabled = false;
btn.textContent = '✓ ' + (msg.freed || '0 MB') + ' frei';
setTimeout(() => { btn.textContent = 'Sicher aufraeumen'; }, 6000);
}
if (dtext) dtext.textContent = 'Aufgeraeumt: ' + (msg.freed || '0 MB') + ' frei (' + (msg.steps || []).join(', ') + '). Disk-Status aktualisiert sich gleich.';
} else if (msg.status === 'error') {
if (btn) { btn.disabled = false; btn.textContent = 'Sicher aufraeumen'; }
alert('Aufraeumen fehlgeschlagen: ' + (msg.error || ''));
if (btn) { btn.disabled = false; btn.textContent = 'Fehler — nochmal'; setTimeout(() => { btn.textContent = 'Sicher aufraeumen'; }, 6000); }
if (dtext) dtext.textContent = 'Aufraeumen fehlgeschlagen: ' + (msg.error || '');
}
return;
}
@@ -6776,12 +6783,17 @@
// Fuehrt das Aufraeumen WIRKLICH aus (Server-seitig via Docker-API), statt
// nur den Befehl zu kopieren.
// WICHTIG: "safe" laeuft OHNE confirm() — Build-Cache + ungenutzte Images
// sind ungefaehrlich (keine Volumes/Daten). Frueher blockte ein confirm()
// den Klick, wenn der Browser Dialoge unterdrueckt hatte ("Verhindern,
// dass diese Seite weitere Dialoge erstellt") → confirm()===false → es ging
// NICHTS raus. Nur "aggressive" (Volumes!) fragt noch nach.
function runDiskCleanup(variant) {
const aggressive = variant === 'aggressive';
const q = aggressive
? 'AGGRESSIV aufraeumen? Loescht zusaetzlich ungenutzte Volumes — nur wenn ALLE ARIA-Container laufen, sonst Datenverlust!'
: 'Sicher aufraeumen? Loescht Build-Cache + ungenutzte Images (keine Volumes, keine Daten gehen verloren).';
if (!confirm(q)) return;
if (aggressive) {
const ok = confirm('AGGRESSIV aufraeumen? Loescht zusaetzlich ungenutzte Volumes — nur wenn ALLE ARIA-Container laufen, sonst Datenverlust!');
if (!ok) return;
}
const btn = document.getElementById('disk-clean-btn');
if (btn && !aggressive) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
send({ action: 'disk_cleanup', variant });
+4
View File
@@ -70,6 +70,10 @@ const ALLOWED_TYPES = new Set([
// spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS
// getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig).
"code_file", "code_file_edit",
// M1 Generatives Cockpit: ARIA komponiert via present_view eine View-Spec
// (Orb + Karten), die App/Web/Diagnostic mit ihrem jeweiligen Renderer
// materialisieren. Brain → Bridge → RVS → Clients.
"aria_view",
"check_desktop", "desktop_status",
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
// Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit
+34 -3
View File
@@ -30,7 +30,7 @@ services:
reservations:
devices:
- driver: nvidia
count: 1
device_ids: ["0"] # TTS → GPU 0 (8 GB; F5 ist klein)
capabilities: [gpu]
volumes:
- ./voices:/voices # WAV + TXT Referenz
@@ -63,12 +63,13 @@ services:
whisper-bridge:
build: ./whisper
container_name: aria-whisper-bridge
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
device_ids: ["1"] # STT/groesstes Modell → GPU 1 (12 GB; spaeter Voxtral-STT-3B ~9 GB)
capabilities: [gpu]
environment:
- RVS_HOST=${RVS_HOST}
@@ -108,7 +109,7 @@ services:
reservations:
devices:
- driver: nvidia
count: 1
device_ids: ["0"] # LLM → GPU 0 (8 GB, teilt sich mit TTS)
capabilities: [gpu]
volumes:
- ./models:/models # HF-Download-Cache (persistent)
@@ -137,3 +138,33 @@ services:
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped
# ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
# immer nur EINEN STT laufen lassen.
voxtral-bridge:
build: ./voxtral
container_name: aria-voxtral-bridge
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
capabilities: [gpu]
volumes:
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
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}
- VOXTRAL_MODEL=mistralai/Voxtral-Mini-3B-2507
- VOXTRAL_LANGUAGE=${WHISPER_LANGUAGE:-de}
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
restart: unless-stopped
+8 -4
View File
@@ -9,12 +9,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
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
# torch FEST auf cu124 (Treiber 550 = CUDA 12.4). 2.6.0 ist der NEUESTE cu124-
# Build — torch 2.7+ gibt es nur noch fuer cu126+, was 550 nicht unterstuetzt
# ("NVIDIA driver too old, found 12040"). Der Constraint haelt f5-tts davon ab,
# torch beim Dependency-Aufloesen wieder auf eine zu neue CUDA-Version zu ziehen.
RUN pip3 install --no-cache-dir torch==2.6.0 torchaudio==2.6.0 \
--index-url https://download.pytorch.org/whl/cu124
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
COPY bridge.py .
+26
View File
@@ -0,0 +1,26 @@
# Voxtral-STT-3B Bridge (Transformers). Laeuft auf Treiber 550/CUDA 12.4 via
# torch cu124 — KEIN Treiber-Upgrade noetig (gleicher Trick wie f5tts).
# Modell: Voxtral-Mini-3B-2507 (~9 GB in bf16) → passt auf die 12-GB-Karte (GPU 1).
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg git \
&& rm -rf /var/lib/apt/lists/*
# torch FEST auf cu124 (Treiber 550 = CUDA 12.4). 2.6.0 ist der neueste cu124-Build;
# torch 2.7+ gibt es nur fuer cu126+ und braeuchte einen neueren Treiber.
RUN pip3 install --no-cache-dir torch==2.6.0 torchaudio==2.6.0 \
--index-url https://download.pytorch.org/whl/cu124
COPY requirements.txt .
# Constraint haelt transformers/mistral-common davon ab, torch wieder hochzuziehen.
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
COPY bridge.py .
CMD ["python3", "bridge.py"]
+47
View File
@@ -0,0 +1,47 @@
# Voxtral-STT-3B-Satellit (Transformers)
Streaming-STT via **Voxtral-Mini-3B-2507** (Mistral, Apache 2.0) über
🤗 Transformers. Ersetzt whisper als STT — genauer, und **ohne Treiber-Upgrade**:
läuft auf dem Trixie-Standardtreiber (550/CUDA 12.4) via **torch 2.6.0+cu124**
(derselbe Trick wie bei f5tts).
- Modell ~9 GB (bf16) → **GPU 1** (die 12-GB-Karte; per Compose gepinnt).
- **F5-TTS + LLM** bleiben auf GPU 0 (8 GB).
- Arbeitsweise = chunked wie whisper: live PCM → alle ~1 s transkribieren
(Partials) → **adaptiver Endpointer** (Rausch-Boden-VAD + semantische
Stagnation, aus M0.1) feuert `stt_endpoint`. RVS-Protokoll identisch → drop-in.
## Starten (Profil `voxtral`)
```bash
cd xtts
docker compose stop whisper-bridge # sonst beantworten beide stt_*
docker compose --profile voxtral up -d --build
docker logs -f aria-voxtral-bridge # Modell laedt (mehrere GB), dann "RVS verbunden"
```
Zurück zu whisper: `docker compose --profile voxtral down && docker compose up -d whisper-bridge`.
## ⚠️ Auf echter Hardware verifizieren (blind gebaut)
1. **Transformers-API.** Die exakte Voxtral-Transkriptions-API ist in `bridge.py`
in **einer** Methode gekapselt (`VoxtralRunner._transcribe_blocking`), modelliert
nach der HF-Modelcard (`apply_transcription_request``generate``batch_decode`).
Beim ersten Lauf gegen die Modelcard prüfen und dort anpassen.
2. **HF-Gating.** Ist `Voxtral-Mini-3B-2507` gated, `HF_TOKEN` in `xtts/.env` setzen
(wird als `HUGGING_FACE_HUB_TOKEN` durchgereicht).
3. **VRAM/Tempo.** 3B in bf16 ~9 GB auf der 12-GB-Karte — Rest fürs KV-Cache. Ist die
Partial-Transkription (alle 1 s) zu schwer, `STREAM_TRANSCRIBE_INTERVAL_MS` hochsetzen.
4. **torch-Konflikt.** Falls `transformers`/`mistral-common` beim Build torch>2.6
erzwingen, meldet der Constraint einen Konflikt → dann brauchen wir doch das
Treiber-Upgrade (`bootstrap.sh --upgrade-driver` via NVIDIA-CUDA-Repo) + cu126-torch.
## Protokoll (RVS, identisch zu whisper — drop-in)
Rein: `stt_stream_start`, `stt_audio_chunk` (16 kHz mono s16le, base64), `stt_stream_end`.
Raus: `stt_partial`, `stt_endpoint`, `stt_stream_done`.
## TTS
Bleibt **F5-TTS** (klingt gut, passt auf GPU 0). Voxtral-TTS bräuchte ~24 GB — separates Thema.
## Quellen
- Modell: https://huggingface.co/mistralai/Voxtral-Mini-3B-2507
- Transformers-Nutzung: HF-Modelcard (Voxtral) + `mistral-common`
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""
ARIA Voxtral-STT-3B Bridge (Transformers) Ersatz fuer whisper.
Laeuft auf Treiber 550/CUDA 12.4 via torch cu124 (kein Treiber-Upgrade noetig).
Modell: Voxtral-Mini-3B-2507 (bf16, ~9 GB) GPU 1 (12 GB, per Compose gepinnt).
Arbeitsweise = Zwilling der whisper-Bridge: App schickt live PCM-Chunks; wir
transkribieren alle ~STREAM_TRANSCRIBE_INTERVAL_MS auf dem Ringbuffer (Partials)
und feuern stt_endpoint, sobald der ADAPTIVE Endpointer (Rausch-Boden-VAD +
semantische Stagnation, aus M0.1) "fertig" sagt. RVS-Wire-Protokoll identisch zu
whisper drop-in (die App merkt nur bessere Genauigkeit).
VERIFY-ON-FIRST-RUN: Die exakte Transformers-Transkriptions-API von Voxtral
(apply_transcription_request / generate / decode) ist unten in EINER Methode
(VoxtralRunner._transcribe_blocking) gekapselt und nach dem HF-Modelcard-Muster
modelliert. Beim ersten echten Lauf gegen die Voxtral-Modelcard pruefen und dort
anpassen. Alles andere (RVS, Endpointer) ist bewaehrt.
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-3B-2507
VOXTRAL_LANGUAGE Default: de
VOXTRAL_DEVICE Default: cuda
STREAM_TRANSCRIBE_INTERVAL_MS Default 1000 (3B ist schwerer als whisper-small)
"""
import asyncio
import base64
import json
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
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("voxtral-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()
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
STREAM_DEFAULT_ENDPOINT_MS = 2400
STREAM_DEFAULT_HARD_CAP_MS = 300000
STREAM_MIN_AUDIO_MS = 600
STREAM_SESSION_TTL_S = 120
STREAM_ENERGY_WINDOW_MS = 300
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
# Adaptiver Voice-Schwellwert (M0.1): relativ zum gemessenen Rausch-Boden.
STREAM_VOICE_FACTOR = 2.5
STREAM_VOICE_RMS_MIN = 0.005
STREAM_VOICE_RMS_MAX = 0.020
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
if not data:
return np.zeros(0, dtype=np.float32)
return np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
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("RVS-Send fehlgeschlagen (%s): %s", mtype, e)
class VoxtralRunner:
"""Haelt das Voxtral-Modell (Transformers). transcribe() blockiert → aus dem
Event-Loop via run_in_executor aufrufen. Ein Lock serialisiert GPU-Zugriffe."""
def __init__(self) -> None:
self.model = None
self.processor = None
self._lock = asyncio.Lock()
def load(self) -> None:
import torch
from transformers import AutoProcessor, VoxtralForConditionalGeneration
t0 = time.time()
logger.info("Lade Voxtral '%s' (device=%s, bf16)…", VOXTRAL_MODEL, VOXTRAL_DEVICE)
self.processor = AutoProcessor.from_pretrained(VOXTRAL_MODEL)
self.model = VoxtralForConditionalGeneration.from_pretrained(
VOXTRAL_MODEL, torch_dtype=torch.bfloat16, device_map=VOXTRAL_DEVICE,
)
logger.info("Voxtral geladen in %.1fs", time.time() - t0)
def _transcribe_blocking(self, audio_f32: np.ndarray, language: str) -> str:
import torch
proc, model = self.processor, self.model
if proc is None or model is None or audio_f32.size == 0:
return ""
# VoxtralProcessor verlangt bei rohen Arrays ein 'format'. Robuster:
# in ein temp-WAV schreiben und den PFAD uebergeben — der Processor liest
# Format + Samplerate selbst, kein 'format'-Argument noetig.
wav_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
wav_path = tf.name
sf.write(wav_path, audio_f32, 16000, subtype="PCM_16")
inputs = proc.apply_transcription_request(
language=language, audio=wav_path, model_id=VOXTRAL_MODEL,
)
inputs = inputs.to(VOXTRAL_DEVICE, dtype=torch.bfloat16)
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=512)
trimmed = outputs[:, inputs.input_ids.shape[1]:]
text = proc.batch_decode(trimmed, skip_special_tokens=True)
return (text[0] if text else "").strip()
finally:
if wav_path:
try:
os.unlink(wav_path)
except Exception:
pass
async def transcribe(self, audio_f32: np.ndarray, language: str) -> str:
loop = asyncio.get_running_loop()
async with self._lock:
return await loop.run_in_executor(None, self._transcribe_blocking, audio_f32, language)
@dataclass
class StreamSession:
request_id: str
audio_request_id: str
language: str
endpoint_ms: int
hard_cap_ms: int
voice: str = ""
speed: float = 1.0
interrupted: bool = False
location: Optional[dict] = None
sample_rate: int = 16000
voice_factor: float = STREAM_VOICE_FACTOR
voice_rms_min: float = STREAM_VOICE_RMS_MIN
voice_rms_max: float = STREAM_VOICE_RMS_MAX
pcm_buffer: bytearray = field(default_factory=bytearray)
started_at: float = field(default_factory=time.time)
last_chunk_at: float = field(default_factory=time.time)
last_partial: str = ""
last_growth_at: float = 0.0
last_transcribe_at: float = 0.0
last_voice_at: float = 0.0
noise_floor: float = 0.0
closed: bool = False
endpoint_sent: bool = False
class SessionManager:
def __init__(self, runner: VoxtralRunner) -> None:
self.runner = runner
self._sessions: dict[str, StreamSession] = {}
self._ws = None
def attach_ws(self, ws) -> None:
self._ws = ws
def start_session(self, payload: dict) -> None:
rid = (payload.get("requestId") or "").strip()
if not rid:
return
try:
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
except (TypeError, ValueError):
endpoint_ms = STREAM_DEFAULT_ENDPOINT_MS
try:
hard_cap_ms = int(payload.get("hardCapMs") or STREAM_DEFAULT_HARD_CAP_MS)
except (TypeError, ValueError):
hard_cap_ms = STREAM_DEFAULT_HARD_CAP_MS
try:
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
except (TypeError, ValueError):
voice_factor = STREAM_VOICE_FACTOR
self._sessions[rid] = StreamSession(
request_id=rid,
audio_request_id=payload.get("audioRequestId", "") or "",
language=payload.get("language") or VOXTRAL_LANGUAGE,
endpoint_ms=endpoint_ms,
hard_cap_ms=hard_cap_ms,
voice=payload.get("voice", "") or "",
speed=float(payload.get("speed") or 1.0),
voice_factor=voice_factor,
interrupted=bool(payload.get("interrupted", False)),
location=payload.get("location") or None,
sample_rate=int(payload.get("sampleRate") or 16000),
)
logger.info("Voxtral-Session offen: id=%s lang=%s endpointMs=%d",
rid[:8], self._sessions[rid].language, endpoint_ms)
def feed_chunk(self, payload: dict) -> bool:
sess = self._sessions.get(payload.get("requestId", ""))
if sess is None or sess.closed:
return False
pcm_b64 = payload.get("pcm", "")
if pcm_b64:
try:
sess.pcm_buffer.extend(base64.b64decode(pcm_b64))
except Exception:
pass
sess.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> None:
sess = self._sessions.get(request_id)
if sess is not None:
sess.closed = True
def drop(self, request_id: str) -> None:
self._sessions.pop(request_id, None)
# ── Endpointer (adaptiv, M0.1) ──
def _buffer_ms(self, sess: StreamSession) -> float:
samples = len(sess.pcm_buffer) // 2
return (samples / sess.sample_rate) * 1000.0 if samples else 0.0
def _tail_rms(self, sess: StreamSession) -> float:
win = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
if win <= 0:
return 0.0
tail = sess.pcm_buffer[-win:]
if len(tail) < 2:
return 0.0
arr = pcm_s16le_to_float32(bytes(tail))
return float(np.sqrt(np.mean(arr * arr))) if arr.size else 0.0
def _voice_threshold(self, sess: StreamSession) -> float:
nf = sess.noise_floor
if nf <= 0.0:
return sess.voice_rms_min
return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max)
def _update_noise_floor(self, sess: StreamSession, rms: float) -> None:
nf = sess.noise_floor
if nf <= 0.0:
sess.noise_floor = rms
elif rms < nf:
sess.noise_floor = 0.90 * nf + 0.10 * rms
else:
sess.noise_floor = 0.98 * nf + 0.02 * rms
async def run_endpointer(self) -> None:
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
STREAM_TRANSCRIBE_INTERVAL_MS)
while True:
await asyncio.sleep(0.2)
now = time.time()
for sid, sess in list(self._sessions.items()):
try:
await self._tick(sess, now)
except Exception:
logger.exception("Tick crashed (session=%s)", sid[:8])
for sid, sess in list(self._sessions.items()):
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
logger.info("Stream %s: TTL — drop", sid[:8])
self.drop(sid)
async def _tick(self, sess: StreamSession, now: float) -> None:
if sess.endpoint_sent:
return
if (now - sess.started_at) * 1000.0 > sess.hard_cap_ms and not sess.closed:
await self._finalize(sess, "hardcap")
return
if sess.closed:
await self._finalize(sess, "stream_end")
return
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
return
# adaptive akustische Sprach-Aktivitaet
rms = self._tail_rms(sess)
if rms >= self._voice_threshold(sess):
sess.last_voice_at = now
else:
self._update_noise_floor(sess, rms)
# Endpoint-Entscheidung, sobald Text erkannt wurde
if sess.last_growth_at > 0.0:
ac_sil = (now - sess.last_voice_at) * 1000.0 if sess.last_voice_at > 0 else 0.0
se_sil = (now - sess.last_growth_at) * 1000.0
ac_done = sess.last_voice_at > 0 and ac_sil >= sess.endpoint_ms
se_done = se_sil >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR
if ac_done or se_done:
await self._finalize(sess, "endpoint" if ac_done else "endpoint_semantic")
return
# Partial-Transkription (throttled)
if (now - sess.last_transcribe_at) * 1000.0 < STREAM_TRANSCRIBE_INTERVAL_MS:
return
sess.last_transcribe_at = now
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
try:
text = (await self.runner.transcribe(audio, sess.language)).strip()
except Exception:
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
return
if text and text != sess.last_partial:
sess.last_partial = text
sess.last_growth_at = now
if self._ws is not None:
await _send(self._ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": text,
})
async def _finalize(self, sess: StreamSession, reason: str) -> None:
if sess.endpoint_sent:
return
sess.endpoint_sent = True
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
t0 = time.time()
try:
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
except Exception:
logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
final_text = sess.last_partial
stt_ms = int((time.time() - t0) * 1000)
duration_s = audio.size / 16000.0
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
if self._ws is not None:
payload = {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": final_text,
"reason": reason,
"durationS": duration_s,
"sttMs": stt_ms,
"voice": sess.voice,
"speed": sess.speed,
"interrupted": sess.interrupted,
}
if sess.location:
payload["location"] = sess.location
await _send(self._ws, "stt_endpoint", payload)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": final_text,
"reason": reason,
})
self.drop(sess.request_id)
async def _broadcast_status(ws, state: str, **extra) -> None:
payload = {"service": "voxtral", "state": state}
payload.update(extra)
await _send(ws, "service_status", payload)
async def run_loop(sessions: SessionManager) -> 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)
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
sessions.attach_ws(ws)
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
await _send(ws, "config_request", {"service": "voxtral"})
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_stream_start":
sessions.start_session(payload)
elif mtype == "stt_audio_chunk":
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
use_tls = False
tls_fallback_tried = True
continue
await asyncio.sleep(retry_s)
retry_s = min(retry_s * 2, 30)
use_tls = RVS_TLS
async def main() -> None:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST/RVS_TOKEN fehlen — .env pruefen. Abbruch.")
return
runner = VoxtralRunner()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
sessions = SessionManager(runner)
logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+9
View File
@@ -0,0 +1,9 @@
# Voxtral-3B via Transformers. torch/torchaudio kommen cu124-gepinnt aus dem
# Dockerfile (nicht hier, sonst zieht pip das Default-CUDA-Wheel).
transformers>=4.54
mistral-common[audio]>=1.8.1
accelerate>=0.30
soundfile>=0.12
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
numpy>=1.24
websockets>=12.0
+49 -2
View File
@@ -75,7 +75,17 @@ STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
# (Whisper oszilliert/halluziniert). Echte akustische Stille ist das robuste
# „User hat aufgehoert"-Signal.
STREAM_ENERGY_WINDOW_MS = 300 # RMS ueber die letzten 300ms Audio messen
STREAM_VOICE_RMS_THRESHOLD = 0.012 # RMS darueber = Sprache (haelt Session am Leben)
# --- Adaptiver Voice-Schwellwert (ersetzt die fixe 0.012-Grenze) ---
# Problem (Repro dokumentiert in audio.ts): eine FESTE RMS-Grenze schneidet
# leises/entferntes Sprechen faelschlich als „Stille" (Handy weiter weg vom Mund,
# ruhig im Auto, kurze Sprech-Pause) → Cut mitten im Satz. Loesung: die Grenze
# relativ zum gemessenen Rausch-Boden der Session fuehren. Sprache =
# noise_floor * Faktor, geklammert auf [MIN, MAX]. Bei noch ungelerntem Boden
# gilt MIN → sensibel, lieber nicht abschneiden.
STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
@@ -323,6 +333,10 @@ class StreamSession:
last_growth_at: float = 0.0
last_transcribe_at: float = 0.0
last_voice_at: float = 0.0 # letzter Tick mit akustischer Sprach-Energie
noise_floor: float = 0.0 # adaptiver Rausch-Boden (0.0 = noch ungelernt)
voice_factor: float = STREAM_VOICE_FACTOR # per-Session konfigurierbar (Payload voiceFactor)
voice_rms_min: float = STREAM_VOICE_RMS_MIN
voice_rms_max: float = STREAM_VOICE_RMS_MAX
closed: bool = False # nach stream_end gesetzt
endpoint_sent: bool = False # Endpoint nur einmal feuern
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
@@ -372,6 +386,10 @@ class SessionManager:
speed = float(payload.get("speed") or 1.0)
except (TypeError, ValueError):
speed = 1.0
try:
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
except (TypeError, ValueError):
voice_factor = STREAM_VOICE_FACTOR
session = StreamSession(
request_id=request_id,
audio_request_id=payload.get("audioRequestId", "") or "",
@@ -381,6 +399,7 @@ class SessionManager:
hard_cap_ms=hard_cap_ms,
voice=payload.get("voice", "") or "",
speed=speed,
voice_factor=voice_factor,
interrupted=bool(payload.get("interrupted", False)),
location=payload.get("location") or None,
sample_rate=int(payload.get("sampleRate") or 16000),
@@ -549,8 +568,16 @@ class SessionManager:
# Akustische Sprach-Aktivitaet JEDEN Tick (~200ms) messen — unabhaengig
# vom Transcribe-Throttle. Solange wirklich gesprochen wird, bleibt die
# Session am Leben, auch wenn Whisper gerade keinen neuen Text liefert.
if self._tail_rms(sess) >= STREAM_VOICE_RMS_THRESHOLD:
# ADAPTIV: der Schwellwert richtet sich nach dem gemessenen Rausch-Boden
# (fast-down/slow-up), damit leises/entferntes Sprechen nicht faelschlich
# als Stille gilt und der Satz mitten drin abgeschnitten wird.
rms = self._tail_rms(sess)
if rms >= self._voice_threshold(sess):
sess.last_voice_at = now
else:
# Rausch-Boden NUR aus Nicht-Sprache lernen — waehrend Sprache
# einfrieren, sonst wandert die Grenze hoch und sperrt Sprache aus.
self._update_noise_floor(sess, rms)
# Endpoint-Entscheidung JEDEN Tick, sobald ueberhaupt Text erkannt wurde:
# (a) akustisch: seit endpoint_ms keine Sprach-Energie mehr → User ist
@@ -620,6 +647,26 @@ class SessionManager:
return 0.0
return (samples / sess.sample_rate) * 1000.0
def _voice_threshold(self, sess: StreamSession) -> float:
"""Adaptiver Voice-Schwellwert = Rausch-Boden * Faktor, geklammert auf
[min, max]. Bei noch ungelerntem Boden (0.0) Untergrenze: sensibel,
lieber nicht abschneiden (das war der eigentliche Cutoff-Bug)."""
nf = sess.noise_floor
if nf <= 0.0:
return sess.voice_rms_min
return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max)
def _update_noise_floor(self, sess: StreamSession, rms: float) -> None:
"""Rausch-Boden nachfuehren: schnell runter (neue, leisere Stille),
langsam rauf (Umgebung wird lauter). NUR mit Nicht-Sprache aufrufen."""
nf = sess.noise_floor
if nf <= 0.0:
sess.noise_floor = rms
elif rms < nf:
sess.noise_floor = 0.90 * nf + 0.10 * rms
else:
sess.noise_floor = 0.98 * nf + 0.02 * rms
def _tail_rms(self, sess: StreamSession) -> float:
"""RMS-Energie der letzten STREAM_ENERGY_WINDOW_MS des Audio-Buffers.
Dient als akustisches redet noch / ist still"-Signal."""