Compare commits

...
60 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 2bd7c747d9 fix(diagnostic): Bild-Upload landet im fokussierten Projekt statt Hauptchat
Datei-Uploads (Copy-Paste UND Datei-Picker) trugen im Diagnostic-Portal
den projectId des fokussierten Projekts nicht mit — anders als Text und
Zwischenrufe. Die Bridge las payload.projectId, bekam nichts und routete
die Datei-Bubble in den Hauptchat.

- index.html: sendDiagAttachments schickt projectId: focusedContextId
- server.js: send_file-Relay reicht projectId in die RVS-Payload weiter

Beide Upload-Wege laufen ueber handleDiagFileSelect → ein Fix deckt beide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:38:58 +02:00
duffyduckandClaude Opus 4.8 d0694cb637 fix(voice): 'gute Nacht' loest nicht mehr faelschlich Ohr-Aus aus
Regression aus dem Schlaf-Idiom-Commit (954ad9a): 'gute nacht' im wake-off-Regex
matchte jeden Verabschiedungs-Gruss ('Ich wünsche dir eine gute Nacht') → Ohr
ging aus, Stefan hing in einer 'Ohr aus'-Schleife fest. 'gute nacht'/'schlaf
gut' sind Gruesse, kein Ohr-Aus-Befehl → raus aus dem Regex. Nur klare Imperative
an ARIA bleiben ('geh/leg dich schlafen', 'leg dich aufs Ohr/hin'); mehrdeutige
Faelle faengt ARIA per ear_control aus dem Kontext ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:43:18 +02:00
duffyduckandClaude Opus 4.8 8f313eb68b feat(voice): ear_control-Tool — ARIA versteht Ohr-Aus/-An in jeder Formulierung
Statt jede Schlaf-/Aus-Formulierung per Regex aufzuzählen: ein Tool ear_control
(action off|on), das ARIA aufruft, wenn Stefan sinngemäß "hör auf/wieder zu
zuhören" sagt — egal wie ("leg dich schlafen", "ich geh ins Bett, du kannst
aus", "mach Pause vom Zuhören"). Tools ruft ARIA zuverlässig (im Gegensatz zu
den [[..]]-Markern, die sie oft ignoriert).

Nutzt die komplette bestehende Plumbing: der Tool-Call setzt _ear_control →
answered_by wird "wake-off"/"wake-on" → main.py setzt wake_off/wake_on → Bridge
→ App stoppt/startet Listener. Reiner Steuerbefehl (still, kein Weiterlauschen).
Der deterministische Regex-Detektor bleibt als schnelles Netz für die
Standard-Sätze (instant, kein Claude-Call). Prompt-Hinweis in der Voice-Flow-
Sektion ergänzt. Brain-only — kein neues APK nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:24:04 +02:00
duffyduckandClaude Opus 4.8 954ad9ade8 fix(voice): wake-off erkennt mehr Schlaf-Idiome
"leg dich schlafen" / "leg dich aufs Ohr" / "leg dich hin" / "gute Nacht" /
"schlaf gut" zusätzlich zu "geh schlafen". Sofort-Netz (instant, kein Claude-
Call). Der robustere Weg (ear_control-Tool, ARIA versteht jede Formulierung)
folgt separat, wenn Stefan will.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:19:29 +02:00
duffyduck 75fa574a85 release: bump version to 0.2.4.8 2026-08-16 22:01:07 +02:00
duffyduckandClaude Opus 4.8 a3d7933949 fix(voxtral): Speech-Endpoint gegen laute Umgebungsmusik
Problem (Stefan): laeuft Musik, endet eine Aufnahme nie von selbst — die Energie
bleibt oben, der RMS-Stille-Endpoint feuert nicht (last_voice_at immer frisch).
Erst Stop druecken oder Musik leiser (dann echte Stille) beendet.

Fix: waehrend lauter Phasen (rms ueber Schwelle) periodisch (~700ms, gedrosselt)
mit Silero pruefen, ob im letzten endpoint_ms-Fenster ueberhaupt Sprache ist.
Nur Musik/Stille → Turn beenden (finalize endpoint). Real gesprochene Turns
haben Sprache im Fenster → laufen normal weiter; eine kurze Denk-Pause auch,
solange innerhalb der Toleranz noch Sprache im Fenster liegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:57:06 +02:00
duffyduck 626281dead release: bump version to 0.2.4.7 2026-08-16 21:39:35 +02:00
duffyduckandClaude Opus 4.8 49ea172617 feat(wake): Voxtral-Bestätigungsstufe gegen Musik-Fehltrigger
openWakeWord triggert oft auf Musik (Pet Shop Boys "West End Girls" & Co.). Neu:
nach dem Trigger prüft Voxtral den Vor-Trigger-Audio ("war das wirklich das
Wake-Wort oder nur Musik?") — erst bei Bestätigung Gong + Mikro, sonst still
verworfen + re-arm. Kostet ~0,5-1s vor dem Gong.

- Native (OpenWakeWordModule.kt): 2s Roh-PCM-Ringpuffer; bei Erkennung wird der
  1,5s-Vor-Trigger-Schnipsel base64 (s16le 16kHz) ans WakeWordDetected-Event
  gehängt (preTriggerPcm).
- Bridge (voxtral): neuer One-Shot-Handler stt_transcribe_blob → Silero + Voxtral
  → stt_transcribe_result. Musik/Rauschen → leerer Text.
- App: audio.transcribeBlob() schickt den Schnipsel, wakeword.confirmWake() prüft
  ob das distinktive Keyword (>=4 Zeichen) im Transkript steht. Gate sitzt in
  onWakeDetected VOR Gong/Dialog. Setting "Wake-Wort per Voxtral bestätigen"
  (default aus, opt-in).

FAIL-OPEN durchgängig: kein preTriggerPcm (altes APK) / Timeout / Fehler / VAD
weg → Wake läuft normal durch. Nie schlechter als heute. NATIVER TEIL UNGETESTET
(kein Gerät hier) — braucht Build + Test auf dem Handy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:37:15 +02:00
duffyduck a3650bb0e4 release: bump version to 0.2.4.6 2026-08-16 21:17:07 +02:00
duffyduckandClaude Opus 4.8 514ba44e51 fix(app): Umgebungsmusik killt Weiterreden-Fenster nicht mehr (re-listen)
Wechselwirkung entdeckt: im Passiv-Lausch-Fenster (Weiterreden nach ARIAs
Antwort) nimmt das Mikro Umgebungsmusik auf → Silero verwirft sie korrekt als
vad_no_speech → leerer Endpoint. Die App wertete JEDEN leeren Endpoint als
"nichts gesagt" → sofort zurueck aufs Wake-Word. Bei laufender Musik in einer
Sprechpause endete so das Gespraech, obwohl die Stille-Toleranz noch lief.

Fix: bei leerem Endpoint im Passiv-Fenster wird — solange die Stille-Toleranz ab
Fenster-Oeffnung noch laeuft (Budget) und < 15 Runden — nochmal gelauscht statt
zu beenden. Musik wird verworfen, das Fenster bleibt bis zur Toleranz offen, du
kannst innerhalb reden. Erst wenn die Toleranz wirklich um ist → aufs Wake-Word.
Sprichst du (Text-Endpoint), geht es sofort normal weiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:14:37 +02:00
duffyduckandClaude Opus 4.8 617dddf3d8 fix(app): Konversationsmodus auch im Hintergrund, wenn Background-Wake an
Diagnose aus dem App-State-Trail (Bridge-Log): nach jeder gesprochenen Antwort
rief die App endConversation(bg || !converse) — der bg-Term (AppState != active)
erzwang "zurueck aufs Wake-Word", sobald die App im Hintergrund war. Das war vor
dem Background-Wake-Feature Absicht (kein Multi-Turn im Hintergrund). Jetzt, mit
aktivem Background-Wake, killt genau das den Konversationsmodus im Hintergrund —
im Vordergrund lief er weiter (daher "kommt beim Vorholen wieder"). converse kam
korrekt als true vom Brain (keine Marker, Default true); die App warf es im
Hintergrund weg.

Fix: bg erzwingt "armed" nur noch, wenn Background-Wake AUS ist
(isBgWakeEnabled()). Bei aktivem Background-Wake bleibt der Konversationsmodus
auch im Hintergrund offen — der User hat das Zuhoeren ja bewusst eingeschaltet.
Vordergrund-Verhalten unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:06:40 +02:00
duffyduck 88faf57253 release: bump version to 0.2.4.5 2026-08-16 20:40:47 +02:00
duffyduckandClaude Opus 4.8 41714f7cf1 feat(voice): Wake-Word per Befehl wieder anschalten (Gegenstück zu wake_off)
"Wake-Word an" / "mach das Ohr an" / "hör wieder zu" / "wach auf" → App startet
den Listener wieder. Geht per Text-Nachricht ODER manuellem Aufnahme-Button —
beide laufen unabhängig vom Wake-Word-Listener, also funktioniert das Wieder-An
auch wenn das Ohr gerade taub ist (nur nicht per "Computer", das hört ja nicht).

Symmetrisch zu wake_off: Detektor _user_wants_wake_on → wake_on durch ChatOut →
Bridge → App löst wakeWordService.start() + setWakeWordActive(true) aus. An/Aus
kollidieren nicht (12 Fälle getestet). Damit: Ohr per Befehl ein UND aus, plus
weiterhin der Ohr-Button.

Fix nebenbei: gerades " in den Reply-Strings (hätte den Brain-Start gecrasht) →
einfache Anführungszeichen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:33:16 +02:00
duffyduckandClaude Opus 4.8 93401d42d1 feat(voice): Wake-Word per Sprachbefehl ausschalten
"Computer, Wake-Word aus" / "mach das Ohr aus" / "hör auf zuzuhören" / "geh
schlafen" → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte
Ruhe). Wieder-An nur über den Ohr-Button (bewusst, weil dann taub — Voice-
Wieder-An ist physikalisch unmöglich, wenn nichts mehr hört).

Deterministischer Detektor _user_wants_wake_off im Brain (kein LLM-Call nötig,
still). Signal fließt als wake_off durch ChatOut → Bridge → App-Payload; die App
löst denselben Pfad wie toggleWakeWord-off aus (cancelRecording +
wakeWordService.stop() + setWakeWordActive(false)). Detektor gegen 13 Positiv-
+ 9 Negativ-Fällen verifiziert (fängt nicht 'Licht aus' / 'Konversation Ende').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:27:48 +02:00
duffyduckandClaude Opus 4.8 c4e658446d feat(voxtral): Silero VAD — echte Sprach-Erkennung gegen generische Phantome + Musik
Der Muster-Filter fängt nur spezifische Artefakte; generische Silence-Phantome
("Ich bin ein guter Mann", "Die Serie wurde in 13 Episoden gedreht") kann ein
Text-Regex prinzipiell nicht vorhersehen. Lösung an der Wurzel: Silero VAD
(neuronal, ~2MB) entscheidet am AUDIO, ob überhaupt echte Sprache drin ist.

Im _finalize vor dem Transkribieren: kein Speech-Segment → no-speech, gar nicht
erst transkribieren → kein Phantom. Trennt zuverlässig Sprache von Stille /
Rauschen / MUSIK (löst damit auch den Musik-False-Positive). Bei Speech wird auf
die Sprach-Spanne getrimmt (Stille-Ränder weg → weniger Edge-Halluzination).

FAIL-OPEN: Import/Load/Inferenz-Fehler → normal transkribieren (die STT darf nie
komplett sterben — Speaker-ID-Lektion). Alles env-tunebar: SILERO_VAD_ENABLED,
_THRESHOLD (0.5), _MIN_SPEECH_MS (150), _PAD_MS (200). Modell im pip-Paket
gebündelt, kein Runtime-Download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:18:06 +02:00
duffyduck 3693157210 release: bump version to 0.2.4.4 2026-08-16 19:57:14 +02:00
duffyduckandClaude Opus 4.8 64670cdd12 fix(voxtral): Repetition-Loop bremsen ('vergiss das'-Schleife)
Stefans Repro: eine echte Nachricht endete mit "...vergiss das, das ist nur..."
und Voxtral hat den Satz ~15x geloopt, bis zur Brain durch. Klassische
Repetition-Halluzination bei Stille/Rauschen am Ende.

Zwei Ebenen: (1) Generation bekommt no_repeat_ngram_size=4 + repetition_penalty
=1.15 → erste echte Nennung bleibt, exakte 4-Gramm-Wiederholung verboten, Loop
bricht an der Quelle ab. (2) Post-Detektor _collapse_repetitions kassiert einen
durchgerutschten Loop auf eine Kopie ein. Gegen den echten Loop + legitime
Doppelungen ('ja ja ja', 'sehr sehr') verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 19:42:22 +02:00
duffyduckandClaude Opus 4.8 c82616ebbd fix(voxtral): No-Speech-Timeout — Stille-Fenster schließt selbst
Stefans Repro: "die Stille-Ende wird nie erreicht, stop ich selbst ist es weg,
und geht automatisch auf lausche Computer". Ursache: der Endpoint feuert nur
wenn schon Stimme da war (last_voice_at>0). Bei totaler Stille bleibt
last_voice_at==0 → Endpoint feuert NIE → Fenster offen bis Hardcap/manuellem
Stop (→ stream_end → Phantom).

Fix: No-Speech-Timeout im _tick — wenn nach endpoint_ms (Stille-Toleranz) ab
Start noch KEINE Stimme kam, schließt der Bridge das Fenster selbst als
no-speech (leer, lautlos, zurück aufs Wake-Word). voiced_frames==0 →
_finalize verwirft ohne Transkript, also kein Phantom. Logik gegen totale
Stille / Sprache-dann-still / Dauer-Sprache verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 19:38:38 +02:00
duffyduck b226e1da11 release: bump version to 0.2.4.3 2026-08-16 11:33:45 +02:00
duffyduckandClaude Opus 4.8 0b7ed241b4 fix(voxtral): Phantom-Filter greift auch bei stream_end
Aus dem ai-box-Log gelernt: die realen Silence-Phantome ('Die Stadt hat eine
Fläche von 1,5 km²') kommen ALLE mit reason=stream_end — Passiv-/Wake-Fenster
enden auch per stream_end, wenn sie auf Stille zumachen. stream_end ist also
NICHT gleich 'manueller Stop mit bewusster Sprache'. Meine vorige Fassung nahm
stream_end aus → Phantome liefen durch.

Jetzt: (1) Pre-Guard greift auch bei stream_end, aber mit Schwelle voiced==0
(kurze bewusste Wörter am Button gehen durch, echte Stille nicht). (2) Phrase-
Filter gilt für ALLE reasons; das borderline-Band (wenig voiced_frames) schützt
echte, klar gesprochene Geo-Fragen. Gegen alle 3 Log-Fälle + reale Eingaben
verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:30:20 +02:00
duffyduck a2b7e3a48d release: bump version to 0.2.4.2 2026-08-16 11:26:49 +02:00
duffyduckandClaude Opus 4.8 7b72149671 fix(voxtral): Halluzinations-Filter gegen Phantom-Text aus Stille
Punkt 3: 2. Netz nach der Transkription. Im borderline-Band (wenig echte
Stimme) werden leere/Artefakt-Transkripte verworfen statt als Phantom ans
Brain zu gehen ('Die Stadt hat eine Fläche von 1,5 km²' aus Fast-Stille).
Bekannte Voxtral-Silence-Artefakte (Untertitel-Credits, Geo-/Städte-Fakten)
per Regex, gegated auf voiced_frames — echte Geo-FRAGEN (normale Energie)
gehen durch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 e9439dbccb feat(app): Hintergrund-Wake als Schalter (default aus)
Punkt 2: das Wake-Wort triggerte nur im Vordergrund — eine bewusste JS-Zeile
verwarf jede Hintergrund-Erkennung (native Erkennung + Foreground-Service
liefen längst durch). Jetzt hinter Einstellung 'Auch bei gesperrtem Bildschirm
zuhören' (default aus, mehr Fehltrigger möglich). Greift live via
setBgWakeEnabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 219de091d2 feat(brain): Titel-Index fürs kalte Gedächtnis + 'beendet' erkennen
Punkt 4B: jeder System-Prompt bekommt einen kompakten Titel-Index der bewusst
gespeicherten Nachschlage-Memories (Zugangsdaten, Infra, Projekte) — ARIA
sieht WAS sie hat und holt es via memory_search, statt Stefan nach etwas zu
fragen, das schon da ist (Git-Credentials-Vorfall). Auto-distillierte Fakten
+ Conversation-Logs sind ausgefiltert → billig.

Punkt 1: _CONV_END_VERB erkennt jetzt auch 'beendet' (beend\w*) und 'stoppe'
(stop\w*) — 'Konversation beendet' schloss vorher das Mikro nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 5992a7e441 feat(brain): Gedächtnis nach scope trennen (system/personal)
Neues Feld scope=system|personal auf jedem Memory-Punkt. Bootstrap-Export
getrennt: System-Regeln (generisch, teilbar) vs. Persönliches (Name,
Zugangsdaten, Projekte). Import ist scope-sicher — ein System-Import löscht
NICHT die persönlichen pinned Memories. seed_rules + AGENT.md/TOOLING.md →
system, USER.md-Präferenzen → personal. Backfill für Bestand (57 system /
617 personal). Diagnostic: zwei Export-Buttons, scope-Badge (SYS/PRIV) +
Umschalter pro Memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduck 07ccf05429 release: bump version to 0.2.4.1 2026-08-16 02:02:10 +02:00
duffyduckandClaude Opus 4.8 4ab0e68245 fix(voice): Passiv-Backstop lang genug — schneidet lange Antworten nicht mehr ab
Log-Analyse (alter Build): eine lange gesprochene Antwort wurde vom 30s-Passiv-
Timer mitten im Wort gekappt (exit reason=timeout, dann stt_endpoint reason=
stream_end mit abgeschnittenem Text). Genau das Fenster ist raus — ABER mein
Ersatz-Backstop (15s) hätte sogar früher abgeschnitten.

Der Backstop ist eine reine HANG-Notbremse und darf aktives Reden NIE kappen →
jetzt 10min (länger als der ~5min-Hardcap der Aufnahme). Das echte Ende regelt
immer die Aufnahme selbst (Stille-Toleranz / No-Speech / Hardcap). Lange
zusammenhängende Antworten laufen jetzt durch, bis du ≥ Stille-Toleranz pausierst.

Hinweis: der geloggte Fehler ist der ALTE Build — die Fixes sind noch nicht
ausgerollt. Dieser Commit korrigiert den noch-nicht-deployten Stand.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:59:45 +02:00
duffyduckandClaude Opus 4.8 9636a702d3 refactor(voice): tote Fenster-Settings ausgeräumt (Konv-Fenster/Passiv-Lauschen)
Nachdem die Stille-Toleranz jetzt überall der einzige Wert ist, sind die alten
Settings obsolet — raus damit:

- audio.ts: CONV_WINDOW_* Konstanten + loadConvWindowMs() entfernt.
- wakeword.ts: PASSIVE_LISTEN_* + load/savePassiveListenMs() entfernt; der Passiv-
  Master-Timer nutzt jetzt einen festen internen Backstop (PASSIVE_BACKSTOP_MS,
  15s) statt eines Nutzer-Werts — das echte Ende regelt die Stille-Toleranz.
- SettingsScreen: "Konversations-Fenster"- und "Weiterreden-Fenster"-Slider raus;
  Letzterer durch eine kurze Erklärung ersetzt (verweist auf Stille-Toleranz).
- ChatScreen: tote Imports weg.

Kein Verhaltensänderung ggü. ebe0e80 — nur Aufräumen. tsc grün.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:53:55 +02:00
duffyduck 5f09e2bca3 release: bump version to 0.2.4.0 2026-08-16 01:48:43 +02:00
duffyduckandClaude Opus 4.8 ebe0e8065f feat(voice): 30s-Passiv-Fenster raus — Stille-Toleranz regiert alles
Stefans Modell: es braucht keinen separaten 30s-Wert. Nach ARIAs Antwort geht das
Mikro auf; fängst du nicht innerhalb der Stille-Toleranz (z.B. 5s) an zu reden, ist
Schluss → zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
Ein Befehl ohne "fortführen" = Ende; nach einer normalen Antwort = ein Stille-
Fenster zum Weiterreden.

- Alle Aufnahme-Pfade (wake/barge/passiv): noSpeechTimeoutMs = endpointMs =
  loadSttEndpointMs() statt loadConvWindowMs()/loadPassiveListenMs(). Ein Wert.
- Passiv-Hardcap: loadMaxRecordingMs() statt fix 35s (schnitt langes Reden ab).
- Leeres Endpoint im listening-State: KEIN Re-Arm mehr → exitPassiveListening
  (ein 5s-Fenster, dann Ende). Der 30s-Master-Timer in wakeword.ts wird dadurch
  nie mehr scharf (harmloser Backstop).

Redest du weiter → Antwort → wieder ein Stille-Fenster (Multi-Turn bleibt, nur ohne
30s-Leerlauf). Die Settings "Konversations-Fenster"/"Passiv-Lauschen" sind damit
obsolet (UI-Cleanup später).

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:46:26 +02:00
duffyduckandClaude Opus 4.8 9d2c07d8d1 feat(voice): "Konversation fortführen" hält offen — auch bei Fast-Path-Befehl
Stefans Repro: "spiel Spotify auf Smartphone ab ABER Konversation fortführen" →
die Kette endete trotzdem. Grund: "spiel spotify" trifft den Fast-Path (Regex),
der den Satz-Rest nicht versteht → converse=false aus dem Skill-Manifest; ARIAs
[[WEITER]]-Marker lebt in Claude, der wurde uebersprungen.

Fix: _user_wants_conversation_continue() — Gegenstueck zu _user_wants_conversation_
end(). Erkennt "Konversation/Gespraech fortfuehren/weiterfuehren/offen halten/nicht
beenden", "weiter reden/sprechen" etc. und erzwingt converse=true an ALLEN Returns
(fast-path/local/claude), auch wenn das Manifest false sagt. [[ENDE]]/_wants_end hat
Vorrang bei Widerspruch. Getestet inkl. Negativfaelle (sofort/spotify ab).

Deploy: Brain-Neustart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:35:49 +02:00
duffyduck 9aae5af6a9 release: bump version to 0.2.3.9 2026-08-16 01:06:18 +02:00
duffyduckandClaude Opus 4.8 a8ff73f93d feat(speaker-id): Gating als bewusster Schalter — Default AUS (fail-open)
Stefans Repro "nichts geht mehr" kam NICHT von den Marker/Guard-Aenderungen,
sondern von der Speaker-ID: die Bridge-Logs zeigten fast durchgehend
"stt_endpoint mit leerem Text — ignoriert (reason=speaker_mismatch)" — ein aus
einem kaputten Enroll (AAC-als-PCM) entstandener Muell-Fingerprint hat Stefans
EIGENE Stimme abgelehnt und damit die komplette STT lahmgelegt.

Fix: Speaker-ID-Gating ist jetzt ein expliziter Schalter, Default AUS. Bei aus
laeuft die Pruefung GAR NICHT (fail-open, alle Stimmen durch) — ein schlechter
Enroll kann nie wieder alles abwuergen. Damit ist Stefans Problem schon durch den
Voxtral-Rebuild geloest (kein Loeschen noetig, der Check greift einfach nicht).

- voxtral + whisper bridge: SPEAKER_ID_ENABLED (Default False, ENV VOICE_ID_ENABLED),
  _check_speaker faellt bei aus sofort fail-open zurueck; config-Broadcast
  voiceIdEnabled setzt es zur Laufzeit.
- diagnostic: Schalter "Nur meine Stimme" in der Voice-ID-Sektion (Default aus,
  Hinweis: erst an wenn enrollt), broadcastet + persistiert voiceIdEnabled.

Reihenfolge lt. Stefan: erst Konversation sauber, dann Multi-Person/nur-ich.

Deploy: docker compose up -d --build voxtral-bridge; aria-diagnostic neu starten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:00:58 +02:00
duffyduckandClaude Opus 4.8 0e9adeee5c fix(speaker-id): Enrollment akzeptiert Android-MP4/AAC — kein "zu kurz" mehr
Voice-ID-Enrollment scheiterte immer mit "zu kurz". Ursache: die App nimmt die
Samples mit dem Legacy-Recorder als AAC im MP4-Container auf (16kHz mono), die
Bridge dekodierte das base64 aber als ROHES int16-PCM. 4s AAC sind stark
komprimiert (< 32KB = MIN_SAMPLE_BYTES) → faelschlich als "zu kurz" verworfen,
und selbst darueber waere das Embedding Muell.

Fix (bridge-seitig, kein APK): _normalize_audio_bytes erkennt jetzt den MP4/M4A/
AAC-Container ('ftyp' bei Offset 4) und dekodiert ihn via ffmpeg (im Container) auf
16kHz mono int16 PCM — zusaetzlich zu rohem PCM und WAV. enroll_from_samples
dekodiert erst, prueft DANN die Laenge aufs dekodierte PCM (nicht die komprimierten
Bytes). Input via Temp-Datei, da Androids moov-Atom am Ende seekbaren Input braucht.
Gleich in voxtral + whisper (identische Kopien).

Deploy: docker compose up -d --build voxtral-bridge; danach in Einstellungen →
Voice-ID neu einlernen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:43:14 +02:00
duffyduckandClaude Opus 4.8 6addb2f8fe fix(voxtral): Halluzinations-Guard — kein Phantom-Text aus Stille/Blip
Stefans Repro: nach dem Senden ging sofort ein Passiv-Fenster auf (waehrend ARIA
noch arbeitete), und obwohl er nichts sagte, kam eine "Nachricht von ihm" — Voxtral
halluzinierte aus Fast-Nichts einen Fuellsatz. Der ging als Phantom-Turn ans Brain
und liess das Gespraech entgleisen (Antwort drauf, dann Konversation-Ende).

Ursache war eine Nebenwirkung des "Sprache erkannt"-Pings (9e78d75): der feuerte
beim ERSTEN Voice-Frame — also auch bei einem kurzen Geraeusch-Blip. Damit war der
App-No-Speech-Watchdog geloescht, der akustische Endpoint feuerte, und Voxtral
transkribierte den Blip → Halluzination. (Vorher haette der Watchdog den Blip
verworfen.)

Fix: voiced_frames-Zaehler (Endpointer-Frames mit echter Stimme). Erst ab
STREAM_MIN_VOICED_FRAMES (=2, ~400ms, env-tunebar) gilt es als Sprache:
- Der Speech-Ping feuert erst ab dieser Schwelle (ein Blip loescht den Watchdog
  also nicht mehr).
- _finalize transkribiert unter der Schwelle NICHT, sondern schickt ein leeres
  Endpoint (no-speech) → App re-armt still, kein Phantom-Turn. Manueller Stop
  (stream_end) ist ausgenommen (bewusst gesprochen, kurze Woerter ok).

Deploy: docker compose up -d --build voxtral-bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:35:21 +02:00
duffyduckandClaude Opus 4.8 9bdfb7193e feat(voice): "Konversation Ende" deterministisch — nicht nur ARIAs [[ENDE]]-Marker
Beobachtung Stefan: ARIA haelt die Konversation offen (Default, korrekt), aber
auf "Konversation Ende" hin schloss sie NICHT — sie hat den [[ENDE]]-Marker nicht
gesetzt. Das aufs LLM allein zu verlassen ist zu unzuverlaessig fuer einen festen
Trigger.

Fix: _user_wants_conversation_end() erkennt explizite Beenden-Phrasen im User-Text
(konversation/gespraech/befehlskette + ende/beenden/aus/stop/schluss, beide
Reihenfolgen, ein Satzteil) und erzwingt converse=false an ALLEN drei Returns
(fast-path/local/claude). ARIA beantwortet eine evtl. enthaltene Frage noch normal
(speak bleibt), danach zurueck aufs Wake-Word. "befehls?kette" statt bare "kette",
damit "Lieferkette" u.ae. nicht faelschlich matchen (per Test abgesichert).

Deploy: Brain-Neustart (nicht waehrend ARIA arbeitet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:25:09 +02:00
duffyduckandClaude Opus 4.8 9e78d75149 fix(voxtral): "Sprache erkannt"-Ping — No-Speech-Watchdog schneidet nicht mehr ab
Im Ohr-Modus (Wake/Passiv/Barge) beendete die App die Aufnahme am Konversations-
fenster (~conv window), obwohl Stefan noch redete — nur dort, nicht beim normalen
Button. Ursache: der App-No-Speech-Watchdog cancelt, solange KEIN stt_partial kam
(streamGotPartial=false). Voxtral schickt aber seit dem Cutoff-Fix keine Live-
Partials mehr → streamGotPartial blieb immer false → Timer feuerte mitten im Satz.
Der normale Button nutzt noSpeechTimeoutMs=0 (Watchdog aus) → dort nie aufgefallen.

Fix: Voxtral-Bridge feuert EIN leeres stt_partial beim ersten Voice-Frame (nach
der Speaker-ID-Pruefung, fremde Stimmen signalisieren also nicht). Die App setzt
darauf streamGotPartial=true und loescht den Watchdog — bestehender Handler, keine
App-Aenderung. Der akustische Endpoint (Stille >= endpointMs) bleibt der einzige
Turn-Ende-Ausloeser.

Deploy: docker compose up -d --build voxtral-bridge (keine neue APK noetig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:15:21 +02:00
duffyduck 517c993ac8 release: bump version to 0.2.3.8 2026-08-15 14:06:34 +02:00
duffyduckandClaude Opus 4.8 c1bd13687d feat(voice): ARIA erkennt Gespraechs-Phase selbst (Befehl/Frage, Kette/Ende)
Voice-First: ARIA deklariert die Phase aus dem Text per Inline-Marker — dasselbe
Muster wie [[AWAIT]], die Marker werden vor Anzeige/TTS/History entfernt. Loest:
- Claude-Pfad las Steuerbefehle vor, obwohl das Skill lief (Manifest-speak-Flag
  kennt den Unterschied Befehl/Frage nicht — dasselbe Skill ist mal Aktion, mal
  Auskunft). Jetzt entscheidet ARIA aus dem Kontext.
- Befehlsketten (VNC oeffnen, Menue klicken, ...): Mikro soll offen bleiben und
  stumm gearbeitet werden, bis Stefan die Kette beendet.

Marker (ARIA haengt sie ans Antwort-Ende):
  [[STUMM]]  -> Steuerbefehl, nicht vorlesen. Allein = Einzelbefehl -> danach Stop.
  [[WEITER]] -> Konversation/Kette laeuft weiter -> Mikro offen halten.
  [[ENDE]]   -> Konversation/Kette beenden -> zurueck aufs Wake-Word.

Brain:
- _extract_flow_markers() zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem Claude-Reply
  und ueberschreibt speak/converse (Marker ist autoritativ ueber Skill-Flag).
  [[STUMM]] allein impliziert converse=false; [[ENDE]] schlaegt [[WEITER]].
- prompts.py: neue Sektion build_voice_flow_section() bringt ARIA Marker +
  Befehl/Frage-Klassifikation + Ketten-/Ende-Erkennung bei.

App (ChatScreen): stiller Antwort-Zweig verzweigt jetzt auf converse —
  converse=true (Kette) -> endConversation(false), Mikro bleibt offen fuer den
  naechsten Kettenbefehl; converse=false -> ariaStopRecording (Stop wie gehabt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:00:51 +02:00
duffyduckandClaude Opus 4.8 d9bb7239c6 fix(voice): ARIA schliesst bei Steuerbefehl die Aufnahme selbst (stiller Stop)
Steuerbefehle laufen stumm (speak=false in Fast-Path/Local/Claude-Pfad) — ohne
TTS feuert aber onPlaybackFinished nie, und daran hing bisher das Aufraeumen der
Aufnahme. Folge: nach "spotify play" blieb das Mikro-/Konversations-Fenster offen
(30s Leerlauf), obwohl der Befehl laengst ausgefuehrt war.

Neu: ariaStopRecording() — das programmatische Gegenstueck zum Stop-Button. Nach
einer stillen Antwort (speak=false) schliesst ARIA jede offene Aufnahme selbst und
geht zurueck aufs Wake-Word, egal in welchem Zustand: passives Lauschen sauber
beenden, offene Streaming-Aufnahme (aktiv/Barge) verwerfen, sonst endConversation.
Vorher nur der conversing-Fall abgedeckt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:49:18 +02:00
duffyduck 0265aabb5e release: bump version to 0.2.3.7 2026-08-15 13:27:32 +02:00
duffyduckandClaude Opus 4.8 17bc50b847 fix(voice): manueller Stop unterdrueckt Passiv-Fenster nach der Antwort
Stop finalisierte die Aufnahme, aber die danach kommende onPlaybackFinished oeffnete via converseRef erneut das 30s-Passiv-Fenster. Jetzt setzt handleVoiceButtonStop converse=false → nach manuellem Stop kein Passiv-Lauschen mehr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:16:46 +02:00
duffyduckandClaude Opus 4.8 1f2be4299d fix(voice): Passiv-Lauschen nur bei converse:true (kein 30s-Linger nach Befehl)
converse defaultete true → jeder Befehl mit gesprochener Bestaetigung ('Spiele Spotify') ging ins 30s-Passiv-Fenster. Jetzt nur bei explizitem converse:true vom Brain; einzelne Befehle enden sofort → zurueck aufs Wake-Word, Spotify resumed gleich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:15:38 +02:00
duffyduck 0ca8a82013 release: bump version to 0.2.3.6 2026-08-15 13:05:46 +02:00
duffyduckandClaude Opus 4.8 7bc3f827d0 feat(voxtral): Speaker-ID portiert (nur Stefans Stimme) — E3a
Voxtral hatte 0 Speaker-Filter (mit Voxtral reagierte ARIA auf JEDE Stimme). Jetzt portiert aus der whisper-Bridge: speaker_id.py (ECAPA/speechbrain) kopiert, Einmal-Check auf die ersten 1.5s (fremde Stimme → leeres stt_endpoint reason=speaker_mismatch, kein Transcribe/Brain), voice_id_enroll/status/delete-RVS-Handler + voiceIdThreshold-config. voice-id-Volume gemountet, speechbrain in requirements. Ohne Enrollment fail-open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:00:32 +02:00
duffyduckandClaude Opus 4.8 e7da9cf9c4 feat(voice): Halb-Duplex-Default + Barge-in-Schalter + echter Cancel (E2)
Behebt den Konversations-Mischmasch: (1) Barge-in-Einstellung (Default AUS = Halb-Duplex) gated das Mikro-Lauschen waehrend TTS -> ARIA spricht ungestoert zu Ende. (2) interruptAriaIfBusy bricht die Brain-Antwort WIRKLICH ab (cancel_request) statt nur TTS zu muten -> sie antwortet nicht mehr weiter waehrend man redet. (3) Stop-Button stoppt vorhersehbar alles (TTS + Brain-Cancel). Musik: nativer Focus ist schon GAIN_TRANSIENT (pausiert) -> der saubere Flow behebt das Focus-Geflacker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:57:24 +02:00
duffyduckandClaude Opus 4.8 353fd98d3f feat(wake): schnellere/empfindlichere Wake-Word-Defaults (E1)
Gegen 'traege': patience 2->1, STARTUP_SUPPRESSION_MS 1500->600, Foreground-Cooldown 3000->1000, Resume-Cooldown 1500->500, Threshold-Default 0.6->0.45. Fehlausloeser faengt die Speaker-ID (E3) ab. Wort bleibt 'computer'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:57:24 +02:00
duffyduck 0350dd33c8 release: bump version to 0.2.3.5 2026-08-15 12:07:01 +02:00
duffyduckandClaude Opus 4.8 7b956c6606 feat(voice): Stille-Toleranz bis 8s stellbar (Denkpausen)
STT_ENDPOINT_MAX_MS 4000->8000. Der (in a) auf den aktiven Endpoint umgeklemmte 'Stille-Toleranz'-Regler geht damit bis 8s statt nur 4s — genug Zeit zum Nachdenken, ohne dass die Aufnahme endet. Fliesst per endpointMs an Voxtral/Whisper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:04:40 +02:00
duffyduckandClaude Opus 4.8 36f04f83ff fix(voxtral): willkuerliche Abbrueche — semantischen Endpoint + Live-Partials raus
Ursache: Voxtral-3B transkribiert den ganzen wachsenden Buffer (~5-6s bei langen Aufnahmen). Diese Partial-Latenz war groesser als der semantische Endpoint-Timeout (4.8s) → 'Text waechst nicht mehr' feuerte faelschlich → Abbruch nach 20-40s. Fix: keine Live-Partials mehr, kein semantischer Endpoint — Turn-Ende rein akustisch (Stille-VAD), transkribiert wird nur EINMAL im _finalize. max_new_tokens 512->4096 (512 schnitt lange Diktate ab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:02:44 +02:00
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
26 changed files with 2225 additions and 638 deletions
+43
View File
@@ -11,6 +11,8 @@
# #
# Optionen: # Optionen:
# --up am Ende den xtts-Stack starten (Default-Profil, OHNE voxtral) # --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=...) # --token <TOK> RVS_TOKEN in xtts/.env eintragen (alternativ: env RVS_TOKEN=...)
# --rvs-host <H> RVS_HOST setzen (Default aus .env.example) # --rvs-host <H> RVS_HOST setzen (Default aus .env.example)
# #
@@ -21,11 +23,13 @@ set -euo pipefail
# ── CLI ── # ── CLI ──
DO_UP=0 DO_UP=0
DO_UPGRADE_DRIVER=0
RVS_TOKEN_ARG="${RVS_TOKEN:-}" RVS_TOKEN_ARG="${RVS_TOKEN:-}"
RVS_HOST_ARG="${RVS_HOST:-}" RVS_HOST_ARG="${RVS_HOST:-}"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--up) DO_UP=1; shift ;; --up) DO_UP=1; shift ;;
--upgrade-driver) DO_UPGRADE_DRIVER=1; shift ;;
--token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;; --token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;;
--rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;; --rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
@@ -90,6 +94,45 @@ fi
[[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv" [[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv"
apt-get update -qq # immer neu einlesen, damit der Kandidat sicher da ist 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 ── # ── 3. NVIDIA-Treiber ──
step "NVIDIA-Treiber" step "NVIDIA-Treiber"
if nvidia-smi >/dev/null 2>&1; then if nvidia-smi >/dev/null 2>&1; then
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20302 versionCode 20408
versionName "0.2.3.2" versionName "0.2.4.8"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -59,7 +59,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik, // Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026). // weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten. // Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 1500L private const val STARTUP_SUPPRESSION_MS = 600L
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
} }
private val env: OrtEnvironment = OrtEnvironment.getEnvironment() private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0 private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L private var lastDetectionMs: Long = 0L
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
private var pcmRingPos = 0
private var pcmRingFilled = false
private val pcmRingLock = Any()
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster // Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L private var recordingStartedMs: Long = 0L
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
embBuffer.clear() embBuffer.clear()
consecutiveAboveThreshold = 0 consecutiveAboveThreshold = 0
lastDetectionMs = 0L lastDetectionMs = 0L
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
// in den Bestaetigungs-Schnipsel bluten.
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
}
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
private fun snapshotPreTrigger(): String? {
val out: ByteArray
synchronized(pcmRingLock) {
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
if (n <= 0) return null
out = ByteArray(n * 2)
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
for (i in 0 until n) {
val s = pcmRing[idx].toInt()
out[i * 2] = (s and 0xFF).toByte()
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
idx += 1
if (idx >= PCM_RING_SAMPLES) idx = 0
}
}
return try {
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
null
}
} }
private fun emitDetected() { private fun emitDetected() {
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)") Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
return return
} }
val preTriggerB64 = snapshotPreTrigger()
val params = com.facebook.react.bridge.Arguments.createMap().apply { val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName) putString("model", modelName)
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
} }
try { try {
reactApplicationContext reactApplicationContext
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
read += n read += n
} }
if (!running.get()) break if (!running.get()) break
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
synchronized(pcmRingLock) {
for (i in 0 until CHUNK_SAMPLES) {
pcmRing[pcmRingPos] = buf[i]
pcmRingPos += 1
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
}
}
try { try {
processChunk(buf) processChunk(buf)
} catch (e: Exception) { } catch (e: Exception) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.3.2", "version": "0.2.4.8",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+197 -35
View File
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
import ErrorBoundary from '../components/ErrorBoundary'; import ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio'; import audioService from '../services/audio';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword'; import wakeWordService from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser'; import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi'; import brainApi, { Project as BrainProject } from '../services/brainApi';
import projectFocus from '../services/projectFocus'; import projectFocus from '../services/projectFocus';
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload'; import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText'; import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs } from '../services/audio'; import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation'; import Geolocation from '@react-native-community/geolocation';
// --- Typen --- // --- Typen ---
@@ -93,6 +93,9 @@ interface ChatMessage {
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen, * gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
* auch wenn mehrere Aufnahmen parallel offen sind. */ * auch wenn mehrere Aufnahmen parallel offen sind. */
audioRequestId?: string; 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 */ /** Skill-Created-Bubble: ARIA hat einen neuen Skill angelegt */
skillCreated?: { skillCreated?: {
name: string; name: string;
@@ -184,6 +187,14 @@ function stripSystemHints(text: string): string {
} }
return out; 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 DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
@@ -373,6 +384,16 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest // stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort. // es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true); const converseRef = useRef<boolean>(true);
// Passiv-Lausch-Fenster (Weiterreden nach ARIAs Antwort): Umgebungsgeraeusch
// (Musik/TV) darf das Fenster NICHT vorzeitig beenden. Bei einem no-speech-
// Endpoint (Silero verwirft Musik) wird — solange die Stille-Toleranz ab
// Fenster-Oeffnung noch laeuft — nochmal gelauscht statt sofort aufs Wake-Word
// zurueckzufallen. Start-Zeit + Re-Listen-Zaehler + Budget hier gemerkt.
const passiveListenStartRef = useRef<number>(0);
const passiveReListenCountRef = useRef<number>(0);
const passiveToleranceRef = useRef<number>(5000);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
@@ -651,6 +672,7 @@ const ChatScreen: React.FC = () => {
const voice = await AsyncStorage.getItem('aria_xtts_voice'); const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || ''; localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed(); ttsSpeedRef.current = await loadTtsSpeed();
bargeInEnabledRef.current = await loadBargeInEnabled();
const gps = await AsyncStorage.getItem('aria_gps_enabled'); const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true'); setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints'); const hints = await AsyncStorage.getItem('aria_show_hints');
@@ -1387,13 +1409,61 @@ const ChatScreen: React.FC = () => {
// Fallback mehr: die Bridge schickt speak zuverlaessig mit. // Fallback mehr: die Bridge schickt speak zuverlaessig mit.
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt // Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen — onPlaybackFinished liest converseRef. Default true. // stoppen — onPlaybackFinished liest converseRef. Default true.
converseRef.current = (message.payload as any).converse !== false; // Passiv-Lauschen (30s) NUR wenn das Brain explizit converse:true schickt.
// Vorher default true → jeder Befehl (auch "Spiele Spotify" mit gesproche-
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
converseRef.current = (message.payload as any).converse === true;
const _wakeOff = (message.payload as any).wake_off === true;
const _wakeOn = (message.payload as any).wake_on === true;
const _isSilent = (message.payload as any).speak === false; const _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) { if (_wakeOn) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation → // "Wake-Word an" per Text/Aufnahme-Button → Listener wieder starten
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme, // (gleicher Weg wie toggleWakeWord-on). Geht auch wenn das Ohr taub war,
// kein 30s-Fenster (skipPassive=true). // weil der Befehl NICHT ueber "Computer" kam.
wakeWordService.endConversation(true).catch(() => {}); (async () => {
try {
const started = await wakeWordService.start();
setWakeWordActive(started);
console.log('[Chat] Wake-Word per Befehl AN gestartet:', started);
} catch (e) {
console.warn('[Chat] Wake-Word AN fehlgeschlagen:', e);
}
})();
} else if (_wakeOff) {
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
(async () => {
try {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('wake-off-voice');
} else {
await audioService.stopRecording();
}
} catch {}
try { await wakeWordService.stop(); } catch {}
setWakeWordActive(false);
console.log('[Chat] Wake-Word per Sprachbefehl AUS — Ohr-Button zum Wieder-Anmachen');
})();
} else if (_isSilent) {
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
if (converseRef.current) {
// Befehlskette laeuft WEITER ([[WEITER]]): Mikro NICHT schliessen,
// sondern das passive Lausch-Fenster oeffnen (endConversation(false)),
// damit der naechste Kettenbefehl direkt gesprochen werden kann. ARIA
// haelt bewusst offen, bis sie [[ENDE]] (converse=false) schickt.
if (wakeWordService.isConversing()) {
wakeWordService.endConversation(false).catch(() => {});
}
} else {
// Einzelbefehl / [[ENDE]] → ARIA "drueckt selbst Stop": jede offene
// Aufnahme schliessen + zurueck aufs Wake-Word, egal in welchem Zustand
// (conversing, passives Lauschen ODER offene Streaming-Aufnahme).
ariaStopRecording('silent-command').catch(() => {});
}
} }
} }
@@ -1628,8 +1698,12 @@ const ChatScreen: React.FC = () => {
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive). // Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch // converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word. // ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
// Im Hintergrund normalerweise direkt re-armen (kein Multi-Turn) — ABER
// wenn Hintergrund-Wake bewusst AN ist, will der User auch im Hintergrund
// ein Gespraech fuehren, also den Konversationsmodus offen halten.
const bg = AppState.currentState !== 'active'; const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {}); const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
}); });
return () => unsubPlayback(); return () => unsubPlayback();
}, []); }, []);
@@ -1648,7 +1722,11 @@ const ChatScreen: React.FC = () => {
rememberMyRequest(audioRequestId); rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy(); const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs(); // EIN Wert regiert: die Stille-Toleranz. Sie gilt sowohl als Pause WÄHREND
// des Redens (endpointMs) ALS AUCH als "wenn du nicht anfängst zu reden,
// ist Schluss" (noSpeechTimeoutMs). Kein separates 30s-Konversationsfenster
// mehr — Stefans Modell: sagst du nichts, greift der Stille-Wert.
const sttEndpointMs = await loadSttEndpointMs();
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
@@ -1666,9 +1744,11 @@ const ChatScreen: React.FC = () => {
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted, interrupted: wasInterrupted,
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: sttEndpointMs,
endpointMs: await loadSttEndpointMs(), endpointMs: sttEndpointMs,
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, projectId: focusedProjectIdRef.current,
}); });
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{}); import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
@@ -1696,6 +1776,13 @@ const ChatScreen: React.FC = () => {
if (ev.text && ev.text.trim()) { if (ev.text && ev.text.trim()) {
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)', console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS); 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 // Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang
// zu 'conversing' damit der normale Flow greift (TTS, resume, etc.) // zu 'conversing' damit der normale Flow greift (TTS, resume, etc.)
if (wakeWordService.getState() === 'listening') { if (wakeWordService.getState() === 'listening') {
@@ -1716,12 +1803,24 @@ const ChatScreen: React.FC = () => {
!(m.audioRequestId === ev.audioRequestId !(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet')))); && m.text.includes('Spracheingabe wird verarbeitet'))));
} }
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv // Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende). // mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
// Sonst endConversation wie bisher. // Fenster-Oeffnung noch laeuft, nochmal lauschen. So killt Umgebungs-
// musik das Weiterreden nicht: die Musik wird verworfen, das Fenster
// bleibt bis zur Toleranz offen, du kannst innerhalb reden. Erst wenn
// die Toleranz wirklich um ist (oder zu viele Runden) → aufs Wake-Word.
if (wakeWordService.getState() === 'listening') { if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme'); const elapsed = Date.now() - passiveListenStartRef.current;
const budget = passiveToleranceRef.current || 5000;
if (elapsed < budget && passiveReListenCountRef.current < 15) {
passiveReListenCountRef.current += 1;
console.log('[Chat] Passive-Listen: leeres Endpoint (%s) — Umgebung, re-listen (%dms/%dms, #%d)',
ev.reason, elapsed, budget, passiveReListenCountRef.current);
startPassiveStreamingRecording(); startPassiveStreamingRecording();
} else {
console.log('[Chat] Passive-Listen: Stille-Toleranz aufgebraucht (%dms) — Ende, zurueck aufs Wake-Word', elapsed);
wakeWordService.exitPassiveListening('timeout').catch(() => {});
}
} else { } else {
wakeWordService.endConversation(); wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false); if (!wakeWordService.isActive()) setWakeWordActive(false);
@@ -1733,8 +1832,14 @@ const ChatScreen: React.FC = () => {
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme // geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der // OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
// Whisper-Bridge filtert fremde Stimmen weg. // Whisper-Bridge filtert fremde Stimmen weg.
const unsubPassive = wakeWordService.onPassiveListen(() => { const unsubPassive = wakeWordService.onPassiveListen(async () => {
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme'); console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
// Fenster NEU geoeffnet (nach ARIAs Antwort): Budget-Uhr + Re-Listen-Zaehler
// zuruecksetzen. Re-Listen ruft startPassiveStreamingRecording direkt (nicht
// ueber diesen Callback), also bleibt der Startzeitpunkt erhalten.
passiveListenStartRef.current = Date.now();
passiveReListenCountRef.current = 0;
passiveToleranceRef.current = await loadSttEndpointMs();
startPassiveStreamingRecording(); startPassiveStreamingRecording();
}); });
@@ -1751,7 +1856,7 @@ const ChatScreen: React.FC = () => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId); rememberMyRequest(audioRequestId);
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs(); const sttEndpointMs = await loadSttEndpointMs(); // ein Wert für Pause + No-Speech
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
@@ -1769,9 +1874,10 @@ const ChatScreen: React.FC = () => {
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen" interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: sttEndpointMs,
endpointMs: await loadSttEndpointMs(), endpointMs: sttEndpointMs,
hardCapMs: 60000, // Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
if (ok) { if (ok) {
@@ -1789,7 +1895,9 @@ const ChatScreen: React.FC = () => {
// Prozess nicht killt wenn die App im Hintergrund ist. // Prozess nicht killt wenn die App im Hintergrund ist.
const unsubTtsStart = audioService.onPlaybackStarted(() => { const unsubTtsStart = audioService.onPlaybackStarted(() => {
acquireBackgroundAudio('tts').catch(() => {}); acquireBackgroundAudio('tts').catch(() => {});
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) { // Barge-Listening (Mikro waehrend TTS) NUR im Barge-in-Modus. Default aus =
// Halb-Duplex: ARIA spricht ungestoert zu Ende, dann erst geht das Mikro auf.
if (bargeInEnabledRef.current && wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
wakeWordService.startBargeListening().catch(() => {}); wakeWordService.startBargeListening().catch(() => {});
} }
}); });
@@ -1828,16 +1936,20 @@ const ChatScreen: React.FC = () => {
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId); rememberMyRequest(audioRequestId);
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const passiveMs = await loadPassiveListenMs(); // Kein 30s-Passiv-Fenster mehr: nach ARIAs Antwort geht das Mikro auf, und
// fängst du nicht innerhalb der Stille-Toleranz an zu reden, ist Schluss →
// zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
const sttEndpointMs = await loadSttEndpointMs();
const { ok } = await audioService.startStreamingRecording({ const { ok } = await audioService.startStreamingRecording({
audioRequestId, audioRequestId,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: false, interrupted: false,
location: location || null, location: location || null,
noSpeechTimeoutMs: Math.min(passiveMs, 30000), noSpeechTimeoutMs: sttEndpointMs,
endpointMs: await loadSttEndpointMs(), endpointMs: sttEndpointMs,
hardCapMs: Math.max(passiveMs + 5000, 35000), // Lange Antworten nicht kappen (früher 35s → schnitt langes Reden ab).
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
if (!ok) { if (!ok) {
@@ -2210,15 +2322,16 @@ const ChatScreen: React.FC = () => {
advanceQueue(pid); advanceQueue(pid);
}, [advanceQueue]); }, [advanceQueue]);
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs // Nimmt der User das Mikro waehrend ARIA SPRICHT, ist das ein echter Interrupt:
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft // TTS stoppen UND die laufende Brain-Antwort abbrechen (cancel_request). Sonst
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf). // produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme // "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button // Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
const interruptAriaIfBusy = useCallback(() => { const interruptAriaIfBusy = useCallback(() => {
if (audioService.isPlayingAudio()) { if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)'); audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
return true;
} }
return false; return false;
}, []); }, []);
@@ -2255,7 +2368,7 @@ const ChatScreen: React.FC = () => {
// die Session auch app-seitig haben wir +2s Toleranz. // die Session auch app-seitig haben wir +2s Toleranz.
noSpeechTimeoutMs: 0, noSpeechTimeoutMs: 0,
endpointMs: await loadSttEndpointMs(), endpointMs: await loadSttEndpointMs(),
hardCapMs: 300000, hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
if (!ok) { if (!ok) {
@@ -2267,11 +2380,50 @@ const ChatScreen: React.FC = () => {
return true; return true;
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]); }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
// Befehl ist ausgefuehrt, ARIA hat die Rueckinfo (Skill-Ergebnis) und antwortet
// NICHT vorgelesen. Weil ohne TTS kein onPlaybackFinished kommt, muss der
// Aufnahme-/Konversations-Zustand hier aktiv aufgeraeumt werden, sonst bleibt
// das Ohr haengen bzw. das Aufnahme-Fenster laeuft leer weiter (Stefans
// Reproduktion: "spotify play" und das Mikro wartet trotzdem 30s).
// Unterschied zum manuellen Stop: der verwirft NICHT, sondern finalisiert die
// Aufnahme (User will seinen Satz verarbeitet haben) — hier ist der Befehl
// schon durch, ein evtl. offenes Folge-Fenster wird verworfen.
const ariaStopRecording = useCallback(async (reason: string): Promise<void> => {
converseRef.current = false;
// 1) Passiv-Lauschen: sauber beenden (cancelt den Stream selbst, startet
// KEINE neue passive Aufnahme).
if (wakeWordService.getState() === 'listening') {
await wakeWordService.exitPassiveListening('manual').catch(() => {});
return;
}
// 2) Noch offene Streaming-Aufnahme (aktiv / Barge-In) verwerfen.
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording(reason).catch(() => {});
}
// 3) Konversation beenden → zurueck aufs Wake-Word (skipPassive: kein 30s-Fenster).
if (wakeWordService.isConversing()) {
await wakeWordService.endConversation(true).catch(() => {});
} else if (!wakeWordService.isActive()) {
setWakeWordActive(false);
}
}, []);
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die // Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge // dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich // forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume. // endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => { const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
// Manueller Stop = endgueltig: auch die NACH der Antwort kommende
// onPlaybackFinished darf kein 30s-Passiv-Fenster mehr oeffnen.
converseRef.current = false;
// Stop = ALLES beenden, vorhersehbar. Spricht ARIA gerade, hart stoppen +
// laufende Brain-Antwort abbrechen (sonst "sagt sie ihren letzten Satz").
if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user stop');
rvs.send('cancel_request' as any, { hard: true, source: 'voice-stop' });
}
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden // Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten. // (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert // exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
@@ -2647,6 +2799,16 @@ const ChatScreen: React.FC = () => {
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'} {att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
</Text> </Text>
</TouchableOpacity> </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 <TouchableOpacity
style={styles.attachmentFile} style={styles.attachmentFile}
+108 -138
View File
@@ -63,14 +63,16 @@ import {
VAD_SILENCE_MIN_SEC, VAD_SILENCE_MIN_SEC,
VAD_SILENCE_MAX_SEC, VAD_SILENCE_MAX_SEC,
VAD_SILENCE_STORAGE_KEY, VAD_SILENCE_STORAGE_KEY,
CONV_WINDOW_DEFAULT_SEC, STT_ENDPOINT_DEFAULT_MS,
CONV_WINDOW_MIN_SEC, STT_ENDPOINT_MIN_MS,
CONV_WINDOW_MAX_SEC, STT_ENDPOINT_MAX_MS,
CONV_WINDOW_STORAGE_KEY, STT_ENDPOINT_STORAGE_KEY,
MAX_RECORDING_DEFAULT_SEC, MAX_RECORDING_DEFAULT_SEC,
MAX_RECORDING_MIN_SEC, MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC, MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY, MAX_RECORDING_STORAGE_KEY,
loadBargeInEnabled,
saveBargeInEnabled,
VAD_SILENCE_DB_DEFAULT, VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN, VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX, VAD_SILENCE_DB_MAX,
@@ -110,9 +112,10 @@ import wakeWordService, {
WAKE_THRESHOLD_MAX, WAKE_THRESHOLD_MAX,
loadWakeThreshold, loadWakeThreshold,
saveWakeThreshold, saveWakeThreshold,
PASSIVE_LISTEN_DEFAULT_MS, loadBgWakeEnabled,
loadPassiveListenMs, saveBgWakeEnabled,
savePassiveListenMs, loadWakeConfirmEnabled,
saveWakeConfirmEnabled,
} from '../services/wakeword'; } from '../services/wakeword';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
@@ -195,8 +198,12 @@ const SettingsScreen: React.FC = () => {
const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC); const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC); // 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 [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
const [bargeIn, setBargeIn] = useState<boolean>(false);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override // null = automatisch (adaptive Baseline), sonst manueller dB-Override
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null); const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false); const [showVadInfo, setShowVadInfo] = useState(false);
@@ -209,7 +216,10 @@ const SettingsScreen: React.FC = () => {
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true); const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT); const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
const [passiveSec, setPassiveSec] = useState<number>(Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)); // Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
const [bgWake, setBgWake] = useState<boolean>(false);
// Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus.
const [wakeConfirm, setWakeConfirm] = useState<boolean>(false);
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -298,11 +308,11 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => { AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
if (saved != null) { if (saved != null) {
const n = parseFloat(saved); const n = parseInt(saved, 10);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) { if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) {
setConvWindowSec(n); setSttEndpointSec(n / 1000);
} }
} }
}); });
@@ -314,6 +324,7 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
loadBargeInEnabled().then(setBargeIn).catch(() => {});
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => { AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') { if (saved != null && saved !== '') {
const n = parseFloat(saved); const n = parseFloat(saved);
@@ -333,7 +344,8 @@ const SettingsScreen: React.FC = () => {
}); });
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {}); loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {}); loadBgWakeEnabled().then(setBgWake).catch(() => {});
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1651,72 +1663,56 @@ const SettingsScreen: React.FC = () => {
{currentSection === 'voice_input' && (<> {currentSection === 'voice_input' && (<>
<Text style={styles.sectionTitle}>Spracheingabe</Text> <Text style={styles.sectionTitle}>Spracheingabe</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.toggleLabel}>Stille-Toleranz</Text> <View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Barge-in (unterbrechen)</Text>
<Text style={styles.toggleHint}>
AUS (empfohlen): ARIA spricht ihre Antwort ZU ENDE, dann geht das
Mikro auf — sauber, kein Selbst-Echo, du hoerst sie ganz. AN: du
kannst sie waehrend des Sprechens per Wake-Wort unterbrechen.
</Text>
</View>
<Switch
value={bargeIn}
onValueChange={(v) => { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={bargeIn ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Stille-Toleranz</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
Nachdenken; niedriger = schnelleres Senden. Nachdenken (z.B. im Auto); niedriger = schnelleres Senden.
Default: {VAD_SILENCE_DEFAULT_SEC.toFixed(1)}s. Default: {(STT_ENDPOINT_DEFAULT_MS / 1000).toFixed(1)}s.
</Text> </Text>
<View style={styles.prerollRow}> <View style={styles.prerollRow}>
<TouchableOpacity <TouchableOpacity
style={styles.prerollButton} style={styles.prerollButton}
onPress={() => { onPress={() => {
const next = Math.max(VAD_SILENCE_MIN_SEC, Math.round((vadSilenceSec - 0.5) * 10) / 10); const next = Math.max(STT_ENDPOINT_MIN_MS / 1000, Math.round((sttEndpointSec - 0.5) * 10) / 10);
setVadSilenceSec(next); setSttEndpointSec(next);
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(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> <Text style={styles.prerollButtonText}>0.5</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text> <Text style={styles.prerollValue}>{sttEndpointSec.toFixed(1)} s</Text>
<TouchableOpacity <TouchableOpacity
style={styles.prerollButton} style={styles.prerollButton}
onPress={() => { onPress={() => {
const next = Math.min(VAD_SILENCE_MAX_SEC, Math.round((vadSilenceSec + 0.5) * 10) / 10); const next = Math.min(STT_ENDPOINT_MAX_MS / 1000, Math.round((sttEndpointSec + 0.5) * 10) / 10);
setVadSilenceSec(next); setSttEndpointSec(next);
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(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> <Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
<Text style={styles.toggleHint}>
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
Sprichst du nichts Mikrofon zu.
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text> <Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet, Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
@@ -1749,56 +1745,10 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}> {/* "Stille-Pegel (dB)"-Regler entfernt: der aktive Streaming-STT nutzt
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text> einen adaptiven Rausch-Boden (automatisch), ein manueller dB-Wert war
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}> wirkungslos. Rauschen-als-Wort verhindert das STT-Modell selbst
<Text style={styles.infoBtnText}>i</Text> (no_speech_prob-Filter), nicht die dB-Schwelle. */}
</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>
)}
</View> </View>
<Modal <Modal
@@ -1954,38 +1904,58 @@ const SettingsScreen: React.FC = () => {
/> />
</View> </View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text> <View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Auch bei gesperrtem Bildschirm zuhören</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach AUS (empfohlen): das Wake-Wort greift nur, wenn die App offen ist
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet im Hintergrund sind die meisten Trigger" Fehlalarme (TV, Husten).
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort. AN: ARIA hört auch bei gesperrtem Bildschirm / im Hintergrund auf
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s. „{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" mehr Fehlauslöser möglich.
</Text> </Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(10, passiveSec - 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec <= 10}
>
<Text style={styles.prerollButtonText}>5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{passiveSec} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(60, passiveSec + 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec >= 60}
>
<Text style={styles.prerollButtonText}>+5</Text>
</TouchableOpacity>
</View> </View>
<Switch
value={bgWake}
onValueChange={(val) => {
setBgWake(val);
saveBgWakeEnabled(val).catch(() => {});
wakeWordService.setBgWakeEnabled(val);
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={bgWake ? '#FFFFFF' : '#666680'}
/>
</View>
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Wake-Wort per Voxtral bestätigen</Text>
<Text style={styles.toggleHint}>
Gegen Musik-Fehltrigger: nach {KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}"
prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur
Musik/TV) — erst dann Gong + Mikro. Kostet ~0,51 s Extra vor dem
Gong. Empfohlen zusammen mit Hintergrund-Zuhören.
</Text>
</View>
<Switch
value={wakeConfirm}
onValueChange={(val) => {
setWakeConfirm(val);
saveWakeConfirmEnabled(val).catch(() => {});
wakeWordService.setWakeConfirmEnabled(val);
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
Wake-Word weiterreden. Fängst du nicht innerhalb der „Stille-Toleranz"
(Sektion Spracheingabe) an, geht's zurück aufs Wake-Word. Reine
Steuerbefehle beenden sofort. Ein separates Zeitfenster gibt es nicht
mehr — es zählt überall derselbe Stille-Wert.
</Text>
</View> </View>
</>)} </>)}
+56 -24
View File
@@ -143,23 +143,67 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
export const VAD_SILENCE_MAX_SEC = 8.0; export const VAD_SILENCE_MAX_SEC = 8.0;
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec'; export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten // STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die // im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv; // zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings. // unter "Stille-Toleranz" konfigurierbar.
export const STT_ENDPOINT_DEFAULT_MS = 2400; export const STT_ENDPOINT_DEFAULT_MS = 2400;
export const STT_ENDPOINT_MIN_MS = 1000; export const STT_ENDPOINT_MIN_MS = 1000;
export const STT_ENDPOINT_MAX_MS = 4000; export const STT_ENDPOINT_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms'; export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
// Barge-in-Modus: darf man ARIA waehrend ihrer TTS-Antwort unterbrechen (reden)?
// Default AUS = sauberes Halb-Duplex (ARIA spricht aus, DANN oeffnet das Mikro —
// kein Selbst-Echo, kein Mischmasch). AN = waehrend TTS auf Wake-Wort lauschen.
export const BARGE_IN_STORAGE_KEY = 'aria_barge_in_enabled';
export async function loadBargeInEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(BARGE_IN_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BARGE_IN_STORAGE_KEY, String(enabled));
} catch {}
}
/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via
* Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und
* wartet auf stt_transcribe_result (matching requestId) mit Timeout.
* Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler →
* Aufrufer macht dann fail-open (Wake normal durchlassen). */
export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise<string | null> {
if (!pcmBase64) return null;
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string | null>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(null), timeoutMs);
function finish(val: string | null) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_transcribe_result') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.text === 'string' ? p.text : '');
});
rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' });
} catch {
finish(null);
}
});
}
export async function loadSttEndpointMs(): Promise<number> { export async function loadSttEndpointMs(): Promise<number> {
try { try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY); const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
@@ -189,18 +233,6 @@ export async function loadTtsSpeed(): Promise<number> {
return TTS_SPEED_DEFAULT; return TTS_SPEED_DEFAULT;
} }
export async function loadConvWindowMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
}
async function loadVadSilenceMs(): Promise<number> { async function loadVadSilenceMs(): Promise<number> {
try { try {
+164 -53
View File
@@ -30,34 +30,22 @@ type PassiveListenCallback = () => void;
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening'; export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation — /** Reine HANG-Notbremse fuer den Passive-Listen-Modus. Das echte Ende regelt IMMER
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt * die passive Aufnahme selbst: Stille-Toleranz (User pausiert), No-Speech (User
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */ * sagt gar nichts) oder Hard-Cap (max. Aufnahmedauer, ~5min) → ChatScreen ruft
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000; * dann exitPassiveListening. Dieser Timer darf aktives Reden NIE abschneiden —
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms'; * deshalb LÄNGER als der Hard-Cap (nur falls ein Endpoint-Event mal verloren geht
* und der State sonst ewig 'listening' bliebe). Das alte 30s-Fenster, das lange
export async function loadPassiveListenMs(): Promise<number> { * Antworten mitten im Satz kappte, ist damit raus. */
try { const PASSIVE_BACKSTOP_MS = 10 * 60_000;
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
}
} catch {}
return PASSIVE_LISTEN_DEFAULT_MS;
}
export async function savePassiveListenMs(ms: number): Promise<void> {
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
}
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword'; export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger = // Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher, // weniger Fehlauslösung, aber man muss deutlicher/lauter sprechen (fuehlt sich
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS // "traege" an). Fehlausloeser werden ueber Speaker-ID (E3) ohnehin verworfen,
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1. // deshalb darf der Default empfindlicher sein. 0.45 (war 0.6/0.5). 0..1.
export const WAKE_THRESHOLD_DEFAULT = 0.6; export const WAKE_THRESHOLD_DEFAULT = 0.45;
export const WAKE_THRESHOLD_MIN = 0.3; export const WAKE_THRESHOLD_MIN = 0.3;
export const WAKE_THRESHOLD_MAX = 0.9; export const WAKE_THRESHOLD_MAX = 0.9;
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold'; export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
@@ -77,6 +65,49 @@ export async function saveWakeThreshold(v: number): Promise<void> {
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v)); await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
} }
// Hintergrund-Wake: darf das Wake-Wort auch triggern, wenn die App im
// Hintergrund / der Bildschirm gesperrt ist? Default AUS — im Hintergrund
// sind die meisten „Trigger" Fehlalarme (TV, Husten, AudioFocus-Spikes).
// AN = auch bei gesperrtem Bildschirm zuhoeren. Die native Erkennung laeuft
// ohnehin durch (Foreground-Service + Wake-Locks) — dieser Schalter oeffnet
// nur das JS-Gate in onWakeDetected.
export const BG_WAKE_STORAGE_KEY = 'aria_bg_wake_enabled';
export async function loadBgWakeEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(BG_WAKE_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BG_WAKE_STORAGE_KEY, String(enabled));
} catch {}
}
// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio
// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?").
// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro
// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im
// Event (neueres APK) — ohne das macht die App normal weiter.
export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled';
export async function loadWakeConfirmEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveWakeConfirmEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_CONFIRM_STORAGE_KEY, String(enabled));
} catch {}
}
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in /** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes * android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut * Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -103,7 +134,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar // Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
// (loadWakeThreshold) — der Wert hier ist nur der Fallback. // (loadWakeThreshold) — der Wert hier ist nur der Fallback.
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT; const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
const DEFAULT_PATIENCE = 2; // patience=1 statt 2: nur EIN Frame ueber Threshold noetig → deutlich schneller.
// Speaker-ID filtert Fehlausloeser, also ist das vertretbar.
const DEFAULT_PATIENCE = 1;
const DEFAULT_DEBOUNCE_MS = 1500; const DEFAULT_DEBOUNCE_MS = 1500;
interface OpenWakeWordModule { interface OpenWakeWordModule {
@@ -143,6 +176,13 @@ class WakeWordService {
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten, * Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
* AudioFocus-Switch beim Wechsel zu Musik etc.). */ * AudioFocus-Switch beim Wechsel zu Musik etc.). */
private inBackground: boolean = false; private inBackground: boolean = false;
/** Wenn true: Wake-Wort triggert auch im Hintergrund / bei gesperrtem
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
private bgWakeEnabled: boolean = false;
/** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false.
* Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */
private wakeConfirmEnabled: boolean = false;
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere /** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS * WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite * resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
@@ -150,8 +190,9 @@ class WakeWordService {
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger * Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */ * waehrend ARIA noch redet, NICHT vom Guard blockieren. */
private detectionInProgress: boolean = false; private detectionInProgress: boolean = false;
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech, /** Passive-Listen-Backstop-Timer: Notbremse (PASSIVE_BACKSTOP_MS). Normal endet
* beendet den listening-State und geht zurueck zu armed. */ * das Fenster ueber die Stille-Toleranz der Aufnahme; feuert dieser Timer
* trotzdem, zurueck zu armed. */
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null; private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet /** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */ * hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
@@ -223,15 +264,20 @@ class WakeWordService {
this.initInProgress = (async () => { this.initInProgress = (async () => {
try { try {
const threshold = await loadWakeThreshold(); const threshold = await loadWakeThreshold();
console.log('[WakeWord] init mit threshold=%s', threshold); this.bgWakeEnabled = await loadBgWakeEnabled();
this.wakeConfirmEnabled = await loadWakeConfirmEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s',
threshold, this.bgWakeEnabled, this.wakeConfirmEnabled);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS); await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal // Subscribe nur einmal
if (!this.eventSub) { if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord); const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
this.eventSub = emitter.addListener('WakeWordDetected', () => { this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => {
console.log('[WakeWord] Native Detection-Event empfangen'); console.log('[WakeWord] Native Detection-Event empfangen');
this.onWakeDetected().catch(err => // payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
console.warn('[WakeWord] onWakeDetected crashed:', err)); // nur in neueren APKs vorhanden; ohne = fail-open (kein Verify).
this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null)
.catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err));
}); });
} }
this.nativeReady = true; this.nativeReady = true;
@@ -310,7 +356,7 @@ class WakeWordService {
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren. /** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen * Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
* die openWakeWord faelschlich als Trigger interpretiert. */ * die openWakeWord faelschlich als Trigger interpretiert. */
setResumeCooldown(ms: number = 1500): void { setResumeCooldown(ms: number = 500): void {
this.cooldownUntilMs = Date.now() + ms; this.cooldownUntilMs = Date.now() + ms;
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms); console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
} }
@@ -320,23 +366,45 @@ class WakeWordService {
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */ * was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
setBackground(): void { setBackground(): void {
this.inBackground = true; this.inBackground = true;
console.log('[WakeWord] App im Hintergrund — Detections gesperrt'); console.log('[WakeWord] App im Hintergrund — Detections %s',
this.bgWakeEnabled ? 'AKTIV (Hintergrund-Wake an)' : 'gesperrt');
} }
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown /** Hintergrund-Wake ein/aus schalten (aus den Einstellungen). */
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach setBgWakeEnabled(enabled: boolean): void {
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */ this.bgWakeEnabled = enabled;
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
}
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
setWakeConfirmEnabled(enabled: boolean): void {
this.wakeConfirmEnabled = enabled;
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
}
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
isBgWakeEnabled(): boolean {
return this.bgWakeEnabled;
}
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
* App-Oeffnen wurden verschluckt). */
setForeground(): void { setForeground(): void {
this.inBackground = false; this.inBackground = false;
this.cooldownUntilMs = Date.now() + 3000; this.cooldownUntilMs = Date.now() + 1000;
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv'); console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
} }
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
private async onWakeDetected(): Promise<void> { * preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
if (this.inBackground) { * (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)'); private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{}); if (this.inBackground && !this.bgWakeEnabled) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
return; return;
} }
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft. // Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
@@ -379,6 +447,22 @@ class WakeWordService {
// Kein erneutes setState — wir bleiben in 'conversing'. // Kein erneutes setState — wir bleiben in 'conversing'.
return; return;
} }
// Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel
// von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen
// (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail-
// open: ohne PCM / bei Timeout/Fehler laeuft es normal durch.
if (this.wakeConfirmEnabled && preTriggerPcm) {
const confirmed = await this.confirmWake(preTriggerPcm);
if (!confirmed) {
this.detectionInProgress = false;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (e) {
console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e);
}
}
return;
}
}
this.setState('conversing'); this.setState('conversing');
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt // Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang // Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
@@ -392,6 +476,34 @@ class WakeWordService {
}); });
} }
/** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt
* (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */
private async confirmWake(pcm: string): Promise<boolean> {
try {
const audio = await import('./audio');
const text = await audio.transcribeBlob(pcm);
if (text === null) {
console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)');
return true;
}
const norm = text.toLowerCase();
// Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern,
// taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen).
const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ')
.split(/\s+/).filter(w => w.length >= 4);
if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar
const ok = kwWords.some(w => norm.includes(w));
console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s',
text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)');
import('./logger').then(m => m.reportAppDebug('wake.confirm',
`text="${text.slice(0, 40)}" kw=${kwWords.join('|')}${ok ? 'ok' : 'reject'}`)).catch(() => {});
return ok;
} catch (e) {
console.warn('[WakeWord] confirmWake err → fail-open:', e);
return true;
}
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann /** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im * "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert. * Native-Modul verhindert dass ARIAs eigene Stimme triggert.
@@ -486,13 +598,12 @@ class WakeWordService {
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{}); `endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen // Kein skipPassive? Dann EIN Stille-Fenster zum Weiterreden (kein Wake-Word
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID // noetig). Das echte Ende regelt die Stille-Toleranz der passiven Aufnahme;
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute // der Backstop-Timer ist nur die Notbremse. Der User kann ohne erneute
// Anrede weitersprechen. // Anrede weitersprechen; sagt er nichts → zurueck aufs Wake-Word.
const passiveMs = await loadPassiveListenMs(); if (!skipPassive && this.nativeReady) {
if (!skipPassive && passiveMs > 0 && this.nativeReady) { this.enterPassiveListening(PASSIVE_BACKSTOP_MS);
this.enterPassiveListening(passiveMs);
return; return;
} }
@@ -534,10 +645,10 @@ class WakeWordService {
this.cancelPassiveListenTimer(); this.cancelPassiveListenTimer();
this.setState('listening'); this.setState('listening');
const seconds = Math.round(durationMs / 1000); const seconds = Math.round(durationMs / 1000);
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds); console.log('[WakeWord] Passive-Listen aktiv (Backstop %ds) — Speaker-ID gefiltert', seconds);
import('./logger').then(m => m.reportAppDebug('wake.passive', import('./logger').then(m => m.reportAppDebug('wake.passive',
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{}); `entered listening (backstop ${seconds}s), cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT); ToastAndroid.show('🎧 sprich einfach weiter', ToastAndroid.SHORT);
this.passiveListenTimer = setTimeout(() => { this.passiveListenTimer = setTimeout(() => {
this.passiveListenTimer = null; this.passiveListenTimer = null;
this.exitPassiveListening('timeout').catch(() => {}); this.exitPassiveListening('timeout').catch(() => {});
+261 -6
View File
@@ -132,6 +132,34 @@ WEB_SEARCH_TOOL = {
# Meta-Tool: ARIA kann selbst neue Skills bauen # Meta-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [ META_TOOLS = [
{
"type": "function",
"function": {
"name": "ear_control",
"description": (
"Schaltet dein Ohr (den Wake-Word-Listener) an oder aus. Nutze das, "
"wenn Stefan will dass du aufhoerst zuzuhoeren — EGAL wie er es "
"formuliert: 'leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute "
"Nacht', 'mach mal Pause vom Zuhoeren', 'ich geh ins Bett, du kannst "
"aus' → action='off'. Wenn er dich wieder aktivieren will ('Ohr an', "
"'wach auf', 'hoer wieder zu') → action='on'. Reiner Steuerbefehl: "
"die App stoppt/startet den Listener, du bestaetigst nur kurz (wird "
"nicht vorgelesen). Nach 'off' geht Wieder-An per 'Ohr an' (Text/"
"Aufnahme-Knopf) oder App-Button."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["off", "on"],
"description": "off = Ohr aus (nicht mehr zuhoeren), on = Ohr wieder an",
},
},
"required": ["action"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -1347,6 +1375,154 @@ def _extract_await_marker(text: str) -> tuple:
return text, False return text, False
# ── Sprach-/Gespraechs-Steuermarker (ARIA deklariert die Phase SELBST) ──
#
# Voice-First: ARIA erkennt aus dem Text, ob Stefan einen BEFEHL gibt (etwas tun)
# oder eine FRAGE stellt (etwas wissen), und ob das Gespraech/eine Befehlskette
# weiterlaeuft oder endet. Sie haengt dazu Marker ans Ende ihrer Antwort — genau
# wie [[AWAIT]], und sie werden ebenso entfernt (nicht angezeigt/vorgelesen/in
# History). Der Marker ist AUTORITATIV — er ueberschreibt das Skill-Manifest-Flag,
# denn dasselbe Skill (z.B. VM-/GUI-Steuerung) ist mal Befehl, mal Auskunft; nur
# ARIA weiss aus dem Kontext, was gerade gemeint ist.
#
# [[STUMM]] -> reiner Steuerbefehl: NICHT vorlesen (speak=false). Allein =
# Einzelbefehl → danach zurueck aufs Wake-Word (converse=false).
# [[WEITER]] -> Konversation/Befehlskette laeuft weiter: Mikro offen halten
# (converse=true) — kein erneutes "Computer" noetig.
# [[ENDE]] -> Konversation/Kette beenden: zurueck aufs Wake-Word (converse=false).
_SILENT_MARKER_RE = re.compile(r"\[\[\s*STUMM\s*\]\]", re.IGNORECASE)
_CONT_MARKER_RE = re.compile(r"\[\[\s*WEITER\s*\]\]", re.IGNORECASE)
_END_MARKER_RE = re.compile(r"\[\[\s*ENDE\s*\]\]", re.IGNORECASE)
def _extract_flow_markers(text: str) -> tuple:
"""Zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem finalen Text.
Gibt (clean_text, speak_override, converse_override) zurueck; ein Override ist
None, wenn der jeweilige Marker fehlt (dann gilt Default/Skill-Flag).
Regeln: [[STUMM]] alleine = Einzelbefehl → auch converse=false (Mikro zu),
ausser [[WEITER]] haelt es explizit offen. [[ENDE]] gewinnt gegen [[WEITER]]."""
if not text:
return text, None, None
speak_ov = None
conv_ov = None
if _SILENT_MARKER_RE.search(text):
speak_ov = False
text = _SILENT_MARKER_RE.sub("", text)
if _END_MARKER_RE.search(text):
conv_ov = False
text = _END_MARKER_RE.sub("", text)
if _CONT_MARKER_RE.search(text):
# [[ENDE]] hat Vorrang — widerspruechliche Marker → beenden.
if conv_ov is None:
conv_ov = True
text = _CONT_MARKER_RE.sub("", text)
# Stiller Einzelbefehl ohne explizites Weiterlauschen → Mikro zu.
if speak_ov is False and conv_ov is None:
conv_ov = False
return text.strip(), speak_ov, conv_ov
# Explizite "Konversation beenden"-Phrasen vom USER — deterministisch, NICHT auf
# ARIAs [[ENDE]]-Marker angewiesen. Stefan will "Konversation Ende" o.ae. als
# festen Trigger: danach zurueck aufs Wake-Word, egal was ARIA sonst tut. Eine in
# derselben Nachricht enthaltene Frage beantwortet sie normal (wird vorgelesen),
# aber converse wird auf false gezwungen. Nomen + Ende-Wort in EINEM Satzteil
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
# o.ae. nicht faelschlich matcht.
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)"
_CONV_END_VERB = r"(?:ende|beend\w*|aus|stop\w*|schluss)"
_END_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b"
rf"|\b(?:beend\w*|schlie(?:ß|ss)\w*)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
re.IGNORECASE,
)
def _user_wants_conversation_end(text: str) -> bool:
"""True, wenn der User in dieser Nachricht explizit die Konversation/Kette
beenden will (deterministisch, unabhaengig vom LLM-Marker)."""
if not text:
return False
return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
# Gegenstueck zu _END: expliziter "Konversation OFFEN halten / fortfuehren"-Wunsch.
# Wichtig fuer BEFEHLE die den Fast-Path treffen: "spiel Spotify ab ABER Konversation
# fortfuehren" — der Fast-Path (Regex) versteht den Satz-Rest nicht und wuerde mit
# converse=false schliessen. Dieser Detektor erzwingt converse=true, auch am
# Fast-Path, egal was das Skill-Manifest sagt. Nomen+Verb in einem Satzteil, plus
# "weiter reden/sprechen" ohne Nomen.
_CONT_VERB = (r"(?:fortf[uü]hr\w*|fortsetz\w*|weiterf[uü]hr\w*|weiter\s*mach\w*|"
r"weiter\b|fort\b|offen\s+(?:halten|lassen)|nicht\s+beenden|weiterlauf\w*)")
_CONTINUE_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,20}}\b{_CONT_VERB}"
rf"|\b{_CONT_VERB}[^.!?]{{0,20}}\b{_CONV_NOUN}\b"
rf"|\bweiter\s*(?:reden|sprechen|quatschen|plaudern|labern)\b",
re.IGNORECASE,
)
def _user_wants_conversation_continue(text: str) -> bool:
"""True, wenn der User explizit weiter im Gespraech bleiben will (converse=true
erzwingen — auch bei einem Fast-Path-Befehl). [[ENDE]]/_wants_end hat Vorrang."""
if not text:
return False
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
# ── Wake-Word AUS per Sprachbefehl ──────────────────────────────────────────
# "Wake-Word aus", "mach das Ohr aus", "hoer auf zuzuhoeren", "geh schlafen" …
# → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte Ruhe).
# Wieder-An geht nur ueber den App-Button (bewusst, weil dann taub). Reiner
# Steuerbefehl: still (speak=false), kein Weiterlauschen (converse=false).
_WAKE_NOUN = r"(?:wake[\s-]?word|wakeword|ohr(?:en)?|mikro(?:fon)?|zuh[oö]r\w*|lausch\w*)"
_WAKE_OFF_VERB = (r"(?:aus(?:schalten|stellen)?|abschalten|abstellen|deaktivier\w*|"
r"beenden|beende|stopp?\w*|schlafen|ruhe)")
_WAKE_OFF_RE = re.compile(
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_OFF_VERB}\b"
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
# Schlaf-Idiome NUR als klare Imperative an ARIA ('geh/leg dich schlafen',
# 'leg dich aufs Ohr/hin'). Mehrdeutige Gruesse ('gute Nacht', 'schlaf gut')
# bewusst NICHT — das sind Verabschiedungen, kein Ohr-Aus-Befehl; die faengt
# ARIA per ear_control aus dem Kontext ab, wenn's wirklich gemeint ist.
rf"|(?:geh|leg\s+dich)\s+schlafen"
rf"|leg\s+dich\s+(?:aufs?\s+ohr|hin)",
re.IGNORECASE,
)
def _user_wants_wake_off(text: str) -> bool:
"""True, wenn der User den Wake-Word-Listener per Sprache abschalten will."""
if not text:
return False
return bool(_WAKE_OFF_RE.search(_strip_leading_hint_blocks(text)))
# ── Wake-Word AN per Befehl (Text ODER manueller Aufnahme-Button) ────────────
# Gegenstueck zu wake_off. Das "Wieder-An" per Stimme geht NICHT ueber "Computer"
# (das hoert ja nicht mehr), aber ueber eine Text-Nachricht oder den manuellen
# Aufnahme-Button — beide laufen unabhaengig vom Wake-Word-Listener. Die App
# startet den Listener dann wieder (wakeWordService.start()).
_WAKE_ON_VERB = r"(?:an(?:schalten|machen|stellen)?|einschalten|aktivier\w*|starte\w*|reaktivier\w*)"
_WAKE_ON_RE = re.compile(
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_ON_VERB}\b"
rf"|\b(?:aktivier\w*|reaktivier\w*|starte\w*)\b[^.!?]{{0,15}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+(?:mir\s+)?wieder\s+zu"
rf"|(?:wieder|erneut)\s+(?:zu\s*)?(?:h[oö]r|lausch)\w*"
rf"|wach\s+auf|aufwachen",
re.IGNORECASE,
)
def _user_wants_wake_on(text: str) -> bool:
"""True, wenn der User den Wake-Word-Listener per Befehl wieder anschalten will."""
if not text:
return False
return bool(_WAKE_ON_RE.search(_strip_leading_hint_blocks(text)))
def _normalize_for_fast_match(text: str) -> str: def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower() norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm) norm = _fold_umlauts(norm)
@@ -1755,6 +1931,14 @@ class Agent:
if not user_message: if not user_message:
raise ValueError("Leere Nachricht") raise ValueError("Leere Nachricht")
# Explizite Gespraechs-Steuerung vom USER (deterministisch, an JEDEM Return
# angewendet — auch am Fast-Path, den die LLM-Marker nicht erreichen):
# _wants_end → converse=false ("Konversation Ende")
# _wants_continue → converse=true ("... aber Konversation fortfuehren")
# End hat Vorrang bei Widerspruch.
_wants_end = _user_wants_conversation_end(user_message)
_wants_continue = (not _wants_end) and _user_wants_conversation_continue(user_message)
# Events vom letzten Turn weglassen # Events vom letzten Turn weglassen
self._pending_events = [] self._pending_events = []
@@ -1762,6 +1946,27 @@ class Agent:
active_project_id = (project_id or "").strip() active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Wake-Word AUS per Sprache: reiner Steuerbefehl, KEIN Claude/Fast-Path
# noetig. Still (speak=false) + kein Weiterlauschen (converse=false). Das
# tatsaechliche Stoppen des Listeners macht die App anhand von wake_off in
# der Antwort (ChatOut) — hier signalisieren wir es nur. Wieder-An: App-Button.
if _user_wants_wake_off(user_message):
reply = "Ohr aus. Sag 'Wake-Word an' (per Text oder Aufnahme-Knopf) oder tipp den Ohr-Button, wenn ich wieder lauschen soll. 🔇"
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
return reply, "wake-off", False, False, False
# Wake-Word AN per Befehl (Text/Aufnahme-Button): App startet den Listener.
if _user_wants_wake_on(user_message):
reply = "Ohr wieder an — ich lausche auf 'Computer'. 👂"
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-on] Befehl erkannt — App startet Listener")
return reply, "wake-on", False, False, False
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett. # Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain # Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz. # iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
@@ -1782,6 +1987,10 @@ class Agent:
speak = bool(getattr(self, "_fast_path_speak", False)) speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False. # converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False)) converse = bool(getattr(self, "_fast_path_converse", False))
if _wants_end:
converse = False
elif _wants_continue:
converse = True
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False. # Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False return fast_reply, "fast-path", speak, converse, False
@@ -1801,6 +2010,10 @@ class Agent:
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s). # dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True) speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True) converse = getattr(self, "_local_turn_converse", True)
if _wants_end:
converse = False
elif _wants_continue:
converse = True
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude. # Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False return local_reply, "local", speak, converse, False
@@ -1818,6 +2031,15 @@ class Agent:
logger.warning("Cold-Search fehlgeschlagen: %s", exc) logger.warning("Cold-Search fehlgeschlagen: %s", exc)
cold = [] cold = []
# 3b. Titel-Index des kalten Gedaechtnisses — ARIA sieht WAS sie an
# Nachschlage-Wissen hat (Zugangsdaten, Infra, Projekte) und holt es via
# memory_search, statt Stefan danach zu fragen. Nur Titel = billig.
try:
memory_index = self.store.list_index_titles()
except Exception as exc:
logger.warning("Titel-Index laden fehlgeschlagen: %s", exc)
memory_index = []
# 4. Aktive Skills holen + Tool-Liste bauen # 4. Aktive Skills holen + Tool-Liste bauen
all_skills = skills_mod.list_skills(active_only=False) all_skills = skills_mod.list_skills(active_only=False)
active_skills = [s for s in all_skills if s.get("active", True)] active_skills = [s for s in all_skills if s.get("active", True)]
@@ -1840,7 +2062,8 @@ class Agent:
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip() oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false" oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
system_prompt = build_system_prompt(hot, cold, skills=all_skills, system_prompt = build_system_prompt(hot, cold, memory_index=memory_index,
skills=all_skills,
triggers=all_triggers, triggers=all_triggers,
condition_vars=condition_vars, condition_vars=condition_vars,
condition_funcs=condition_funcs, condition_funcs=condition_funcs,
@@ -1942,6 +2165,7 @@ class Agent:
# Konversation bleiben gesprochen. # Konversation bleiben gesprochen.
self._claude_turn_speak = True self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
try: try:
for iteration in range(self.MAX_TOOL_ITERATIONS): for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools, result = self.proxy.chat_full(messages, tools=tools,
@@ -2033,16 +2257,38 @@ class Agent:
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit # Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet). # er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
final_reply, awaiting_reply = _extract_await_marker(final_reply) final_reply, awaiting_reply = _extract_await_marker(final_reply)
# ARIAs Phasen-Marker ([[STUMM]]/[[WEITER]]/[[ENDE]]) ziehen — VOR History,
# damit sie nicht angezeigt/vorgelesen/gespeichert werden.
final_reply, _speak_ov, _conv_ov = _extract_flow_markers(final_reply)
# 7. Assistant-Turn (final reply) in die Conversation # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply, self.conversation.add("assistant", final_reply,
project_id=active_project_id) project_id=active_project_id)
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech); # speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
# ARIAs Phasen-Marker sind AUTORITATIV: sie kennt aus dem Text den Unter-
# schied Befehl/Frage und Kette/Ende, den das Skill-Manifest nicht kennt.
speak = bool(getattr(self, "_claude_turn_speak", True))
converse = bool(getattr(self, "_claude_turn_converse", True))
if _speak_ov is not None:
speak = _speak_ov
if _conv_ov is not None:
converse = _conv_ov
# Explizite User-Woerter gewinnen ueber Marker/Manifest: "Konversation
# beenden" → zu; "... fortfuehren" → offen halten. End hat Vorrang.
if _wants_end:
converse = False
elif _wants_continue:
converse = True
# ear_control-Tool aufgerufen → Ohr schalten. answered_by=wake-off/wake-on
# nutzt die bestehende Plumbing (main.py → wake_off/wake_on → Bridge → App).
# Reiner Steuerbefehl: still + kein Weiterlauschen.
answered_by = "claude"
if self._ear_control == "off":
answered_by, speak, converse = "wake-off", False, False
elif self._ear_control == "on":
answered_by, speak, converse = "wake-on", False, False
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert). # awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude", return (final_reply, answered_by, speak, converse, awaiting_reply)
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)),
awaiting_reply)
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -2637,6 +2883,15 @@ class Agent:
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als " f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline." f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
) )
if name == "ear_control":
action = (arguments.get("action") or "").strip().lower()
if action not in ("off", "on"):
return "FEHLER: action muss 'off' oder 'on' sein."
self._ear_control = action
logger.info("[ear_control] action=%s — App schaltet den Listener", action)
return ("Ohr wird ausgeschaltet — Wieder-An per 'Ohr an' (Text/"
"Aufnahme-Knopf) oder App-Button." if action == "off"
else "Ohr wird wieder eingeschaltet.")
if name == "memory_search": if name == "memory_search":
query = (arguments.get("query") or "").strip() query = (arguments.get("query") or "").strip()
if not query: if not query:
+90
View File
@@ -0,0 +1,90 @@
"""Einmaliger Backfill: weist bestehenden Memory-Punkten ein `scope`
(system | personal) zu. Sicher & reversibel — Stefan kann pro Eintrag in der
Diagnostic-UI umschalten. Idempotent: laeuft mehrfach ohne Schaden.
Heuristik (datengetrieben aus dem realen Bestand):
- type=preference / fact / conversation / reminder -> personal
- source in (seed, auto-feedback) -> system
- type=identity -> system
- type in (rule, tool, skill) und category in SYSTEM_CATS -> system
- sonst -> personal (sicher: nichts leakt)
Aufruf im Brain-Container:
docker exec aria-brain python3 /app/backfill_scope.py # dry-run
docker exec aria-brain python3 /app/backfill_scope.py --apply # schreibt
"""
import os
import sys
from collections import Counter
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
COLLECTION = "aria_memory"
SYSTEM_CATS = {
"sicherheit", "arbeitsweise", "architektur", "ehrlichkeit", "verhalten",
"voice", "skills", "freigaben", "infrastruktur", "persoenlichkeit",
"pentest", "ausgabe",
}
def compute_scope(pl: dict) -> str:
typ = pl.get("type")
src = pl.get("source")
cat = (pl.get("category") or "").lower()
if typ == "preference":
return "personal"
if typ in ("fact", "conversation", "reminder"):
return "personal"
if src in ("seed", "auto-feedback"):
return "system"
if typ == "identity":
return "system"
if typ in ("rule", "tool", "skill") and cat in SYSTEM_CATS:
return "system"
return "personal"
def main():
apply = "--apply" in sys.argv
force = "--force" in sys.argv # auch schon gesetzte scopes ueberschreiben
c = QdrantClient(
host=os.environ.get("QDRANT_HOST", "aria-qdrant"),
port=int(os.environ.get("QDRANT_PORT", "6333")),
)
pts, _ = c.scroll(collection_name=COLLECTION, limit=5000,
with_payload=True, with_vectors=False)
per_scope: dict[str, list] = {"system": [], "personal": []}
pinned_examples = Counter()
skipped = 0
for p in pts:
pl = p.payload or {}
if pl.get("scope") in ("system", "personal") and not force:
skipped += 1
continue
scope = compute_scope(pl)
per_scope[scope].append(p.id)
if pl.get("pinned"):
pinned_examples[(scope, pl.get("source"), pl.get("type"),
pl.get("category"))] += 1
print(f"total={len(pts)} skipped(already set)={skipped}")
print(f"-> system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
print("pinned split (scope, source, type, category):")
for k, v in sorted(pinned_examples.items()):
print(" ", k, v)
if not apply:
print("\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
return
for scope, ids in per_scope.items():
if not ids:
continue
c.set_payload(collection_name=COLLECTION, payload={"scope": scope}, points=ids)
print(f"\nAPPLIED: system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
if __name__ == "__main__":
main()
+51 -12
View File
@@ -190,6 +190,7 @@ class MemoryIn(BaseModel):
pinned: bool = False pinned: bool = False
category: str = "" category: str = ""
source: str = "manual" source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
conversation_id: Optional[str] = None conversation_id: Optional[str] = None
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge # Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
@@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel):
content: Optional[str] = None content: Optional[str] = None
pinned: Optional[bool] = None pinned: Optional[bool] = None
category: Optional[str] = None category: Optional[str] = None
scope: Optional[str] = None # system | personal
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
@@ -214,6 +216,7 @@ class MemoryOut(BaseModel):
pinned: bool pinned: bool
category: str category: str
source: str source: str
scope: str = "personal"
tags: List[str] tags: List[str]
created_at: str created_at: str
updated_at: str updated_at: str
@@ -328,6 +331,7 @@ def memory_save(body: MemoryIn):
pinned=body.pinned, pinned=body.pinned,
category=body.category, category=body.category,
source=body.source, source=body.source,
scope=body.scope,
tags=body.tags, tags=body.tags,
conversation_id=body.conversation_id, conversation_id=body.conversation_id,
attachments=body.attachments or [], attachments=body.attachments or [],
@@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
existing.pinned = body.pinned existing.pinned = body.pinned
if body.category is not None: if body.category is not None:
existing.category = body.category existing.category = body.category
if body.scope is not None:
existing.scope = body.scope
if body.tags is not None: if body.tags is not None:
existing.tags = body.tags existing.tags = body.tags
@@ -537,12 +543,23 @@ def memory_import_files():
# Wiederherstellen einer schlanken ARIA nach Wipe. # Wiederherstellen einer schlanken ARIA nach Wipe.
@app.get("/memory/export-bootstrap") @app.get("/memory/export-bootstrap")
def memory_export_bootstrap(): def memory_export_bootstrap(scope: str = "system"):
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download.""" """Gibt pinned Memories als JSON zurueck — fuer Browser-Download.
scope='system' → nur generische Regeln (fuer ein frisches System),
scope='personal' → nur Stefan-spezifisches (Name, Zugangsdaten, Projekte),
scope='all' → alles pinned (Vollbackup).
Default 'system', damit man nicht versehentlich Persoenliches teilt."""
s = store() s = store()
if scope == "all":
pinned = s.list_pinned() pinned = s.list_pinned()
elif scope in ("system", "personal"):
pinned = s.list_pinned_by_scope(scope)
else:
raise HTTPException(400, f"Ungueltiger scope: {scope}")
return { return {
"version": 1, "version": 2,
"scope": scope,
"exported_at": __import__("datetime").datetime.now( "exported_at": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc __import__("datetime").timezone.utc
).isoformat(), ).isoformat(),
@@ -555,6 +572,7 @@ def memory_export_bootstrap():
"pinned": True, "pinned": True,
"category": p.category, "category": p.category,
"source": p.source, "source": p.source,
"scope": p.scope,
"tags": p.tags, "tags": p.tags,
} }
for p in pinned for p in pinned
@@ -564,13 +582,18 @@ def memory_export_bootstrap():
class BootstrapBundle(BaseModel): class BootstrapBundle(BaseModel):
version: int = 1 version: int = 1
scope: Optional[str] = None # system | personal | all (aus dem Export)
memories: List[dict] memories: List[dict]
@app.post("/memory/import-bootstrap") @app.post("/memory/import-bootstrap")
def memory_import_bootstrap(body: BootstrapBundle): def memory_import_bootstrap(body: BootstrapBundle):
"""Loescht alle pinned Memories und importiert die im Bundle. """Importiert ein Bootstrap-Bundle scope-sicher.
Cold Memory (unpinned) bleibt unangetastet.
Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import
gehoert — ein System-Import laesst also die persoenlichen pinned Memories
(Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle
(Vollbackup) werden alle pinned ersetzt.
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
Caller soll erst exportieren und dann importieren. Caller soll erst exportieren und dann importieren.
@@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
s = store() s = store()
e = embedder() e = embedder()
# Alle aktuell pinned Punkte loeschen
from qdrant_client.http import models as qm from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION from memory.vector_store import COLLECTION
# Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten.
bundle_scope = body.scope
if bundle_scope not in ("system", "personal", "all"):
scopes_in_mems = {m.get("scope", "personal") for m in body.memories}
bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all"
# Nur die pinned Punkte des betroffenen scope loeschen.
del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))]
if bundle_scope in ("system", "personal"):
del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope)))
s.client.delete( s.client.delete(
collection_name=COLLECTION, collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[ points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
])),
) )
# Neue Punkte einspeisen # Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
created = 0 created = 0
for m in body.memories: for m in body.memories:
content = (m.get("content") or "").strip() content = (m.get("content") or "").strip()
if not content: if not content:
continue continue
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
point = MemoryPoint( point = MemoryPoint(
id="", id="",
type=m.get("type", "fact"), type=m.get("type", "fact"),
@@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
pinned=True, pinned=True,
category=m.get("category", ""), category=m.get("category", ""),
source=m.get("source", "bootstrap-import"), source=m.get("source", "bootstrap-import"),
scope=mscope,
tags=list(m.get("tags", [])), tags=list(m.get("tags", [])),
) )
vec = e.embed(content) vec = e.embed(content)
s.upsert(point, vec) s.upsert(point, vec)
created += 1 created += 1
return {"created": created, "deleted_previous_pinned": True} return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
# ─── Conversation-Loop ────────────────────────────────────────────── # ─── Conversation-Loop ──────────────────────────────────────────────
@@ -644,6 +676,11 @@ class ChatOut(BaseModel):
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die # Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen. # naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False awaiting_reply: bool = False
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
# Wake-Word-Listener komplett (Mikro frei).
wake_off: bool = False
# "Wake-Word an" per Befehl (Text/Aufnahme-Button) → App startet den Listener.
wake_on: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -753,6 +790,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
speak=speak, speak=speak,
converse=converse, converse=converse,
awaiting_reply=awaiting_reply, awaiting_reply=awaiting_reply,
wake_off=(answered_by == "wake-off"),
wake_on=(answered_by == "wake-on"),
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+52 -4
View File
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
content — eigentlicher Text (wird embedded) content — eigentlicher Text (wird embedded)
pinned — bool, True = Hot Memory (immer in Prompt) pinned — bool, True = Hot Memory (immer in Prompt)
source — import | conversation | manual source — import | conversation | manual
scope — system | personal. system = generische Regeln, die JEDER
braucht, der das System aufsetzt (Sicherheit, Ehrlichkeit,
Skill-Regeln). personal = Stefan-spezifisch (Name, Zugangs-
daten, Projekte). Steuert den getrennten Bootstrap-Export.
tags — Liste von Strings tags — Liste von Strings
created_at, updated_at — ISO-Strings created_at, updated_at — ISO-Strings
conversation_id — optional, nur fuer type=conversation conversation_id — optional, nur fuer type=conversation
@@ -55,6 +59,7 @@ class MemoryPoint:
pinned: bool = False pinned: bool = False
category: str = "" category: str = ""
source: str = "manual" source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
created_at: str = "" created_at: str = ""
updated_at: str = "" updated_at: str = ""
@@ -74,6 +79,7 @@ class MemoryPoint:
"pinned": self.pinned, "pinned": self.pinned,
"category": self.category, "category": self.category,
"source": self.source, "source": self.source,
"scope": self.scope,
"tags": self.tags, "tags": self.tags,
"created_at": self.created_at, "created_at": self.created_at,
"updated_at": self.updated_at, "updated_at": self.updated_at,
@@ -94,6 +100,7 @@ class MemoryPoint:
pinned=payload.get("pinned", False), pinned=payload.get("pinned", False),
category=payload.get("category", ""), category=payload.get("category", ""),
source=payload.get("source", "manual"), source=payload.get("source", "manual"),
scope=payload.get("scope", "personal"),
tags=payload.get("tags", []), tags=payload.get("tags", []),
created_at=payload.get("created_at", ""), created_at=payload.get("created_at", ""),
updated_at=payload.get("updated_at", ""), updated_at=payload.get("updated_at", ""),
@@ -120,14 +127,23 @@ class VectorStore:
collection_name=COLLECTION, collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE), vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
) )
# Indexe fuer typische Filter-Felder # Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
for field_name in ("type", "pinned", "category", "source", "migration_key"): # einer bestehenden Collection (fuer neu hinzugekommene Felder wie scope).
self._ensure_indexes()
def _ensure_indexes(self):
for field_name in ("type", "pinned", "category", "source", "scope", "migration_key"):
schema = (qm.PayloadSchemaType.BOOL if field_name == "pinned"
else qm.PayloadSchemaType.KEYWORD)
try:
self.client.create_payload_index( self.client.create_payload_index(
collection_name=COLLECTION, collection_name=COLLECTION,
field_name=field_name, field_name=field_name,
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned" field_schema=schema,
else qm.PayloadSchemaType.BOOL,
) )
except Exception:
# Index existiert bereits — kein Problem.
pass
# ─── Schreib-Operationen ───────────────────────────────────────── # ─── Schreib-Operationen ─────────────────────────────────────────
@@ -164,6 +180,38 @@ class VectorStore:
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)) qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
])) ]))
def list_pinned_by_scope(self, scope: str) -> List[MemoryPoint]:
"""Alle pinned Punkte eines scope (system | personal). Fuer den
getrennten Bootstrap-Export."""
return self._scroll(filter=qm.Filter(must=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="scope", match=qm.MatchValue(value=scope)),
]))
def list_index_titles(self, limit: int = 500) -> List[MemoryPoint]:
"""Leichtgewichtiger Titel-Index des kalten Gedaechtnisses fuer den
System-Prompt: ARIA sieht WAS sie an Nachschlage-Wissen hat (Zugangs-
daten, Infrastruktur, Projekte) und holt den Inhalt bei Bedarf via
memory_search — statt Stefan nach etwas zu fragen, das schon da ist.
Bewusst NUR die deliberat gespeicherten Punkte:
- nicht pinned (die sind eh schon voll im Prompt),
- kein type=conversation (Chat-Mitschnitte),
- kein source=distilled (die 100e auto-destillierten Gespraechs-
Fakten — die traegt das semantische Auto-Retrieval, sie hier
als Titel zu listen wuerde nur Kontext fressen).
So bleibt der Index klein (Dutzende statt Hunderte Zeilen)."""
return self._scroll(
filter=qm.Filter(
must_not=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="type", match=qm.MatchValue(value="conversation")),
qm.FieldCondition(key="source", match=qm.MatchValue(value="distilled")),
]
),
limit=limit,
)
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]: def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
return self._scroll( return self._scroll(
filter=qm.Filter(must=[ filter=qm.Filter(must=[
+7
View File
@@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=f"User: {btitle}", type_="preference", title=f"User: {btitle}",
content=btext, category="allgemein", content=btext, category="allgemein",
migration_key=f"{source_file}/general-{idx}", migration_key=f"{source_file}/general-{idx}",
scope="personal",
)) ))
else: else:
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein" cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein"
@@ -259,6 +260,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=title, type_="preference", title=title,
content=content, category=cat_key, content=content, category=cat_key,
migration_key=f"{source_file}/{cat_key}", migration_key=f"{source_file}/{cat_key}",
scope="personal",
)) ))
return points return points
@@ -283,7 +285,11 @@ def _mk(
migration_key: str, migration_key: str,
pinned: bool = True, pinned: bool = True,
category: str = "", category: str = "",
scope: str = "system",
) -> MemoryPoint: ) -> MemoryPoint:
# scope-Default 'system': AGENT.md + TOOLING.md beschreiben ARIA selbst
# (Identitaet, Sicherheit, Architektur) — das braucht jedes System.
# USER.md-Praeferenzen sind personal und uebergeben scope='personal'.
p = MemoryPoint( p = MemoryPoint(
id="", id="",
type=type_, type=type_,
@@ -292,6 +298,7 @@ def _mk(
pinned=pinned, pinned=pinned,
category=category, category=category,
source="import", source="import",
scope=scope,
tags=[], tags=[],
) )
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen # migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
+83 -1
View File
@@ -162,6 +162,53 @@ def build_time_section() -> str:
] ]
return "\n".join(lines) return "\n".join(lines)
def build_voice_flow_section() -> str:
"""Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs.
Frage, Kette vs. Ende) und deklariert sie per Marker wie [[AWAIT]]. Die
Marker werden im Brain entfernt (nie angezeigt/vorgelesen)."""
return "\n".join([
"## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)",
"Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase "
"ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf "
"zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, "
"nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.",
"",
"- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas "
"GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird "
"NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. "
"Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — "
"AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim "
"Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs "
"Wake-Word.",
"- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das "
"Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes "
"\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb "
"dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') "
"oder das Gespraech klar weitergeht.",
"- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz "
"das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette "
"Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, "
"beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng "
"`[[ENDE]]` an.",
"",
"Regeln:",
"- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, "
"ohne Marker (wird vorgelesen).",
"- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro "
"offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.",
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
"",
"**Ohr aus/an:** Wenn Stefan will dass du aufhoerst zuzuhoeren — egal wie "
"formuliert ('leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute Nacht', "
"'mach Pause vom Zuhoeren') — ruf das Tool `ear_control(action='off')`. "
"Wieder aktivieren ('Ohr an', 'wach auf', 'hoer wieder zu') → "
"`ear_control(action='on')`. Die App stoppt/startet dann den Listener; du "
"bestaetigst nur kurz.",
])
TYPE_HEADINGS = { TYPE_HEADINGS = {
"identity": "## Wer du bist", "identity": "## Wer du bist",
"rule": "## Sicherheitsregeln & Prinzipien", "rule": "## Sicherheitsregeln & Prinzipien",
@@ -260,6 +307,36 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
return "\n".join(lines) return "\n".join(lines)
def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
"""Titel-Index des kalten Gedaechtnisses: ARIA sieht WELCHES Nachschlage-
Wissen sie hat (nur Titel, kein Inhalt = billig), damit sie den Inhalt via
memory_search holt statt Stefan nach etwas zu fragen, das schon da ist.
Nach Kategorie gruppiert; Conversation-Logs + auto-destillierte Fakten sind
bereits ausgefiltert (siehe list_index_titles)."""
if not index_titles:
return ""
grouped: dict[str, List[MemoryPoint]] = {}
for p in index_titles:
key = (p.category or p.type or "sonstiges").strip() or "sonstiges"
grouped.setdefault(key, []).append(p)
lines = [
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
"",
]
for cat in sorted(grouped.keys()):
items = grouped[cat]
lines.append(f"### {cat}")
for p in items:
lines.append(f"- {p.title}")
lines.append("")
return "\n".join(lines).strip()
def build_skills_section(skills: List[dict]) -> str: def build_skills_section(skills: List[dict]) -> str:
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt """Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt.""" und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt."""
@@ -450,6 +527,7 @@ def build_flux_section(flux_config: dict) -> str:
def build_system_prompt( def build_system_prompt(
pinned: List[MemoryPoint], pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None, cold: List[MemoryPoint] | None = None,
memory_index: List[MemoryPoint] | None = None,
skills: List[dict] | None = None, skills: List[dict] | None = None,
triggers: List[dict] | None = None, triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None, condition_vars: List[dict] | None = None,
@@ -463,7 +541,8 @@ def build_system_prompt(
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth.""" """Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die # Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt. # ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()] parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
"", build_voice_flow_section()]
if skills: if skills:
parts.append("") parts.append("")
parts.append(build_skills_section(skills)) parts.append(build_skills_section(skills))
@@ -482,6 +561,9 @@ def build_system_prompt(
callback_host=oauth_callback_host, callback_host=oauth_callback_host,
callback_port=oauth_callback_port, callback_port=oauth_callback_port,
callback_tls=oauth_callback_tls)) callback_tls=oauth_callback_tls))
if memory_index:
parts.append("")
parts.append(build_memory_index_section(memory_index))
if cold: if cold:
parts.append("") parts.append("")
parts.append(build_cold_memory_section(cold)) parts.append(build_cold_memory_section(cold))
+1
View File
@@ -915,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
"pinned": True, "pinned": True,
"category": rule.get("category", ""), "category": rule.get("category", ""),
"source": "seed", "source": "seed",
"scope": "system",
"tags": [], "tags": [],
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
+12 -1
View File
@@ -1622,6 +1622,11 @@ class ARIABridge:
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf # die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen. # DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False, "awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
# User hat "Wake-Word aus" gesagt → App stoppt den Listener komplett
# (Mikro frei).
"wake_off": bool(payload.get("wake_off", False)) if isinstance(payload, dict) else False,
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener.
"wake_on": bool(payload.get("wake_on", False)) if isinstance(payload, dict) else False,
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -2017,6 +2022,10 @@ class ARIABridge:
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die # Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter. # Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
awaiting_reply = bool(data.get("awaiting_reply", False)) awaiting_reply = bool(data.get("awaiting_reply", False))
# User hat per Sprache "Wake-Word aus" gesagt → App stoppt den Listener.
wake_off = bool(data.get("wake_off", False))
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener wieder.
wake_on = bool(data.get("wake_on", False))
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
@@ -2108,7 +2117,9 @@ class ARIABridge:
"answeredBy": answered_by, "answeredBy": answered_by,
"speak": speak, "speak": speak,
"converse": converse, "converse": converse,
"awaiting_reply": awaiting_reply}) "awaiting_reply": awaiting_reply,
"wake_off": wake_off,
"wake_on": wake_on})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)
+56 -15
View File
@@ -788,6 +788,18 @@
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;"> <div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
Status wird geladen... Status wird geladen...
</div> </div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
</label>
</div>
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label> <label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50" <input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
@@ -1068,11 +1080,13 @@
<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;"> <div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;">
<div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div> <div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div>
<div style="color:#8888AA;font-size:11px;margin-bottom:8px;"> <div style="color:#8888AA;font-size:11px;margin-bottom:8px;">
Klein und schnell: <strong>nur</strong> die pinned Memories (Identität, Regeln, Präferenzen, Tools, Skills) als JSON. Getrennt nach <strong>scope</strong>: <span style="color:#3FFF3F;">System</span> = generische Regeln, die jeder braucht (Sicherheit, Ehrlichkeit, Skill-Regeln) — teilbar für ein frisches System.
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer. <span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet. Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
</div> </div>
<button class="btn secondary" onclick="exportBootstrap()" style="color:#FFD60A;border-color:#FFD60A;">Bootstrap exportieren (JSON)</button> <button class="btn secondary" onclick="exportBootstrap('system')" style="color:#3FFF3F;border-color:#3FFF3F;">System-Regeln exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('personal')" style="color:#FF9F0A;border-color:#FF9F0A;">⬇ Persönliches exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('all')" style="color:#FFD60A;border-color:#FFD60A;">⬇ Alles (Vollbackup)</button>
<input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)"> <input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)">
<button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button> <button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button>
<div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div> <div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
@@ -1396,6 +1410,11 @@
<input type="checkbox" id="memory-pinned"> <input type="checkbox" id="memory-pinned">
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span> <span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
</label> </label>
<label style="display:block;color:#8888AA;font-size:11px;margin-top:10px;margin-bottom:3px;">Scope (steuert Bootstrap-Export):</label>
<select id="memory-scope" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<option value="personal">🟠 Persönlich — Stefan-spezifisch, bleibt privat</option>
<option value="system">🟢 System — generische Regel, teilbar für frisches System</option>
</select>
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar --> <!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;"> <div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
@@ -1899,6 +1918,11 @@
if (slider) slider.value = msg.voiceIdThreshold; if (slider) slider.value = msg.voiceIdThreshold;
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2); if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
} }
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
{
const cb = document.getElementById('diag-voice-id-enabled');
if (cb) cb.checked = !!msg.voiceIdEnabled;
}
return; return;
} }
@@ -2275,9 +2299,10 @@
} }
function sendDiagAttachments() { function sendDiagAttachments() {
// Alle pending Dateien an RVS senden // Alle pending Dateien an RVS senden — projectId des aktuellen Focus
// mitschicken, sonst landet die Datei-Bubble im Hauptchat statt im Projekt.
for (const f of diagPendingFiles) { for (const f of diagPendingFiles) {
send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64 }); send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64, projectId: focusedContextId });
} }
if (diagPendingFiles.length > 0) { if (diagPendingFiles.length > 0) {
addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei'); addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei');
@@ -3600,13 +3625,14 @@
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value; const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value; const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined; const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
send({ send({
action: 'send_voice_config', action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel, ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile, f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep, f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken, fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
voiceIdThreshold, voiceIdThreshold, voiceIdEnabled,
}); });
const statusEl = document.getElementById('voice-status'); const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) { if (statusEl && xttsVoice) {
@@ -5786,9 +5812,15 @@
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : ''; const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0; const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : ''; const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
// scope-Badge nur bei pinned (nur die werden exportiert — da zaehlt die Trennung).
const scopeBadge = m.pinned
? (m.scope === 'system'
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
: '';
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;"> return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')"> <div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge} <div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''} ${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
</div> </div>
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div> <div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
@@ -5998,6 +6030,7 @@
document.getElementById('memory-category').value = m.category || ''; document.getElementById('memory-category').value = m.category || '';
document.getElementById('memory-tags').value = (m.tags || []).join(', '); document.getElementById('memory-tags').value = (m.tags || []).join(', ');
document.getElementById('memory-pinned').checked = !!m.pinned; document.getElementById('memory-pinned').checked = !!m.pinned;
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
// Anhang-Block sichtbar — Liste rendern // Anhang-Block sichtbar — Liste rendern
if (attBlock) attBlock.style.display = 'block'; if (attBlock) attBlock.style.display = 'block';
if (attHint) attHint.style.display = 'none'; if (attHint) attHint.style.display = 'none';
@@ -6011,6 +6044,7 @@
document.getElementById('memory-category').value = ''; document.getElementById('memory-category').value = '';
document.getElementById('memory-tags').value = ''; document.getElementById('memory-tags').value = '';
document.getElementById('memory-pinned').checked = false; document.getElementById('memory-pinned').checked = false;
document.getElementById('memory-scope').value = 'personal';
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen // Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
if (attBlock) attBlock.style.display = 'none'; if (attBlock) attBlock.style.display = 'none';
if (attHint) attHint.style.display = 'block'; if (attHint) attHint.style.display = 'block';
@@ -6115,6 +6149,7 @@
const category = document.getElementById('memory-category').value.trim(); const category = document.getElementById('memory-category').value.trim();
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean); const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
const pinned = document.getElementById('memory-pinned').checked; const pinned = document.getElementById('memory-pinned').checked;
const scope = document.getElementById('memory-scope').value || 'personal';
if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; } if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; } if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
@@ -6125,13 +6160,13 @@
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), { r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, pinned, category, tags }), body: JSON.stringify({ title, content, pinned, category, scope, tags }),
}); });
} else { } else {
r = await fetch('/api/brain/memory/save', { r = await fetch('/api/brain/memory/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, title, content, pinned, category, tags, source: 'manual' }), body: JSON.stringify({ type, title, content, pinned, category, scope, tags, source: 'manual' }),
}); });
} }
if (!r.ok) { if (!r.ok) {
@@ -6508,11 +6543,12 @@
} }
// ── Bootstrap Export / Import ────────────────────────── // ── Bootstrap Export / Import ──────────────────────────
async function exportBootstrap() { async function exportBootstrap(scope) {
scope = scope || 'system';
const status = document.getElementById('bootstrap-status'); const status = document.getElementById('bootstrap-status');
if (status) status.innerHTML = '⏳ Lade...'; if (status) status.innerHTML = '⏳ Lade...';
try { try {
const r = await fetch('/api/brain/memory/export-bootstrap'); const r = await fetch('/api/brain/memory/export-bootstrap?scope=' + encodeURIComponent(scope));
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json(); const data = await r.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -6520,10 +6556,11 @@
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `aria-bootstrap-${ts}.json`; a.download = `aria-bootstrap-${scope}-${ts}.json`;
document.body.appendChild(a); a.click(); document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} pinned Memories exportiert</span>`; const label = scope === 'system' ? 'System-Regeln' : (scope === 'personal' ? 'persönliche Memories' : 'pinned Memories');
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} ${label} exportiert</span>`;
} catch (e) { } catch (e) {
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`; if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
} }
@@ -6537,7 +6574,11 @@
const text = await file.text(); const text = await file.text();
const bundle = JSON.parse(text); const bundle = JSON.parse(text);
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array'); if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array');
if (!confirm(`Bootstrap importieren?\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\nALLE aktuell pinned Memories werden überschrieben. Cold Memory bleibt unverändert.`)) { const bScope = bundle.scope || 'all';
const scopeInfo = bScope === 'system' ? 'Nur die aktuell pinned SYSTEM-Regeln werden ersetzt — Persönliches bleibt.'
: bScope === 'personal' ? 'Nur die aktuell pinned PERSÖNLICHEN Memories werden ersetzt — System-Regeln bleiben.'
: 'ALLE aktuell pinned Memories werden überschrieben.';
if (!confirm(`Bootstrap importieren? (scope: ${bScope})\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\n${scopeInfo} Cold Memory bleibt unverändert.`)) {
event.target.value = ''; event.target.value = '';
return; return;
} }
+7 -1
View File
@@ -2568,7 +2568,7 @@ wss.on("connection", (ws) => {
// Datei von Diagnostic an Bridge via RVS senden // Datei von Diagnostic an Bridge via RVS senden
sendToRVS_raw({ sendToRVS_raw({
type: "file", type: "file",
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 }, payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64, projectId: msg.projectId || "" },
timestamp: Date.now(), timestamp: Date.now(),
}); });
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`); log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
@@ -2681,6 +2681,12 @@ wss.on("connection", (ws) => {
const t = parseFloat(msg.voiceIdThreshold); const t = parseFloat(msg.voiceIdThreshold);
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t; if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
} }
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
// config-Broadcast; aus = gar keine Pruefung.
if (msg.voiceIdEnabled !== undefined) {
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
}
try { try {
fs.mkdirSync("/shared/config", { recursive: true }); fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2)); fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
+15 -36
View File
@@ -63,6 +63,7 @@ services:
whisper-bridge: whisper-bridge:
build: ./whisper build: ./whisper
container_name: aria-whisper-bridge container_name: aria-whisper-bridge
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
deploy: deploy:
resources: resources:
reservations: reservations:
@@ -138,55 +139,33 @@ services:
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600} - LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped restart: unless-stopped
# ─── Voxtral STT (GPU, Realtime) — PROFIL "voxtral" ─────────── # ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
# Ersetzt whisper als STT sobald die 24-GB-Karte da ist. Startet NUR mit # Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
# docker compose --profile voxtral up -d # Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
# (sonst kollidiert es mit whisper — beide wuerden stt_* beantworten). # Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
# # (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
# ⚠️ VRAM: Voxtral-Mini-4B-Realtime-2602 braucht >=16 GB (BF16, laut vLLM- # immer nur EINEN STT laufen lassen.
# Rezept keine Quant). Laeuft NICHT auf der 3060 (12 GB) — erst 24-GB-Karte. voxtral-bridge:
# ⚠️ vLLM: Version >=0.20.0 noetig. Entrypoint/Serve-Form beim ersten Lauf build: ./voxtral
# gegen das offizielle Rezept pruefen (siehe voxtral/README.md). container_name: aria-voxtral-bridge
voxtral-vllm:
image: vllm/vllm-openai:latest
container_name: aria-voxtral-vllm
profiles: ["voxtral"]
deploy: deploy:
resources: resources:
reservations: reservations:
devices: devices:
- driver: nvidia - driver: nvidia
count: 1 device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
capabilities: [gpu] capabilities: [gpu]
volumes: volumes:
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5 - ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
environment: - ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
- VLLM_DISABLE_COMPILE_CACHE=1
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-}
# Serve-Command aus dem vLLM-Rezept (Voxtral-Mini-4B-Realtime-2602).
command:
- --model
- mistralai/Voxtral-Mini-4B-Realtime-2602
- --tokenizer-mode
- mistral
- --compilation_config
- '{"cudagraph_mode":"PIECEWISE"}'
restart: unless-stopped
# ─── Voxtral-Bridge — RVS <-> vLLM-Realtime-WS (CPU-Glue) ─────
voxtral-bridge:
build: ./voxtral
container_name: aria-voxtral-bridge
profiles: ["voxtral"]
depends_on:
- voxtral-vllm
environment: environment:
- RVS_HOST=${RVS_HOST} - RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443} - RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true} - RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true} - RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN} - RVS_TOKEN=${RVS_TOKEN}
- VOXTRAL_VLLM_URL=ws://voxtral-vllm:8000/v1/realtime - VOXTRAL_MODEL=mistralai/Voxtral-Mini-3B-2507
- VOXTRAL_MODEL=mistralai/Voxtral-Mini-4B-Realtime-2602
- VOXTRAL_LANGUAGE=${WHISPER_LANGUAGE:-de} - 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 restart: unless-stopped
+19 -7
View File
@@ -1,14 +1,26 @@
# Voxtral-BRIDGE (nicht das Modell!) — leichte CPU-Glue zwischen RVS und dem # Voxtral-STT-3B Bridge (Transformers). Laeuft auf Treiber 550/CUDA 12.4 via
# vLLM-Realtime-Server. Das eigentliche Voxtral-Modell laeuft im Container # torch cu124 — KEIN Treiber-Upgrade noetig (gleicher Trick wie f5tts).
# `voxtral-vllm` (GPU, vllm/vllm-openai). Deshalb hier kein CUDA-Base noetig. # Modell: Voxtral-Mini-3B-2507 (~9 GB in bf16) → passt auf die 12-GB-Karte (GPU 1).
FROM python:3.11-slim FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY requirements.txt . RUN apt-get update && apt-get install -y --no-install-recommends \
RUN pip install --no-cache-dir -r requirements.txt python3 python3-pip ffmpeg git \
&& rm -rf /var/lib/apt/lists/*
COPY bridge.py ./ # 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 speaker_id.py ./
CMD ["python3", "bridge.py"] CMD ["python3", "bridge.py"]
+31 -54
View File
@@ -1,70 +1,47 @@
# Voxtral-STT-Satellit (M0.3) # Voxtral-STT-3B-Satellit (Transformers)
Streaming-STT via **Voxtral-Mini-4B-Realtime-2602** (Mistral, Apache 2.0) auf Streaming-STT via **Voxtral-Mini-3B-2507** (Mistral, Apache 2.0) über
vLLM. Ersetzt whisper als STT — genauer (~5,9 % WER vs 7,4 % FLEURS) und mit 🤗 Transformers. Ersetzt whisper als STT — genauer, und **ohne Treiber-Upgrade**:
echtem Realtime-Streaming. Deutsch ist in den 13 Sprachen abgedeckt. läuft auf dem Trixie-Standardtreiber (550/CUDA 12.4) via **torch 2.6.0+cu124**
(derselbe Trick wie bei f5tts).
Zwei Container: - Modell ~9 GB (bf16) → **GPU 1** (die 12-GB-Karte; per Compose gepinnt).
- **`voxtral-vllm`** — das Modell auf vLLM (GPU). Exponiert die Realtime-WS-API. - **F5-TTS + LLM** bleiben auf GPU 0 (8 GB).
- **`voxtral-bridge`** — CPU-Glue: RVS ⇄ vLLM-Realtime-WS. Macht das Endpointing - Arbeitsweise = chunked wie whisper: live PCM → alle ~1 s transkribieren
selbst (adaptiver Rausch-Boden-VAD, identisch zur whisper-Bridge / M0.1). (Partials) → **adaptiver Endpointer** (Rausch-Boden-VAD + semantische
Stagnation, aus M0.1) feuert `stt_endpoint`. RVS-Protokoll identisch → drop-in.
## ⚠️ Hardware-Realität — läuft NICHT auf der 3060 ## Starten (Profil `voxtral`)
Das Realtime-Modell braucht laut [vLLM-Rezept](https://recipes.vllm.ai/mistralai/Voxtral-Mini-4B-Realtime-2602)
**≥ 16 GB VRAM (BF16, keine Quant)**. Die RTX 3060 hat 12 GB → passt nicht.
- **Interim-gpubox (nur 3060):** whisper (mit M0.1-Fix) + F5-TTS bleiben aktiv.
Voxtral NICHT starten.
- **Ab der 24-GB-Karte:** Voxtral-Profil hochziehen, whisper wird Fallback.
Deshalb liegen beide Services hinter dem Compose-**Profil `voxtral`** und starten
NUR explizit — sonst würden whisper *und* voxtral dieselben `stt_*`-Messages
beantworten (Kollision).
## Starten (erst wenn die 24-GB-Karte drin ist)
```bash ```bash
cd xtts cd xtts
docker compose stop whisper-bridge # sonst beantworten beide stt_*
docker compose --profile voxtral up -d --build docker compose --profile voxtral up -d --build
docker logs -f aria-voxtral-vllm # laedt Modell (mehrere GB, dauert) docker logs -f aria-voxtral-bridge # Modell laedt (mehrere GB), dann "RVS verbunden"
docker logs -f aria-voxtral-bridge # "RVS verbunden" + service_status ready
```
Whisper vorher stoppen, damit nur eine STT-Engine antwortet:
```bash
docker compose stop whisper-bridge
``` ```
Zurück zu whisper: `docker compose --profile voxtral down && docker compose up -d whisper-bridge`.
## ⚠️ Auf echter Hardware verifizieren (blind gebaut, kein Test hier) ## ⚠️ Auf echter Hardware verifizieren (blind gebaut)
1. **vLLM-Version ≥ 0.20.0** und die **Serve-Form**. Das Rezept nutzt 1. **Transformers-API.** Die exakte Voxtral-Transkriptions-API ist in `bridge.py`
`vllm serve <model> …`. Falls das `vllm/vllm-openai`-Image einen anderen in **einer** Methode gekapselt (`VoxtralRunner._transcribe_blocking`), modelliert
Entrypoint hat, das `command:` in `docker-compose.yml` anpassen nach der HF-Modelcard (`apply_transcription_request``generate` `batch_decode`).
(Rezept-Command steht dort als Kommentar). Beim ersten Lauf gegen die Modelcard prüfen und dort anpassen.
2. **Realtime-WS-Frames.** Die exakten Event-Namen sind in `bridge.py` ganz oben 2. **HF-Gating.** Ist `Voxtral-Mini-3B-2507` gated, `HF_TOKEN` in `xtts/.env` setzen
als Konstanten gebündelt (`VLLM_SEND_APPEND`, `VLLM_DELTA_SUFFIXES`, …), (wird als `HUGGING_FACE_HUB_TOKEN` durchgereicht).
modelliert nach dem OpenAI-Realtime-Schema. Gegen das offizielle 3. **VRAM/Tempo.** 3B in bf16 ~9 GB auf der 12-GB-Karte — Rest fürs KV-Cache. Ist die
**vLLM-Realtime-Client-Beispiel** prüfen und dort anpassen — nur an dieser Partial-Transkription (alle 1 s) zu schwer, `STREAM_TRANSCRIBE_INTERVAL_MS` hochsetzen.
einen Stelle. Das Response-Handling ist bereits defensiv (mehrere Feldnamen). 4. **torch-Konflikt.** Falls `transformers`/`mistral-common` beim Build torch>2.6
3. **Endpoint-URL/Port.** Default `ws://voxtral-vllm:8000/v1/realtime` — prüfen ob erzwingen, meldet der Constraint einen Konflikt → dann brauchen wir doch das
vLLM auf 8000 lauscht und `/v1/realtime` registriert (Log-Zeile Treiber-Upgrade (`bootstrap.sh --upgrade-driver` via NVIDIA-CUDA-Repo) + cu126-torch.
`Route: /v1/realtime`).
4. **Endpointing.** Voxtral liefert keine eigene VAD → unser adaptiver Endpointer
entscheidet (akustisch + semantisch am Delta-Wachstum). `endpointMs` kommt wie
bei whisper aus der App; `voiceFactor` per Session tunebar.
## Protokoll (RVS, identisch zu whisper — drop-in) ## Protokoll (RVS, identisch zu whisper — drop-in)
Rein: `stt_stream_start`, `stt_audio_chunk` (16 kHz mono s16le, base64), `stt_stream_end`. Rein: `stt_stream_start`, `stt_audio_chunk` (16 kHz mono s16le, base64), `stt_stream_end`.
Raus: `stt_partial`, `stt_endpoint` (das Event, auf das aria-bridge horcht), `stt_stream_done`. Raus: `stt_partial`, `stt_endpoint`, `stt_stream_done`.
## TTS-Hinweis ## TTS
Bleibt **F5-TTS** (klingt gut, passt auf GPU 0). Voxtral-TTS bräuchte ~24 GB — separates Thema.
Voxtral-**TTS** (Voice-Cloning) ist hier NICHT enthalten — braucht ebenfalls
16 GB VRAM und ist ein eigener Bau. Bis zur 24-GB-Karte bleibt **F5-TTS** aktiv.
Danach: eigener `voxtral-tts`-Satellit (separates Ticket).
## Quellen ## Quellen
- Rezept: https://recipes.vllm.ai/mistralai/Voxtral-Mini-4B-Realtime-2602 - Modell: https://huggingface.co/mistralai/Voxtral-Mini-3B-2507
- vLLM Speech-to-Text: https://docs.vllm.ai/en/latest/serving/online_serving/speech_to_text/ - Transformers-Nutzung: HF-Modelcard (Voxtral) + `mistral-common`
- Modell: https://huggingface.co/mistralai/Voxtral-Mini-4B-Realtime-2602
+562 -231
View File
@@ -1,52 +1,46 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ARIA Voxtral Bridge Streaming-STT via Voxtral-Mini-4B-Realtime-2602 (vLLM). ARIA Voxtral-STT-3B Bridge (Transformers) Ersatz fuer whisper.
Zwilling der whisper-Bridge, aber das Transkribieren macht NICHT faster-whisper Laeuft auf Treiber 550/CUDA 12.4 via torch cu124 (kein Treiber-Upgrade noetig).
im selben Prozess, sondern der separate vLLM-Realtime-Server (Container Modell: Voxtral-Mini-3B-2507 (bf16, ~9 GB) GPU 1 (12 GB, per Compose gepinnt).
`voxtral-vllm`) ueber dessen WebSocket-API `/v1/realtime`. Diese Bridge ist
reine Glue:
App (RVS: stt_stream_start / stt_audio_chunk / stt_stream_end) diese Bridge Arbeitsweise = Zwilling der whisper-Bridge: App schickt live PCM-Chunks; wir
diese Bridge (WS /v1/realtime: PCM16-b64 append) voxtral-vllm transkribieren alle ~STREAM_TRANSCRIBE_INTERVAL_MS auf dem Ringbuffer (Partials)
voxtral-vllm (transcription.delta / transcription.done) diese Bridge und feuern stt_endpoint, sobald der ADAPTIVE Endpointer (Rausch-Boden-VAD +
diese Bridge (RVS: stt_partial / stt_endpoint / stt_stream_done) App/aria-bridge semantische Stagnation, aus M0.1) "fertig" sagt. RVS-Wire-Protokoll identisch zu
whisper drop-in (die App merkt nur bessere Genauigkeit).
Das RVS-Wire-Protokoll ist IDENTISCH zur whisper-Bridge (drop-in). Das VERIFY-ON-FIRST-RUN: Die exakte Transformers-Transkriptions-API von Voxtral
Endpointing (wann hat der User aufgehoert zu sprechen) macht diese Bridge (apply_transcription_request / generate / decode) ist unten in EINER Methode
selbst mit demselben ADAPTIVEN Rausch-Boden-Endpointer wie whisper (Voxtral (VoxtralRunner._transcribe_blocking) gekapselt und nach dem HF-Modelcard-Muster
Realtime liefert laut vLLM-Doku keine eigene VAD/speaker done"-Semantik, nur modelliert. Beim ersten echten Lauf gegen die Voxtral-Modelcard pruefen und dort
transcription.delta/.done). Die akustische Energie messen wir auf unserer anpassen. Alles andere (RVS, Endpointer) ist bewaehrt.
eigenen PCM-Kopie, die semantische Stagnation am Delta-Textwachstum.
HARDWARE: Voxtral-Mini-4B-Realtime-2602 braucht >=16 GB VRAM (BF16). Auf der
RTX 3060 (12 GB) laeuft es NICHT erst auf der 24-GB-Karte. Bis dahin
bleibt die whisper-Bridge aktiv (Profil-gesteuert im docker-compose).
VERIFY-ON-FIRST-RUN: Die exakten vLLM-Realtime-FRAME-Namen (Audio-Append,
Delta/Done-Event-Typen) sind unten als Konstanten gebuendelt und nach dem
OpenAI-Realtime-Schema modelliert. Gegen das offizielle vLLM-Realtime-
Client-Beispiel pruefen und ggf. anpassen sie stehen bewusst an EINER
Stelle. Response-Handling ist defensiv (mehrere moegliche Feldnamen).
Env: Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
VOXTRAL_VLLM_URL Default: ws://voxtral-vllm:8000/v1/realtime VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-3B-2507
VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-4B-Realtime-2602
VOXTRAL_LANGUAGE Default: de VOXTRAL_LANGUAGE Default: de
VOXTRAL_DEVICE Default: cuda
STREAM_TRANSCRIBE_INTERVAL_MS Default 1000 (3B ist schwerer als whisper-small)
""" """
import asyncio import asyncio
import base64 import base64
import json import json
import logging import logging
import os import os
import re
import tempfile
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
import numpy as np import numpy as np
import soundfile as sf
import websockets import websockets
import speaker_id # Speaker-ID (nur Stefans Stimme) — portiert aus der whisper-Bridge
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
@@ -60,52 +54,216 @@ RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true" RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip() RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
VOXTRAL_VLLM_URL = os.getenv("VOXTRAL_VLLM_URL", "ws://voxtral-vllm:8000/v1/realtime") VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602")
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de") VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
# ── vLLM-Realtime-Frames — HIER anpassen falls das Client-Beispiel abweicht ── STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
# Senderichtung (wir → vLLM): PCM16-16kHz-mono base64 anhaengen + committen.
VLLM_SEND_APPEND = "input_audio_buffer.append" # {"type":..., "audio": "<b64>"}
VLLM_SEND_COMMIT = "input_audio_buffer.commit" # Buffer abschliessen
VLLM_AUDIO_FIELD = "audio"
# Empfangsrichtung (vLLM → wir): inkrementeller Text + final. Defensiv geprueft.
VLLM_DELTA_SUFFIXES = ("transcription.delta",) # msg["type"] endet hierauf
VLLM_DONE_SUFFIXES = ("transcription.done", "transcription.completed")
VLLM_DELTA_FIELDS = ("delta", "text", "transcription") # eins davon traegt den Text
# ── Streaming-/Endpointing-Parameter (analog whisper-Bridge) ──
STREAM_DEFAULT_ENDPOINT_MS = 2400 STREAM_DEFAULT_ENDPOINT_MS = 2400
STREAM_DEFAULT_HARD_CAP_MS = 60000 STREAM_DEFAULT_HARD_CAP_MS = 300000
STREAM_MIN_AUDIO_MS = 600 STREAM_MIN_AUDIO_MS = 600
STREAM_SPEAKER_CHECK_MS = 1500 # ab so viel Audio einmalig Speaker-ID pruefen
STREAM_SESSION_TTL_S = 120 STREAM_SESSION_TTL_S = 120
STREAM_ENERGY_WINDOW_MS = 300 STREAM_ENERGY_WINDOW_MS = 300
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0 STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
# Adaptiver Voice-Schwellwert (siehe whisper-Bridge M0.1): Grenze relativ zum # Adaptiver Voice-Schwellwert (M0.1): relativ zum gemessenen Rausch-Boden.
# gemessenen Rausch-Boden statt fix — schneidet leises Sprechen nicht ab.
STREAM_VOICE_FACTOR = 2.5 STREAM_VOICE_FACTOR = 2.5
STREAM_VOICE_RMS_MIN = 0.005 STREAM_VOICE_RMS_MIN = 0.005
STREAM_VOICE_RMS_MAX = 0.020 STREAM_VOICE_RMS_MAX = 0.020
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
# Halluzinations-Filter (2. Netz NACH der Transkription). Der voiced_frames-Guard
# oben faengt die reine Stille; hier kommt das "borderline"-Band dazu: wenn wenig
# echte Stimme da war UND das Transkript ein bekanntes Voxtral-Silence-Artefakt
# ist (Untertitel-Credits, Staedte-/Geo-Fakten "Flaeche von X km2"), ist es fast
# sicher ein Phantom aus Fast-Nichts → verwerfen. Gegated auf wenig voiced_frames,
# damit eine ECHTE Geografie-Frage (die hat normale Stimm-Energie) durchgeht.
STREAM_HALLUC_GUARD_FRAMES = int(os.getenv("STREAM_HALLUC_GUARD_FRAMES",
str(STREAM_MIN_VOICED_FRAMES * 4))) # ~1.6s
_HALLUCINATION_RE = re.compile(
r"untertitel"
r"|amara\.org"
r"|vielen\s+dank\s+f[uü]r'?s?\s+(zuschauen|zusehen|zuh[oö]ren)"
r"|bis\s+zum\s+n[aä]chsten\s+mal"
r"|abonnier"
r"|fl[aä]che\s+von\s+[\d.,]+\s*(km|quadratkilometer)"
r"|[\d.,]+\s*(km²|quadratkilometern?|einwohnern?)\b",
re.IGNORECASE,
)
# Kollabiert unmittelbar wiederholte Phrasen (Voxtral-Repetition-Loop) auf EINE
# Kopie. Zweites Netz hinter no_repeat_ngram in der Generation. Phrase 5-80 Zeichen,
# 3+ mal hintereinander → eine. Kurze legitime Doppelungen ('ja ja', 'sehr sehr')
# bleiben (Unit < 5 Zeichen bzw. < 3 Wiederholungen).
_REPEAT_RE = re.compile(r"(.{5,80}?)(?:\s*\1){2,}", re.IGNORECASE | re.DOTALL)
def _collapse_repetitions(text: str) -> str:
if not text:
return text
out = text
for _ in range(3): # mehrfach fuer verschachtelte/ungleiche Loops
new = _REPEAT_RE.sub(r"\1", out)
if new == out:
break
out = new
return out.strip()
# ── Silero VAD: echte Sprach-Erkennung VOR dem Transkribieren ──────────────
# Der Muster-Filter oben kennt nur spezifische Artefakte. Generische Phantome
# ("Ich bin ein guter Mann" aus Fast-Stille) kann ein Text-Regex nicht fangen —
# aber ein VAD schon, weil es am AUDIO entscheidet, nicht am Text. Silero trennt
# Sprache zuverlaessig von Stille / Rauschen / MUSIK. Kein Speech-Segment →
# no-speech → nicht transkribieren → kein Phantom (und Musik/Instrumental fliegt
# gleich mit raus).
# FAIL-OPEN: klappt das VAD nicht (Import/Load/Inferenz), wird trotzdem normal
# transkribiert. Die STT darf NIE komplett sterben (Speaker-ID-Lektion).
SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "true", "yes")
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
# Speech-Endpoint gegen laute Umgebungsmusik: der RMS-Stille-Endpoint feuert bei
# durchgehender Musik NIE (Energie bleibt oben). Deshalb waehrend lauter Phasen
# periodisch (alle X ms) mit Silero pruefen, ob im letzten endpoint_ms-Fenster
# ueberhaupt noch Sprache ist — wenn nicht (nur Musik/Stille), Turn beenden.
STREAM_SPEECH_ENDPOINT_CHECK_MS = int(os.getenv("STREAM_SPEECH_ENDPOINT_CHECK_MS", "700"))
_vad_state = {"model": None, "get_ts": None, "failed": False}
def _speech_segments(audio_f32):
"""Silero-VAD-Sprachsegmente (Liste von {start,end} Sample-Indizes) im
16kHz-float32-Audio. Rueckgabe:
[] kein Speech (Stille/Rauschen/Musik) Phantom-Verdacht, verwerfen.
[...] Speech vorhanden.
None VAD nicht verfuegbar fail-open (Aufrufer transkribiert normal)."""
if not SILERO_VAD_ENABLED or _vad_state["failed"]:
return None
if _vad_state["model"] is None:
try:
from silero_vad import load_silero_vad, get_speech_timestamps
_vad_state["model"] = load_silero_vad()
_vad_state["get_ts"] = get_speech_timestamps
logger.info("Silero VAD geladen (threshold=%.2f, min_speech=%dms)",
SILERO_VAD_THRESHOLD, SILERO_MIN_SPEECH_MS)
except Exception:
logger.exception("Silero VAD Laden fehlgeschlagen — dauerhaft aus (fail-open)")
_vad_state["failed"] = True
return None
try:
import torch as _torch
segs = _vad_state["get_ts"](
_torch.from_numpy(audio_f32), _vad_state["model"],
sampling_rate=16000, threshold=SILERO_VAD_THRESHOLD,
min_speech_duration_ms=SILERO_MIN_SPEECH_MS,
)
return segs or []
except Exception:
logger.exception("Silero VAD Inferenz fehlgeschlagen — dieser Turn fail-open")
return None
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
# vorbelegt werden.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
def pcm_s16le_to_float32(data: bytes) -> np.ndarray: def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
if not data: if not data:
return np.zeros(0, dtype=np.float32) return np.zeros(0, dtype=np.float32)
arr = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0 return np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
return arr
async def _send(ws, mtype: str, payload: dict) -> None: async def _send(ws, mtype: str, payload: dict) -> None:
try: try:
await ws.send(json.dumps({ await ws.send(json.dumps({
"type": mtype, "type": mtype, "payload": payload, "timestamp": int(time.time() * 1000),
"payload": payload,
"timestamp": int(time.time() * 1000),
})) }))
except Exception as e: except Exception as e:
logger.warning("RVS-Send fehlgeschlagen (%s): %s", mtype, 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():
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
# mehrminutige Aufnahmen abgeschnitten.
# Repetition-Bremse: Voxtral kippt bei Stille/Rauschen am Ende
# gern in eine Schleife und wiederholt einen Satz zig-mal
# ("Vergiss das, das ist nur... Vergiss das, das ist nur..."
# x15). no_repeat_ngram_size=4 laesst die ERSTE echte Nennung
# durch, verbietet aber die exakte 4-Gramm-Wiederholung → Loop
# bricht ab; repetition_penalty daempft zusaetzlich. Beides mild,
# damit normale Sprache (auch mal ein doppeltes Wort) unberuehrt
# bleibt.
outputs = model.generate(
**inputs,
max_new_tokens=4096,
no_repeat_ngram_size=4,
repetition_penalty=1.15,
)
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 @dataclass
class StreamSession: class StreamSession:
request_id: str request_id: str
@@ -126,28 +284,39 @@ class StreamSession:
last_chunk_at: float = field(default_factory=time.time) last_chunk_at: float = field(default_factory=time.time)
last_partial: str = "" last_partial: str = ""
last_growth_at: float = 0.0 last_growth_at: float = 0.0
last_transcribe_at: float = 0.0
last_voice_at: float = 0.0 last_voice_at: float = 0.0
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
noise_floor: float = 0.0 noise_floor: float = 0.0
closed: bool = False closed: bool = False
endpoint_sent: bool = False endpoint_sent: bool = False
# vLLM-Realtime-Session # Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
vllm_ws: object = None # keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
vllm_reader: object = None # stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
speech_signaled: bool = False
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
voiced_frames: int = 0
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
speaker_checked: bool = False
speaker_match: Optional[bool] = None
speaker_similarity: float = 0.0
class SessionManager: class SessionManager:
def __init__(self) -> None: def __init__(self, runner: VoxtralRunner) -> None:
self.runner = runner
self._sessions: dict[str, StreamSession] = {} self._sessions: dict[str, StreamSession] = {}
self._ws = None # RVS self._ws = None
def attach_ws(self, ws) -> None: def attach_ws(self, ws) -> None:
self._ws = ws self._ws = ws
async def start_session(self, payload: dict) -> Optional[StreamSession]: def start_session(self, payload: dict) -> None:
request_id = (payload.get("requestId") or "").strip() rid = (payload.get("requestId") or "").strip()
if not request_id: if not rid:
logger.warning("stt_stream_start ohne requestId — ignoriert") return
return None
try: try:
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS) endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -160,8 +329,8 @@ class SessionManager:
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR) voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
except (TypeError, ValueError): except (TypeError, ValueError):
voice_factor = STREAM_VOICE_FACTOR voice_factor = STREAM_VOICE_FACTOR
sess = StreamSession( self._sessions[rid] = StreamSession(
request_id=request_id, request_id=rid,
audio_request_id=payload.get("audioRequestId", "") or "", audio_request_id=payload.get("audioRequestId", "") or "",
language=payload.get("language") or VOXTRAL_LANGUAGE, language=payload.get("language") or VOXTRAL_LANGUAGE,
endpoint_ms=endpoint_ms, endpoint_ms=endpoint_ms,
@@ -173,115 +342,21 @@ class SessionManager:
location=payload.get("location") or None, location=payload.get("location") or None,
sample_rate=int(payload.get("sampleRate") or 16000), sample_rate=int(payload.get("sampleRate") or 16000),
) )
# vLLM-Realtime-Session oeffnen + Reader starten.
try:
sess.vllm_ws = await websockets.connect(VOXTRAL_VLLM_URL, max_size=8 * 1024 * 1024)
await self._vllm_configure(sess)
sess.vllm_reader = asyncio.create_task(self._vllm_read_loop(sess))
except Exception as e:
logger.exception("Stream %s: vLLM-Realtime-Connect fehlgeschlagen: %s",
request_id[:8], e)
# ohne Backend keine Transkription → sofort leeres Endpoint melden
self._sessions[request_id] = sess
await self._finalize(sess, reason="vllm_unavailable")
return None
self._sessions[request_id] = sess
logger.info("Voxtral-Session offen: id=%s lang=%s endpointMs=%d", logger.info("Voxtral-Session offen: id=%s lang=%s endpointMs=%d",
request_id[:8], sess.language, sess.endpoint_ms) rid[:8], self._sessions[rid].language, endpoint_ms)
return sess
async def _vllm_configure(self, sess: StreamSession) -> None:
"""Optionale Session-Konfig an vLLM (Modell/Sprache/temperature=0).
VERIFY: exaktes session.update-Schema gegen vLLM-Realtime-Beispiel.
Best-effort Fehler hier sind nicht fatal."""
try:
await sess.vllm_ws.send(json.dumps({
"type": "session.update",
"session": {
"model": VOXTRAL_MODEL,
"language": sess.language,
"temperature": 0.0,
"input_audio_format": "pcm16",
},
}))
except Exception:
pass
async def _vllm_read_loop(self, sess: StreamSession) -> None:
"""Liest transcription.delta/.done vom vLLM-Realtime-Server."""
ws = sess.vllm_ws
try:
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
mtype = str(msg.get("type", ""))
if any(mtype.endswith(s) for s in VLLM_DELTA_SUFFIXES):
text = self._extract_text(msg)
if text:
await self._on_delta(sess, text)
elif any(mtype.endswith(s) for s in VLLM_DONE_SUFFIXES):
text = self._extract_text(msg)
if text:
await self._on_delta(sess, text, final=True)
except Exception:
logger.debug("Stream %s: vLLM-Reader beendet", sess.request_id[:8])
@staticmethod
def _extract_text(msg: dict) -> str:
for f in VLLM_DELTA_FIELDS:
v = msg.get(f)
if isinstance(v, str) and v:
return v
return ""
async def _on_delta(self, sess: StreamSession, text: str, final: bool = False) -> None:
"""Neuer/finaler Transkript-Text vom vLLM. delta = inkrementell; wir
haengen an, wenn er den bisherigen Partial verlaengert, sonst ersetzen
wir (Voxtral kann korrigieren)."""
if final or text.startswith(sess.last_partial):
new_full = text if final else text
else:
new_full = (sess.last_partial + text).strip()
new_full = new_full.strip()
if new_full and new_full != sess.last_partial:
sess.last_partial = new_full
sess.last_growth_at = time.time()
if self._ws is not None:
await _send(self._ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": new_full,
})
def feed_chunk(self, payload: dict) -> bool: def feed_chunk(self, payload: dict) -> bool:
request_id = payload.get("requestId", "") sess = self._sessions.get(payload.get("requestId", ""))
sess = self._sessions.get(request_id)
if sess is None or sess.closed: if sess is None or sess.closed:
return False return False
pcm_b64 = payload.get("pcm", "") pcm_b64 = payload.get("pcm", "")
if not pcm_b64: if pcm_b64:
return True
try: try:
pcm = base64.b64decode(pcm_b64) sess.pcm_buffer.extend(base64.b64decode(pcm_b64))
except Exception:
return True
sess.pcm_buffer.extend(pcm)
sess.last_chunk_at = time.time()
# An vLLM weiterreichen (fire-and-forget).
if sess.vllm_ws is not None:
asyncio.create_task(self._vllm_append(sess, pcm_b64))
return True
async def _vllm_append(self, sess: StreamSession, pcm_b64: str) -> None:
try:
await sess.vllm_ws.send(json.dumps({
"type": VLLM_SEND_APPEND,
VLLM_AUDIO_FIELD: pcm_b64,
}))
except Exception: except Exception:
pass pass
sess.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> None: def end_session(self, request_id: str) -> None:
sess = self._sessions.get(request_id) sess = self._sessions.get(request_id)
@@ -289,43 +364,22 @@ class SessionManager:
sess.closed = True sess.closed = True
def drop(self, request_id: str) -> None: def drop(self, request_id: str) -> None:
sess = self._sessions.pop(request_id, None) self._sessions.pop(request_id, None)
if sess is not None:
self._teardown_vllm(sess)
def _teardown_vllm(self, sess: StreamSession) -> None: # ── Endpointer (adaptiv, M0.1) ──
try: def _buffer_ms(self, sess: StreamSession) -> float:
if sess.vllm_reader is not None:
sess.vllm_reader.cancel()
except Exception:
pass
if sess.vllm_ws is not None:
asyncio.create_task(self._close_ws(sess.vllm_ws))
sess.vllm_ws = None
@staticmethod
async def _close_ws(ws) -> None:
try:
await ws.close()
except Exception:
pass
# ── Endpointer (adaptiv, wie whisper-Bridge M0.1) ──
def _buffer_duration_ms(self, sess: StreamSession) -> float:
samples = len(sess.pcm_buffer) // 2 samples = len(sess.pcm_buffer) // 2
return (samples / sess.sample_rate) * 1000.0 if samples else 0.0 return (samples / sess.sample_rate) * 1000.0 if samples else 0.0
def _tail_rms(self, sess: StreamSession) -> float: def _tail_rms(self, sess: StreamSession) -> float:
win_bytes = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2 win = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
if win_bytes <= 0: if win <= 0:
return 0.0 return 0.0
tail = sess.pcm_buffer[-win_bytes:] tail = sess.pcm_buffer[-win:]
if len(tail) < 2: if len(tail) < 2:
return 0.0 return 0.0
arr = pcm_s16le_to_float32(bytes(tail)) arr = pcm_s16le_to_float32(bytes(tail))
if arr.size == 0: return float(np.sqrt(np.mean(arr * arr))) if arr.size else 0.0
return 0.0
return float(np.sqrt(np.mean(arr * arr)))
def _voice_threshold(self, sess: StreamSession) -> float: def _voice_threshold(self, sess: StreamSession) -> float:
nf = sess.noise_floor nf = sess.noise_floor
@@ -342,8 +396,66 @@ class SessionManager:
else: else:
sess.noise_floor = 0.98 * nf + 0.02 * rms sess.noise_floor = 0.98 * nf + 0.02 * rms
async def _check_speaker(self, sess: StreamSession) -> None:
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
return
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
sess.speaker_match = True
return
try:
loop = asyncio.get_running_loop()
is_match, sim = await loop.run_in_executor(None, speaker_id.verify, head)
except Exception as exc:
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
sess.request_id[:8], exc)
sess.speaker_match = True
return
sess.speaker_match = is_match
sess.speaker_similarity = sim
logger.info("Stream %s: speaker-check sim=%.2f%s (thr=%.2f)",
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
speaker_id.DEFAULT_THRESHOLD)
if not is_match:
await self._finalize_speaker_mismatch(sess, sim)
async def _finalize_speaker_mismatch(self, sess: StreamSession, similarity: float) -> None:
"""Fremde Stimme: synthetisches leeres stt_endpoint (reason=speaker_mismatch),
Session droppen kein Voxtral-Transcribe, kein Brain-Call."""
if sess.endpoint_sent:
return
sess.endpoint_sent = True
duration_s = self._buffer_ms(sess) / 1000.0
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
sess.request_id[:8], similarity, duration_s)
if self._ws is not None:
payload = {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": "speaker_mismatch",
"durationS": duration_s, "sttMs": 0,
"voice": sess.voice, "speed": sess.speed,
"interrupted": sess.interrupted,
"speakerSimilarity": float(similarity),
}
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": "", "reason": "speaker_mismatch",
})
self.drop(sess.request_id)
async def run_endpointer(self) -> None: async def run_endpointer(self) -> None:
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD)") logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
STREAM_TRANSCRIBE_INTERVAL_MS)
while True: while True:
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
now = time.time() now = time.time()
@@ -351,7 +463,7 @@ class SessionManager:
try: try:
await self._tick(sess, now) await self._tick(sess, now)
except Exception: except Exception:
logger.exception("Endpointer-Tick crashed (session=%s)", sid[:8]) logger.exception("Tick crashed (session=%s)", sid[:8])
for sid, sess in list(self._sessions.items()): for sid, sess in list(self._sessions.items()):
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S: if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
logger.info("Stream %s: TTL — drop", sid[:8]) logger.info("Stream %s: TTL — drop", sid[:8])
@@ -360,60 +472,214 @@ class SessionManager:
async def _tick(self, sess: StreamSession, now: float) -> None: async def _tick(self, sess: StreamSession, now: float) -> None:
if sess.endpoint_sent: if sess.endpoint_sent:
return return
elapsed_ms = (now - sess.started_at) * 1000.0 if (now - sess.started_at) * 1000.0 > sess.hard_cap_ms and not sess.closed:
if elapsed_ms > sess.hard_cap_ms and not sess.closed: await self._finalize(sess, "hardcap")
await self._finalize(sess, reason="hardcap")
return return
if sess.closed: if sess.closed:
await self._finalize(sess, reason="stream_end") await self._finalize(sess, "stream_end")
return return
if self._buffer_duration_ms(sess) < STREAM_MIN_AUDIO_MS: if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
return return
# adaptive akustische Sprach-Aktivitaet # Speaker-ID einmalig: ist es Stefans Stimme? Fremde → Session verwerfen
# (kein Transcribe, kein Brain-Call). Ohne Enrollment fail-open.
if not sess.speaker_checked and self._buffer_ms(sess) >= STREAM_SPEAKER_CHECK_MS:
await self._check_speaker(sess)
if sess.speaker_match is False:
return
# Adaptive akustische Sprach-Aktivitaet (M0.1). KEINE Live-Partials mehr:
# Voxtral-3B transkribiert den ganzen WACHSENDEN Buffer und braucht dafuer
# bei langen Aufnahmen 5-6 s — zu langsam fuer Live-Text, UND diese Latenz
# hat den semantischen Endpoint faelschlich ausgeloest (Partial-Latenz >
# Timeout → willkuerliche Abbrueche nach 20-40 s). Deshalb: Turn-Ende rein
# AKUSTISCH, transkribiert wird nur EINMAL im _finalize.
rms = self._tail_rms(sess) rms = self._tail_rms(sess)
if rms >= self._voice_threshold(sess): if rms >= self._voice_threshold(sess):
sess.last_voice_at = now sess.last_voice_at = now
sess.voiced_frames += 1
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
if (not sess.speech_signaled and self._ws is not None
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
sess.speech_signaled = True
await _send(self._ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "",
})
# Speech-Endpoint gegen laute Umgebungsmusik: es ist gerade laut (rms
# ueber Schwelle) — aber ist es Sprache oder Musik? Der RMS-Endpoint
# unten wuerde bei Musik NIE feuern (last_voice_at bleibt frisch).
# Deshalb gedrosselt mit Silero das letzte endpoint_ms-Fenster pruefen:
# KEINE Sprache drin (nur Musik) → Turn ist zu Ende. Real gesprochene
# Turns haben Sprache im Fenster → laufen weiter.
if (self._buffer_ms(sess) >= sess.endpoint_ms
and (now - sess.last_speech_check_at) * 1000.0 >= STREAM_SPEECH_ENDPOINT_CHECK_MS):
sess.last_speech_check_at = now
try:
tail_bytes = int(sess.endpoint_ms / 1000.0 * sess.sample_rate) * 2
tail = pcm_s16le_to_float32(bytes(sess.pcm_buffer[-tail_bytes:]))
segs = _speech_segments(tail)
if segs is not None and len(segs) == 0:
logger.info("Stream %s: Speech-Endpoint (keine Sprache im letzten %dms — Musik/Stille) → finalize",
sess.request_id[:8], sess.endpoint_ms)
await self._finalize(sess, "endpoint")
return
except Exception:
logger.exception("Speech-Endpoint-Check fehlgeschlagen — ignoriert")
else: else:
self._update_noise_floor(sess, rms) self._update_noise_floor(sess, rms)
# Endpoint: akustisch (Primaer) oder semantisch (Backstop), sobald Text da # No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
if sess.last_growth_at > 0.0: # (last_voice_at==0), feuert der normale Endpoint unten NIE — der braucht
acoustic_silence_ms = (now - sess.last_voice_at) * 1000.0 if sess.last_voice_at > 0 else 0.0 # last_voice_at>0. Ohne das bleibt ein reines Stille-Fenster offen bis
semantic_silence_ms = (now - sess.last_growth_at) * 1000.0 # Hardcap/manuellem Stop → genau Stefans Repro: "die Stille-Ende wird nie
acoustic_done = sess.last_voice_at > 0 and acoustic_silence_ms >= sess.endpoint_ms # erreicht, stop ich selbst ist es weg". Nach endpoint_ms Stille ab Start
semantic_done = semantic_silence_ms >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR # schliessen wir das Fenster selbst als no-speech (leer, lautlos, zurueck
if acoustic_done or semantic_done: # aufs Wake-Word). voiced_frames==0 → _finalize verwirft ohne Transkript,
await self._finalize(sess, reason="endpoint" if acoustic_done else "endpoint_semantic") # also KEIN Phantom.
if sess.last_voice_at == 0 and (now - sess.started_at) * 1000.0 >= sess.endpoint_ms:
await self._finalize(sess, "no_speech")
return
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
await self._finalize(sess, "endpoint")
async def _emit_no_speech(self, sess: "StreamSession", reason_label: str) -> None:
"""Leeres no-speech-Endpoint senden + Session droppen (kein Transkript).
App re-armt still, zurueck aufs Wake-Word."""
if self._ws is not None:
payload = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": reason_label,
"durationS": 0.0, "sttMs": 0}
await _send(self._ws, "stt_endpoint", payload)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": reason_label})
self.drop(sess.request_id)
async def _finalize(self, sess: StreamSession, reason: str) -> None: async def _finalize(self, sess: StreamSession, reason: str) -> None:
if sess.endpoint_sent: if sess.endpoint_sent:
return return
sess.endpoint_sent = True sess.endpoint_sent = True
# vLLM ggf. committen, damit ein letztes transcription.done kommt. # Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
if sess.vllm_ws is not None: # Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
try: # aus Fast-Nichts gern einen Fuellsatz ("Die Stadt hat eine Flaeche von
await sess.vllm_ws.send(json.dumps({"type": VLLM_SEND_COMMIT})) # 1,5 km2"), der dann als PHANTOM-Nachricht ans Brain geht und das Gespraech
await asyncio.sleep(0.15) # kurz auf finalen Delta warten # entgleisen laesst (Stefans Repro: "kam Nachricht von mir, obwohl ich
except Exception: # nichts sagte"). Leeres Endpoint = no-speech → App re-armt still.
pass #
final_text = sess.last_partial.strip() # WICHTIG (aus dem ai-box-Log gelernt): die Phantome kommen mit
duration_s = self._buffer_duration_ms(sess) / 1000.0 # reason=stream_end — Passiv-/Wake-Fenster enden AUCH per stream_end, wenn
logger.info("Stream %s: FINAL (reason=%s, %.1fs): %r", # sie auf Stille zumachen. stream_end ist also NICHT gleich "manueller Stop".
sess.request_id[:8], reason, duration_s, final_text[:120]) # Deshalb greift der Guard jetzt auch bei stream_end, aber mit niedrigerer
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
if sess.voiced_frames < _min_voiced:
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
if self._ws is not None: if self._ws is not None:
endpoint_payload = { nospeech = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}",
"durationS": 0.0, "sttMs": 0}
await _send(self._ws, "stt_endpoint", nospeech)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}"})
self.drop(sess.request_id)
return
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
# Silero VAD: ist ueberhaupt echte Sprache im Audio? Das entscheidet am
# AUDIO, nicht am Text — faengt also generische Phantome ("Ich bin ein
# guter Mann") UND Musik/Rauschen, die der Muster-Filter nicht kennt.
# Kein Speech-Segment → no-speech, gar nicht erst transkribieren.
# fail-open: segs=None (VAD nicht verfuegbar) → normal weiter.
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
logger.info("Stream %s: Silero VAD — keine Sprache (%.1fs, reason=%s) → no-speech",
sess.request_id[:8], audio.size / 16000.0, reason)
await self._emit_no_speech(sess, f"vad_no_speech:{reason}")
return
if segs:
# Auf die Sprach-Spanne trimmen (Stille-Raender weg → Voxtral
# halluziniert an den Enden weniger). Kleiner Pad gegen abgeschnittene
# leise Wort-Anfaenge/-Enden.
pad = int(SILERO_PAD_MS / 1000.0 * 16000)
s0 = max(0, segs[0]["start"] - pad)
s1 = min(int(audio.size), segs[-1]["end"] + pad)
if s1 > s0 and (s1 - s0) < audio.size:
audio = audio[s0:s1]
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
# Repetition-Loop einkassieren, falls trotz no_repeat_ngram was durchkam.
_collapsed = _collapse_repetitions(final_text)
if _collapsed != final_text:
logger.info("Stream %s: Repetition-Loop kollabiert (%d%d Zeichen)",
sess.request_id[:8], len(final_text), len(_collapsed))
final_text = _collapsed
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
# Halluzinations-Filter (2. Netz): leeres/Artefakt-Transkript im borderline-
# Band → als no-speech verwerfen statt ein Phantom ("Die Stadt hat eine
# Flaeche von 1,5 km2") ans Brain zu schicken. Gilt fuer ALLE reasons inkl.
# stream_end (dort kamen die realen Phantome!) — aber das borderline-Band
# (wenig voiced_frames) schuetzt echte, klar gesprochene Eingaben: eine echte
# Geografie-FRAGE hat normale Stimm-Energie (voiced_frames >> Schwelle) und
# geht durch; das Phantom aus Stille hat ~0 und wird verworfen. Ein leeres
# Transkript wird immer verworfen (nichts gesagt = nichts senden).
_clean = final_text.strip(" .,!?…-\t\n\r")
_borderline = sess.voiced_frames < STREAM_HALLUC_GUARD_FRAMES
_is_phantom = (not _clean) or (_borderline and bool(_HALLUCINATION_RE.search(final_text)))
if _is_phantom:
logger.info("Stream %s: Halluzination verworfen (voiced_frames=%d<%d, %.1fs, text=%r)",
sess.request_id[:8], sess.voiced_frames, STREAM_HALLUC_GUARD_FRAMES,
duration_s, final_text[:80])
if self._ws is not None:
nospeech = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"hallucination:{reason}",
"durationS": 0.0, "sttMs": stt_ms}
await _send(self._ws, "stt_endpoint", nospeech)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"hallucination:{reason}"})
self.drop(sess.request_id)
return
if self._ws is not None:
payload = {
"requestId": sess.request_id, "requestId": sess.request_id,
"audioRequestId": sess.audio_request_id, "audioRequestId": sess.audio_request_id,
"text": final_text, "text": final_text,
"reason": reason, "reason": reason,
"durationS": duration_s, "durationS": duration_s,
"sttMs": 0, "sttMs": stt_ms,
"voice": sess.voice, "voice": sess.voice,
"speed": sess.speed, "speed": sess.speed,
"interrupted": sess.interrupted, "interrupted": sess.interrupted,
} }
if sess.location: if sess.location:
endpoint_payload["location"] = sess.location payload["location"] = sess.location
await _send(self._ws, "stt_endpoint", endpoint_payload) await _send(self._ws, "stt_endpoint", payload)
await _send(self._ws, "stt_stream_done", { await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id, "requestId": sess.request_id,
"audioRequestId": sess.audio_request_id, "audioRequestId": sess.audio_request_id,
@@ -447,7 +713,6 @@ async def run_loop(sessions: SessionManager) -> None:
sessions.attach_ws(ws) sessions.attach_ws(ws)
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL) await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
await _send(ws, "config_request", {"service": "voxtral"}) await _send(ws, "config_request", {"service": "voxtral"})
async for raw in ws: async for raw in ws:
try: try:
msg = json.loads(raw) msg = json.loads(raw)
@@ -456,35 +721,101 @@ async def run_loop(sessions: SessionManager) -> None:
mtype = msg.get("type", "") mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {} payload = msg.get("payload", {}) or {}
if mtype == "stt_stream_start": if mtype == "stt_stream_start":
asyncio.create_task(sessions.start_session(payload)) sessions.start_session(payload)
elif mtype == "stt_audio_chunk": elif mtype == "stt_audio_chunk":
sessions.feed_chunk(payload) sessions.feed_chunk(payload)
elif mtype == "stt_stream_end": elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", "")) sessions.end_session(payload.get("requestId", ""))
# stt_request (Legacy One-Shot) macht Voxtral hier NICHT — elif mtype == "stt_transcribe_blob":
# dafuer bleibt die whisper-Bridge (Fallback). # One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
# den Vor-Trigger-Audio, wir sagen was gesagt wurde, die App
# prueft ob "Computer" drin ist. Silero vorgeschaltet:
# Musik/Rauschen → leerer Text (nicht bestaetigt).
req_id = payload.get("requestId", "")
try:
pcm = base64.b64decode(payload.get("pcm", ""))
audio = pcm_s16le_to_float32(pcm)
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
text = ""
else:
text = (await sessions.runner.transcribe(
audio, payload.get("language", "de"))).strip()
logger.info("stt_transcribe_blob (%.1fs) → %r",
audio.size / 16000.0, text[:60])
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": text})
except Exception as exc:
logger.warning("stt_transcribe_blob fehlgeschlagen: %s", exc)
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": "", "error": str(exc)[:200]})
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")
try:
status = speaker_id.status()
await _send(ws, "voice_id_status_response",
{"requestId": req_id, "ok": True, **status})
except Exception as exc:
await _send(ws, "voice_id_status_response",
{"requestId": req_id, "ok": False, "error": str(exc)[:200]})
elif mtype == "voice_id_enroll_request":
req_id = payload.get("requestId", "")
samples = payload.get("samples") or []
logger.info("voice_id_enroll_request: %d Samples (id=%s)", len(samples), req_id[:8])
try:
result = await asyncio.get_running_loop().run_in_executor(
None, speaker_id.enroll_from_samples, samples)
await _send(ws, "voice_id_enroll_response", {
"requestId": req_id, "ok": True,
"sample_count": result.get("sample_count", 0),
"rejected": result.get("rejected", []),
"updated_at": result.get("updated_at"),
"embedding_dim": result.get("embedding_dim"),
})
except Exception as exc:
logger.warning("voice_id_enroll failed: %s", exc)
await _send(ws, "voice_id_enroll_response",
{"requestId": req_id, "ok": False, "error": str(exc)[:300]})
elif mtype == "voice_id_delete_request":
req_id = payload.get("requestId", "")
removed = speaker_id.delete_fingerprint()
await _send(ws, "voice_id_delete_response",
{"requestId": req_id, "ok": True, "removed": removed})
elif mtype == "config":
if "voiceIdThreshold" in payload:
try:
t = float(payload.get("voiceIdThreshold", 0.5))
if 0.0 <= t <= 1.0:
speaker_id.DEFAULT_THRESHOLD = t
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError):
pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
except Exception as e: except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s) logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried: if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
use_tls = False use_tls = False
tls_fallback_tried = True tls_fallback_tried = True
logger.info("TLS-Fallback: versuche ws:// (kein TLS)")
continue continue
await asyncio.sleep(retry_s) await asyncio.sleep(retry_s)
retry_s = min(retry_s * 2, 30) retry_s = min(retry_s * 2, 30)
use_tls = RVS_TLS # fuer den naechsten Zyklus zuruecksetzen use_tls = RVS_TLS
async def main() -> None: async def main() -> None:
if not RVS_HOST or not RVS_TOKEN: if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST/RVS_TOKEN fehlen — .env pruefen. Abbruch.") logger.error("RVS_HOST/RVS_TOKEN fehlen — .env pruefen. Abbruch.")
return return
sessions = SessionManager() runner = VoxtralRunner()
logger.info("Voxtral-Bridge startet — vLLM=%s Modell=%s", VOXTRAL_VLLM_URL, VOXTRAL_MODEL) loop = asyncio.get_running_loop()
await asyncio.gather( await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
run_loop(sessions), sessions = SessionManager(runner)
sessions.run_endpointer(), logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
) await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
if __name__ == "__main__": if __name__ == "__main__":
+10 -4
View File
@@ -1,5 +1,11 @@
# Voxtral-Bridge ist reine CPU-Glue (RVS-WS <-> vLLM-Realtime-WS). Das Modell # Voxtral-3B via Transformers. torch/torchaudio kommen cu124-gepinnt aus dem
# selbst laeuft im separaten voxtral-vllm-Container (GPU). Deshalb hier KEIN # Dockerfile (nicht hier, sonst zieht pip das Default-CUDA-Wheel).
# torch/vllm — nur der WebSocket-Client + numpy fuer die RMS-Energiemessung. transformers>=4.54
websockets>=12.0 mistral-common[audio]>=1.8.1
accelerate>=0.30
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
silero-vad>=5.1 # neuronales VAD: echte Sprache vs Stille/Rauschen/Musik
soundfile>=0.12
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
numpy>=1.24 numpy>=1.24
websockets>=12.0
+272
View File
@@ -0,0 +1,272 @@
"""
Speaker-ID Backend fuer ARIAs Stimmen-Erkennung.
Nutzt SpeechBrain ECAPA-TDNN (192-dim Embeddings, auf VoxCeleb-1+2 trainiert).
Fingerprint = gemittelter, L2-normalisierter Embedding-Vektor aus N
Enrollment-Samples. Verify: cosine_similarity(neue_aufnahme, fingerprint).
Persistenz: /voice-id/fingerprint.json (Float-Liste + Metadaten).
Modell-Cache: /root/.cache/huggingface/ (Bind-Mount mit f5tts geteilt).
Verhalten OHNE Enrollment (kein Fingerprint vorhanden):
verify() (True, 0.0) Fail-open, damit Speaker-ID-Gating den
ungeenrollten Brain-Pfad nicht versehentlich blockiert.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
import numpy as np
logger = logging.getLogger(__name__)
VOICE_ID_DIR = Path(os.environ.get("VOICE_ID_DIR", "/voice-id"))
FINGERPRINT_FILE = VOICE_ID_DIR / "fingerprint.json"
# Cosine-Threshold: 0.5 ist konservativ (wenig false-positives), 0.3 ist
# locker (mehr Treffer auch bei Nebengeraeuschen). Stefan kann's per
# Diagnostic-Setting feintunen.
DEFAULT_THRESHOLD = 0.5
# Minimal-Sample-Laenge fuer ein verlaessliches Embedding (~1s @ 16kHz int16 = 32000 bytes)
MIN_SAMPLE_BYTES = 32000
_model = None
def _ensure_loaded():
"""Lazy-Load des ECAPA-TDNN. Holt das Modell beim ersten Aufruf von HF;
danach cached im HF-Cache-Volume. Erste Init: ~30s download + load,
danach <1s warm. Wirft bei Fehler Caller muss catchen + fail-open."""
global _model
if _model is not None:
return _model
import torch
from speechbrain.inference.speaker import EncoderClassifier
device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info("[speaker-id] loading ECAPA-TDNN on %s ...", device)
_model = EncoderClassifier.from_hparams(
source="speechbrain/spkrec-ecapa-voxceleb",
savedir="/root/.cache/huggingface/speechbrain-ecapa",
run_opts={"device": device},
)
logger.info("[speaker-id] model ready (device=%s)", device)
return _model
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
import os
import subprocess
import tempfile
tmp = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
tf.write(audio_bytes)
tmp = tf.name
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode != 0 or not proc.stdout:
raise ValueError(
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
return proc.stdout
finally:
if tmp:
try:
os.unlink(tmp)
except Exception:
pass
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV Header strippen +
Format validieren; MP4/AAC via ffmpeg dekodieren. Ergebnis: rohes PCM."""
if (len(audio_bytes) >= 44
and audio_bytes[:4] == b"RIFF"
and audio_bytes[8:12] == b"WAVE"):
import io
import wave
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
sr = wav.getframerate()
ch = wav.getnchannels()
sw = wav.getsampwidth()
if sr != 16000:
raise ValueError(f"WAV-Samplerate {sr} != 16000")
if ch != 1:
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
if sw != 2:
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
return wav.readframes(wav.getnframes())
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
return _decode_compressed_to_pcm(audio_bytes)
return audio_bytes
def _audio_bytes_to_tensor(audio_bytes: bytes):
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
import torch
raw = _normalize_audio_bytes(audio_bytes)
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
return torch.from_numpy(arr).unsqueeze(0)
def embed(audio_bytes: bytes) -> np.ndarray:
"""Berechnet das Speaker-Embedding fuer einen Audio-Chunk.
Erwartet 16kHz int16 LE PCM Mono. Returns 192-dim numpy float32."""
import torch
model = _ensure_loaded()
wav = _audio_bytes_to_tensor(audio_bytes)
with torch.no_grad():
emb = model.encode_batch(wav)
return emb.squeeze().cpu().numpy().astype(np.float32)
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Kosinus-Aehnlichkeit zwischen zwei 1D-Vektoren, Range [-1, 1].
Hoeher = aehnlicher. Bei normalisierten Vektoren ist das gleich dem Skalarprodukt."""
na = np.linalg.norm(a)
nb = np.linalg.norm(b)
if na < 1e-9 or nb < 1e-9:
return 0.0
return float(np.dot(a, b) / (na * nb))
def save_fingerprint(embeddings: list[np.ndarray], sample_durations_s: list[float]) -> dict:
"""Mittelt + L2-normalisiert die Embeddings und schreibt sie nach
FINGERPRINT_FILE. Returns das gespeicherte Dict."""
if not embeddings:
raise ValueError("Keine Embeddings zum Speichern")
VOICE_ID_DIR.mkdir(parents=True, exist_ok=True)
stacked = np.stack(embeddings)
mean = stacked.mean(axis=0)
mean = mean / max(np.linalg.norm(mean), 1e-9)
data = {
"version": 1,
"embedding": mean.tolist(),
"embedding_dim": int(mean.shape[0]),
"sample_count": len(embeddings),
"sample_durations_s": [float(s) for s in sample_durations_s],
"updated_at": int(time.time()),
}
FINGERPRINT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.info("[speaker-id] fingerprint gespeichert: %d Samples, dim=%d, total_s=%.1f",
len(embeddings), mean.shape[0], sum(sample_durations_s))
return data
def load_fingerprint() -> Optional[dict]:
"""Returns das Fingerprint-Dict oder None wenn noch nicht enrolled."""
if not FINGERPRINT_FILE.exists():
return None
try:
return json.loads(FINGERPRINT_FILE.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("[speaker-id] fingerprint laden fehlgeschlagen: %s", exc)
return None
def delete_fingerprint() -> bool:
"""Loescht den Fingerprint (z.B. fuer Re-Enrollment). True wenn was weg ist."""
if FINGERPRINT_FILE.exists():
FINGERPRINT_FILE.unlink()
logger.info("[speaker-id] fingerprint geloescht")
return True
return False
def verify(audio_bytes: bytes, threshold: Optional[float] = None) -> tuple[bool, float]:
"""Returns (is_match, similarity).
Wenn threshold=None: nutzt den Modul-Default (DEFAULT_THRESHOLD) der wird
vom config-Broadcast zur Laufzeit auf den Diagnostic-Slider-Wert gesetzt.
Default-Arg-Bindung waere zur Def-Zeit, also bewusst None statt direkt.
Fail-open: wenn kein Fingerprint vorhanden ist oder das Embedding-Modell
crasht, returnt (True, 0.0) kein Filtering. Sonst wuerde ein kaputter
Speaker-ID-Service die ganze Aufnahme blockieren."""
if threshold is None:
threshold = DEFAULT_THRESHOLD
fp = load_fingerprint()
if fp is None:
return True, 0.0
if len(audio_bytes) < MIN_SAMPLE_BYTES:
# Zu wenig Audio fuer ein verlaessliches Embedding → durchlassen
return True, 0.0
try:
saved_emb = np.array(fp["embedding"], dtype=np.float32)
new_emb = embed(audio_bytes)
except Exception as exc:
logger.warning("[speaker-id] verify embed failed: %s — fail-open", exc)
return True, 0.0
sim = cosine_similarity(new_emb, saved_emb)
return sim >= threshold, sim
def status() -> dict:
"""Status-Snapshot fuer die App / Diagnostic."""
fp = load_fingerprint()
return {
"enrolled": fp is not None,
"sample_count": fp.get("sample_count", 0) if fp else 0,
"sample_durations_s": fp.get("sample_durations_s", []) if fp else [],
"updated_at": fp.get("updated_at") if fp else None,
"embedding_dim": fp.get("embedding_dim") if fp else None,
"default_threshold": DEFAULT_THRESHOLD,
}
def enroll_from_samples(samples_b64: list[str]) -> dict:
"""Verarbeitet base64-Samples (16kHz int16 LE PCM Mono) zu einem neuen
Fingerprint. Returns Status-Dict. Wirft ValueError wenn nichts brauchbar ist."""
if not samples_b64:
raise ValueError("Keine Samples uebergeben")
embeddings: list[np.ndarray] = []
durations: list[float] = []
rejected: list[dict] = []
for idx, s in enumerate(samples_b64):
try:
raw = base64.b64decode(s)
except Exception as exc:
rejected.append({"index": idx, "reason": f"base64: {exc}"})
continue
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
try:
pcm = _normalize_audio_bytes(raw)
except Exception as exc:
rejected.append({"index": idx, "reason": f"decode: {exc}"})
continue
if len(pcm) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
continue
try:
emb = embed(pcm)
embeddings.append(emb)
durations.append(len(pcm) / 2 / 16000.0)
except Exception as exc:
rejected.append({"index": idx, "reason": f"embed: {exc}"})
if not embeddings:
raise ValueError(
f"Keine Samples konnten verarbeitet werden ({len(rejected)} rejected). "
f"Details: {rejected[:3]}"
)
fingerprint = save_fingerprint(embeddings, durations)
fingerprint["rejected"] = rejected
return fingerprint
+19
View File
@@ -86,6 +86,16 @@ 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_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_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt) STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung, # Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x # z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird. # endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
@@ -467,6 +477,11 @@ class SessionManager:
Ohne Fingerprint fail-open (match=True). Bei mismatch wird die Ohne Fingerprint fail-open (match=True). Bei mismatch wird die
Session sofort beendet mit synthetischem stt_endpoint.""" Session sofort beendet mit synthetischem stt_endpoint."""
sess.speaker_checked = True sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
sess.speaker_similarity = 0.0
return
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms) # Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32]) head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES: if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
@@ -975,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
logger.info("[speaker-id] threshold gesetzt: %.2f", t) logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
if "whisperDebugLog" in payload: if "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE old = _DEBUG_LOG_TO_BRIDGE
+48 -7
View File
@@ -61,10 +61,40 @@ def _ensure_loaded():
return _model return _model
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
import os
import subprocess
import tempfile
tmp = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
tf.write(audio_bytes)
tmp = tf.name
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode != 0 or not proc.stdout:
raise ValueError(
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
return proc.stdout
finally:
if tmp:
try:
os.unlink(tmp)
except Exception:
pass
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes: def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE). """Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16). komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV Header strippen +
Ergebnis: rohes PCM.""" Format validieren; MP4/AAC via ffmpeg dekodieren. Ergebnis: rohes PCM."""
if (len(audio_bytes) >= 44 if (len(audio_bytes) >= 44
and audio_bytes[:4] == b"RIFF" and audio_bytes[:4] == b"RIFF"
and audio_bytes[8:12] == b"WAVE"): and audio_bytes[8:12] == b"WAVE"):
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
if sw != 2: if sw != 2:
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)") raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
return wav.readframes(wav.getnframes()) return wav.readframes(wav.getnframes())
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
return _decode_compressed_to_pcm(audio_bytes)
return audio_bytes return audio_bytes
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
except Exception as exc: except Exception as exc:
rejected.append({"index": idx, "reason": f"base64: {exc}"}) rejected.append({"index": idx, "reason": f"base64: {exc}"})
continue continue
if len(raw) < MIN_SAMPLE_BYTES: # Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"}) # Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
try:
pcm = _normalize_audio_bytes(raw)
except Exception as exc:
rejected.append({"index": idx, "reason": f"decode: {exc}"})
continue
if len(pcm) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
continue continue
try: try:
emb = embed(raw) emb = embed(pcm)
embeddings.append(emb) embeddings.append(emb)
durations.append(len(raw) / 2 / 16000.0) durations.append(len(pcm) / 2 / 16000.0)
except Exception as exc: except Exception as exc:
rejected.append({"index": idx, "reason": f"embed: {exc}"}) rejected.append({"index": idx, "reason": f"embed: {exc}"})
if not embeddings: if not embeddings: