Compare commits

...
19 Commits
Author SHA1 Message Date
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
15 changed files with 801 additions and 375 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20303
versionName "0.2.3.3"
versionCode 20308
versionName "0.2.3.8"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -59,7 +59,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 1500L
private const val STARTUP_SUPPRESSION_MS = 600L
}
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.3.3",
"version": "0.2.3.8",
"private": true,
"scripts": {
"android": "react-native run-android",
+77 -15
View File
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs } from '../services/audio';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@@ -384,6 +384,8 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false);
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
@@ -662,6 +664,7 @@ const ChatScreen: React.FC = () => {
const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed();
bargeInEnabledRef.current = await loadBargeInEnabled();
const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints');
@@ -1398,13 +1401,30 @@ const ChatScreen: React.FC = () => {
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// 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 _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
// kein 30s-Fenster (skipPassive=true).
wakeWordService.endConversation(true).catch(() => {});
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(() => {});
}
}
}
@@ -1810,7 +1830,9 @@ const ChatScreen: React.FC = () => {
// Prozess nicht killt wenn die App im Hintergrund ist.
const unsubTtsStart = audioService.onPlaybackStarted(() => {
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(() => {});
}
});
@@ -2231,15 +2253,16 @@ const ChatScreen: React.FC = () => {
advanceQueue(pid);
}, [advanceQueue]);
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
// Nimmt der User das Mikro waehrend ARIA SPRICHT, ist das ein echter Interrupt:
// TTS stoppen UND die laufende Brain-Antwort abbrechen (cancel_request). Sonst
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
const interruptAriaIfBusy = useCallback(() => {
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;
}, []);
@@ -2288,11 +2311,50 @@ const ChatScreen: React.FC = () => {
return true;
}, [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
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
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
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
+23 -1
View File
@@ -75,6 +75,8 @@ import {
MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY,
loadBargeInEnabled,
saveBargeInEnabled,
VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX,
@@ -204,6 +206,8 @@ const SettingsScreen: React.FC = () => {
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// 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
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false);
@@ -329,6 +333,7 @@ const SettingsScreen: React.FC = () => {
}
}
});
loadBargeInEnabled().then(setBargeIn).catch(() => {});
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') {
const n = parseFloat(saved);
@@ -1666,7 +1671,24 @@ const SettingsScreen: React.FC = () => {
{currentSection === 'voice_input' && (<>
<Text style={styles.sectionTitle}>Spracheingabe</Text>
<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}>
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
+23 -4
View File
@@ -152,14 +152,33 @@ 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
// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die
// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv;
// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings.
// im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
// unter "Stille-Toleranz" konfigurierbar.
export const STT_ENDPOINT_DEFAULT_MS = 2400;
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';
// 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 {}
}
export async function loadSttEndpointMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
+14 -11
View File
@@ -54,10 +54,10 @@ export async function savePassiveListenMs(ms: number): Promise<void> {
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher,
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1.
export const WAKE_THRESHOLD_DEFAULT = 0.6;
// weniger Fehlauslösung, aber man muss deutlicher/lauter sprechen (fuehlt sich
// "traege" an). Fehlausloeser werden ueber Speaker-ID (E3) ohnehin verworfen,
// deshalb darf der Default empfindlicher sein. 0.45 (war 0.6/0.5). 0..1.
export const WAKE_THRESHOLD_DEFAULT = 0.45;
export const WAKE_THRESHOLD_MIN = 0.3;
export const WAKE_THRESHOLD_MAX = 0.9;
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
@@ -103,7 +103,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
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;
interface OpenWakeWordModule {
@@ -310,7 +312,7 @@ class WakeWordService {
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
* die openWakeWord faelschlich als Trigger interpretiert. */
setResumeCooldown(ms: number = 1500): void {
setResumeCooldown(ms: number = 500): void {
this.cooldownUntilMs = Date.now() + ms;
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
}
@@ -323,13 +325,14 @@ class WakeWordService {
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
}
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
/** 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 {
this.inBackground = false;
this.cooldownUntilMs = Date.now() + 3000;
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
this.cooldownUntilMs = Date.now() + 1000;
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
}
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
+60 -5
View File
@@ -1347,6 +1347,53 @@ def _extract_await_marker(text: str) -> tuple:
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
def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
@@ -2033,16 +2080,24 @@ class Agent:
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
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
self.conversation.add("assistant", final_reply,
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
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)),
awaiting_reply)
return (final_reply, "claude", speak, converse, awaiting_reply)
# ── Tool-Dispatcher ───────────────────────────────────────
+42 -1
View File
@@ -162,6 +162,46 @@ def build_time_section() -> str:
]
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]]`.",
])
TYPE_HEADINGS = {
"identity": "## Wer du bist",
"rule": "## Sicherheitsregeln & Prinzipien",
@@ -463,7 +503,8 @@ def build_system_prompt(
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
# 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:
parts.append("")
parts.append(build_skills_section(skills))
+15 -36
View File
@@ -63,6 +63,7 @@ services:
whisper-bridge:
build: ./whisper
container_name: aria-whisper-bridge
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
deploy:
resources:
reservations:
@@ -138,55 +139,33 @@ services:
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped
# ─── Voxtral STT (GPU, Realtime) — PROFIL "voxtral" ───────────
# Ersetzt whisper als STT sobald die 24-GB-Karte da ist. Startet NUR mit
# docker compose --profile voxtral up -d
# (sonst kollidiert es mit whisper — beide wuerden stt_* beantworten).
#
# ⚠️ VRAM: Voxtral-Mini-4B-Realtime-2602 braucht >=16 GB (BF16, laut vLLM-
# Rezept keine Quant). Laeuft NICHT auf der 3060 (12 GB) — erst 24-GB-Karte.
# ⚠️ vLLM: Version >=0.20.0 noetig. Entrypoint/Serve-Form beim ersten Lauf
# gegen das offizielle Rezept pruefen (siehe voxtral/README.md).
voxtral-vllm:
image: vllm/vllm-openai:latest
container_name: aria-voxtral-vllm
profiles: ["voxtral"]
# ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
# immer nur EINEN STT laufen lassen.
voxtral-bridge:
build: ./voxtral
container_name: aria-voxtral-bridge
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
capabilities: [gpu]
volumes:
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
environment:
- 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
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
environment:
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN}
- VOXTRAL_VLLM_URL=ws://voxtral-vllm:8000/v1/realtime
- VOXTRAL_MODEL=mistralai/Voxtral-Mini-4B-Realtime-2602
- VOXTRAL_MODEL=mistralai/Voxtral-Mini-3B-2507
- VOXTRAL_LANGUAGE=${WHISPER_LANGUAGE:-de}
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
restart: unless-stopped
+19 -7
View File
@@ -1,14 +1,26 @@
# Voxtral-BRIDGE (nicht das Modell!) — leichte CPU-Glue zwischen RVS und dem
# vLLM-Realtime-Server. Das eigentliche Voxtral-Modell laeuft im Container
# `voxtral-vllm` (GPU, vllm/vllm-openai). Deshalb hier kein CUDA-Base noetig.
FROM python:3.11-slim
# Voxtral-STT-3B Bridge (Transformers). Laeuft auf Treiber 550/CUDA 12.4 via
# torch cu124 — KEIN Treiber-Upgrade noetig (gleicher Trick wie f5tts).
# Modell: Voxtral-Mini-3B-2507 (~9 GB in bf16) → passt auf die 12-GB-Karte (GPU 1).
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends \
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"]
+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
vLLM. Ersetzt whisper als STT — genauer (~5,9 % WER vs 7,4 % FLEURS) und mit
echtem Realtime-Streaming. Deutsch ist in den 13 Sprachen abgedeckt.
Streaming-STT via **Voxtral-Mini-3B-2507** (Mistral, Apache 2.0) über
🤗 Transformers. Ersetzt whisper als STT — genauer, und **ohne Treiber-Upgrade**:
läuft auf dem Trixie-Standardtreiber (550/CUDA 12.4) via **torch 2.6.0+cu124**
(derselbe Trick wie bei f5tts).
Zwei Container:
- **`voxtral-vllm`** — das Modell auf vLLM (GPU). Exponiert die Realtime-WS-API.
- **`voxtral-bridge`** — CPU-Glue: RVS ⇄ vLLM-Realtime-WS. Macht das Endpointing
selbst (adaptiver Rausch-Boden-VAD, identisch zur whisper-Bridge / M0.1).
- Modell ~9 GB (bf16) → **GPU 1** (die 12-GB-Karte; per Compose gepinnt).
- **F5-TTS + LLM** bleiben auf GPU 0 (8 GB).
- Arbeitsweise = chunked wie whisper: live PCM → alle ~1 s transkribieren
(Partials) → **adaptiver Endpointer** (Rausch-Boden-VAD + semantische
Stagnation, aus M0.1) feuert `stt_endpoint`. RVS-Protokoll identisch → drop-in.
## ⚠️ Hardware-Realität — läuft NICHT auf der 3060
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)
## Starten (Profil `voxtral`)
```bash
cd xtts
docker compose stop whisper-bridge # sonst beantworten beide stt_*
docker compose --profile voxtral up -d --build
docker logs -f aria-voxtral-vllm # laedt Modell (mehrere GB, dauert)
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
docker logs -f aria-voxtral-bridge # Modell laedt (mehrere GB), dann "RVS verbunden"
```
Zurück zu whisper: `docker compose --profile voxtral down && docker compose up -d whisper-bridge`.
## ⚠️ Auf echter Hardware verifizieren (blind gebaut, kein Test hier)
## ⚠️ Auf echter Hardware verifizieren (blind gebaut)
1. **vLLM-Version ≥ 0.20.0** und die **Serve-Form**. Das Rezept nutzt
`vllm serve <model> …`. Falls das `vllm/vllm-openai`-Image einen anderen
Entrypoint hat, das `command:` in `docker-compose.yml` anpassen
(Rezept-Command steht dort als Kommentar).
2. **Realtime-WS-Frames.** Die exakten Event-Namen sind in `bridge.py` ganz oben
als Konstanten gebündelt (`VLLM_SEND_APPEND`, `VLLM_DELTA_SUFFIXES`, …),
modelliert nach dem OpenAI-Realtime-Schema. Gegen das offizielle
**vLLM-Realtime-Client-Beispiel** prüfen und dort anpassen — nur an dieser
einen Stelle. Das Response-Handling ist bereits defensiv (mehrere Feldnamen).
3. **Endpoint-URL/Port.** Default `ws://voxtral-vllm:8000/v1/realtime` — prüfen ob
vLLM auf 8000 lauscht und `/v1/realtime` registriert (Log-Zeile
`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.
1. **Transformers-API.** Die exakte Voxtral-Transkriptions-API ist in `bridge.py`
in **einer** Methode gekapselt (`VoxtralRunner._transcribe_blocking`), modelliert
nach der HF-Modelcard (`apply_transcription_request``generate` `batch_decode`).
Beim ersten Lauf gegen die Modelcard prüfen und dort anpassen.
2. **HF-Gating.** Ist `Voxtral-Mini-3B-2507` gated, `HF_TOKEN` in `xtts/.env` setzen
(wird als `HUGGING_FACE_HUB_TOKEN` durchgereicht).
3. **VRAM/Tempo.** 3B in bf16 ~9 GB auf der 12-GB-Karte — Rest fürs KV-Cache. Ist die
Partial-Transkription (alle 1 s) zu schwer, `STREAM_TRANSCRIBE_INTERVAL_MS` hochsetzen.
4. **torch-Konflikt.** Falls `transformers`/`mistral-common` beim Build torch>2.6
erzwingen, meldet der Constraint einen Konflikt → dann brauchen wir doch das
Treiber-Upgrade (`bootstrap.sh --upgrade-driver` via NVIDIA-CUDA-Repo) + cu126-torch.
## Protokoll (RVS, identisch zu whisper — drop-in)
Rein: `stt_stream_start`, `stt_audio_chunk` (16 kHz mono s16le, base64), `stt_stream_end`.
Raus: `stt_partial`, `stt_endpoint` (das Event, auf das aria-bridge horcht), `stt_stream_done`.
Raus: `stt_partial`, `stt_endpoint`, `stt_stream_done`.
## TTS-Hinweis
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).
## TTS
Bleibt **F5-TTS** (klingt gut, passt auf GPU 0). Voxtral-TTS bräuchte ~24 GB — separates Thema.
## Quellen
- Rezept: https://recipes.vllm.ai/mistralai/Voxtral-Mini-4B-Realtime-2602
- vLLM Speech-to-Text: https://docs.vllm.ai/en/latest/serving/online_serving/speech_to_text/
- Modell: https://huggingface.co/mistralai/Voxtral-Mini-4B-Realtime-2602
- Modell: https://huggingface.co/mistralai/Voxtral-Mini-3B-2507
- Transformers-Nutzung: HF-Modelcard (Voxtral) + `mistral-common`
+253 -233
View File
@@ -1,52 +1,45 @@
#!/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
im selben Prozess, sondern der separate vLLM-Realtime-Server (Container
`voxtral-vllm`) ueber dessen WebSocket-API `/v1/realtime`. Diese Bridge ist
reine Glue:
Laeuft auf Treiber 550/CUDA 12.4 via torch cu124 (kein Treiber-Upgrade noetig).
Modell: Voxtral-Mini-3B-2507 (bf16, ~9 GB) → GPU 1 (12 GB, per Compose gepinnt).
App ──(RVS: stt_stream_start / stt_audio_chunk / stt_stream_end)──▶ diese Bridge
diese Bridge ──(WS /v1/realtime: PCM16-b64 append)──▶ voxtral-vllm
voxtral-vllm ──(transcription.delta / transcription.done)──▶ diese Bridge
diese Bridge ──(RVS: stt_partial / stt_endpoint / stt_stream_done)──▶ App/aria-bridge
Arbeitsweise = Zwilling der whisper-Bridge: App schickt live PCM-Chunks; wir
transkribieren alle ~STREAM_TRANSCRIBE_INTERVAL_MS auf dem Ringbuffer (Partials)
und feuern stt_endpoint, sobald der ADAPTIVE Endpointer (Rausch-Boden-VAD +
semantische Stagnation, aus M0.1) "fertig" sagt. RVS-Wire-Protokoll identisch zu
whisper → drop-in (die App merkt nur bessere Genauigkeit).
Das RVS-Wire-Protokoll ist IDENTISCH zur whisper-Bridge (drop-in). Das
Endpointing (wann hat der User aufgehoert zu sprechen) macht diese Bridge
selbst — mit demselben ADAPTIVEN Rausch-Boden-Endpointer wie whisper (Voxtral
Realtime liefert laut vLLM-Doku keine eigene VAD/„speaker done"-Semantik, nur
transcription.delta/.done). Die akustische Energie messen wir auf unserer
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).
⚠️ VERIFY-ON-FIRST-RUN: Die exakte Transformers-Transkriptions-API von Voxtral
(apply_transcription_request / generate / decode) ist unten in EINER Methode
(VoxtralRunner._transcribe_blocking) gekapselt und nach dem HF-Modelcard-Muster
modelliert. Beim ersten echten Lauf gegen die Voxtral-Modelcard pruefen und dort
anpassen. Alles andere (RVS, Endpointer) ist bewaehrt.
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
VOXTRAL_VLLM_URL Default: ws://voxtral-vllm:8000/v1/realtime
VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-4B-Realtime-2602
VOXTRAL_LANGUAGE Default: de
VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-3B-2507
VOXTRAL_LANGUAGE Default: de
VOXTRAL_DEVICE Default: cuda
STREAM_TRANSCRIBE_INTERVAL_MS Default 1000 (3B ist schwerer als whisper-small)
"""
import asyncio
import base64
import json
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import soundfile as sf
import websockets
import speaker_id # Speaker-ID (nur Stefans Stimme) — portiert aus der whisper-Bridge
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
@@ -60,29 +53,19 @@ RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
VOXTRAL_VLLM_URL = os.getenv("VOXTRAL_VLLM_URL", "ws://voxtral-vllm:8000/v1/realtime")
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602")
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
# ── vLLM-Realtime-Frames — HIER anpassen falls das Client-Beispiel abweicht ──
# 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_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
STREAM_DEFAULT_ENDPOINT_MS = 2400
STREAM_DEFAULT_HARD_CAP_MS = 60000
STREAM_DEFAULT_HARD_CAP_MS = 300000
STREAM_MIN_AUDIO_MS = 600
STREAM_SPEAKER_CHECK_MS = 1500 # ab so viel Audio einmalig Speaker-ID pruefen
STREAM_SESSION_TTL_S = 120
STREAM_ENERGY_WINDOW_MS = 300
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
# Adaptiver Voice-Schwellwert (siehe whisper-Bridge M0.1): Grenze relativ zum
# gemessenen Rausch-Boden statt fix — schneidet leises Sprechen nicht ab.
# Adaptiver Voice-Schwellwert (M0.1): relativ zum gemessenen Rausch-Boden.
STREAM_VOICE_FACTOR = 2.5
STREAM_VOICE_RMS_MIN = 0.005
STREAM_VOICE_RMS_MAX = 0.020
@@ -91,21 +74,75 @@ STREAM_VOICE_RMS_MAX = 0.020
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
if not data:
return np.zeros(0, dtype=np.float32)
arr = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
return arr
return np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
"type": mtype, "payload": payload, "timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("RVS-Send fehlgeschlagen (%s): %s", mtype, e)
class VoxtralRunner:
"""Haelt das Voxtral-Modell (Transformers). transcribe() blockiert → aus dem
Event-Loop via run_in_executor aufrufen. Ein Lock serialisiert GPU-Zugriffe."""
def __init__(self) -> None:
self.model = None
self.processor = None
self._lock = asyncio.Lock()
def load(self) -> None:
import torch
from transformers import AutoProcessor, VoxtralForConditionalGeneration
t0 = time.time()
logger.info("Lade Voxtral '%s' (device=%s, bf16)…", VOXTRAL_MODEL, VOXTRAL_DEVICE)
self.processor = AutoProcessor.from_pretrained(VOXTRAL_MODEL)
self.model = VoxtralForConditionalGeneration.from_pretrained(
VOXTRAL_MODEL, torch_dtype=torch.bfloat16, device_map=VOXTRAL_DEVICE,
)
logger.info("Voxtral geladen in %.1fs", time.time() - t0)
def _transcribe_blocking(self, audio_f32: np.ndarray, language: str) -> str:
import torch
proc, model = self.processor, self.model
if proc is None or model is None or audio_f32.size == 0:
return ""
# VoxtralProcessor verlangt bei rohen Arrays ein 'format'. Robuster:
# in ein temp-WAV schreiben und den PFAD uebergeben — der Processor liest
# Format + Samplerate selbst, kein 'format'-Argument noetig.
wav_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
wav_path = tf.name
sf.write(wav_path, audio_f32, 16000, subtype="PCM_16")
inputs = proc.apply_transcription_request(
language=language, audio=wav_path, model_id=VOXTRAL_MODEL,
)
inputs = inputs.to(VOXTRAL_DEVICE, dtype=torch.bfloat16)
with torch.no_grad():
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
# mehrminutige Aufnahmen abgeschnitten.
outputs = model.generate(**inputs, max_new_tokens=4096)
trimmed = outputs[:, inputs.input_ids.shape[1]:]
text = proc.batch_decode(trimmed, skip_special_tokens=True)
return (text[0] if text else "").strip()
finally:
if wav_path:
try:
os.unlink(wav_path)
except Exception:
pass
async def transcribe(self, audio_f32: np.ndarray, language: str) -> str:
loop = asyncio.get_running_loop()
async with self._lock:
return await loop.run_in_executor(None, self._transcribe_blocking, audio_f32, language)
@dataclass
class StreamSession:
request_id: str
@@ -126,28 +163,30 @@ class StreamSession:
last_chunk_at: float = field(default_factory=time.time)
last_partial: str = ""
last_growth_at: float = 0.0
last_transcribe_at: float = 0.0
last_voice_at: float = 0.0
noise_floor: float = 0.0
closed: bool = False
endpoint_sent: bool = False
# vLLM-Realtime-Session
vllm_ws: object = None
vllm_reader: object = None
# 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:
def __init__(self) -> None:
def __init__(self, runner: VoxtralRunner) -> None:
self.runner = runner
self._sessions: dict[str, StreamSession] = {}
self._ws = None # RVS
self._ws = None
def attach_ws(self, ws) -> None:
self._ws = ws
async def start_session(self, payload: dict) -> Optional[StreamSession]:
request_id = (payload.get("requestId") or "").strip()
if not request_id:
logger.warning("stt_stream_start ohne requestId — ignoriert")
return None
def start_session(self, payload: dict) -> None:
rid = (payload.get("requestId") or "").strip()
if not rid:
return
try:
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
except (TypeError, ValueError):
@@ -160,8 +199,8 @@ class SessionManager:
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
except (TypeError, ValueError):
voice_factor = STREAM_VOICE_FACTOR
sess = StreamSession(
request_id=request_id,
self._sessions[rid] = StreamSession(
request_id=rid,
audio_request_id=payload.get("audioRequestId", "") or "",
language=payload.get("language") or VOXTRAL_LANGUAGE,
endpoint_ms=endpoint_ms,
@@ -173,159 +212,44 @@ class SessionManager:
location=payload.get("location") or None,
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",
request_id[:8], sess.language, sess.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,
})
rid[:8], self._sessions[rid].language, endpoint_ms)
def feed_chunk(self, payload: dict) -> bool:
request_id = payload.get("requestId", "")
sess = self._sessions.get(request_id)
sess = self._sessions.get(payload.get("requestId", ""))
if sess is None or sess.closed:
return False
pcm_b64 = payload.get("pcm", "")
if not pcm_b64:
return True
try:
pcm = base64.b64decode(pcm_b64)
except Exception:
return True
sess.pcm_buffer.extend(pcm)
if pcm_b64:
try:
sess.pcm_buffer.extend(base64.b64decode(pcm_b64))
except Exception:
pass
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:
pass
def end_session(self, request_id: str) -> None:
sess = self._sessions.get(request_id)
if sess is not None:
sess.closed = True
def drop(self, request_id: str) -> None:
sess = self._sessions.pop(request_id, None)
if sess is not None:
self._teardown_vllm(sess)
self._sessions.pop(request_id, None)
def _teardown_vllm(self, sess: StreamSession) -> None:
try:
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:
# ── Endpointer (adaptiv, M0.1) ──
def _buffer_ms(self, sess: StreamSession) -> float:
samples = len(sess.pcm_buffer) // 2
return (samples / sess.sample_rate) * 1000.0 if samples else 0.0
def _tail_rms(self, sess: StreamSession) -> float:
win_bytes = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
if win_bytes <= 0:
win = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
if win <= 0:
return 0.0
tail = sess.pcm_buffer[-win_bytes:]
tail = sess.pcm_buffer[-win:]
if len(tail) < 2:
return 0.0
arr = pcm_s16le_to_float32(bytes(tail))
if arr.size == 0:
return 0.0
return float(np.sqrt(np.mean(arr * arr)))
return float(np.sqrt(np.mean(arr * arr))) if arr.size else 0.0
def _voice_threshold(self, sess: StreamSession) -> float:
nf = sess.noise_floor
@@ -342,8 +266,62 @@ class SessionManager:
else:
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
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:
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD)")
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
STREAM_TRANSCRIBE_INTERVAL_MS)
while True:
await asyncio.sleep(0.2)
now = time.time()
@@ -351,7 +329,7 @@ class SessionManager:
try:
await self._tick(sess, now)
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()):
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
logger.info("Stream %s: TTL — drop", sid[:8])
@@ -360,60 +338,65 @@ class SessionManager:
async def _tick(self, sess: StreamSession, now: float) -> None:
if sess.endpoint_sent:
return
elapsed_ms = (now - sess.started_at) * 1000.0
if elapsed_ms > sess.hard_cap_ms and not sess.closed:
await self._finalize(sess, reason="hardcap")
if (now - sess.started_at) * 1000.0 > sess.hard_cap_ms and not sess.closed:
await self._finalize(sess, "hardcap")
return
if sess.closed:
await self._finalize(sess, reason="stream_end")
await self._finalize(sess, "stream_end")
return
if self._buffer_duration_ms(sess) < STREAM_MIN_AUDIO_MS:
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
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)
if rms >= self._voice_threshold(sess):
sess.last_voice_at = now
else:
self._update_noise_floor(sess, rms)
# Endpoint: akustisch (Primaer) oder semantisch (Backstop), sobald Text da
if sess.last_growth_at > 0.0:
acoustic_silence_ms = (now - sess.last_voice_at) * 1000.0 if sess.last_voice_at > 0 else 0.0
semantic_silence_ms = (now - sess.last_growth_at) * 1000.0
acoustic_done = sess.last_voice_at > 0 and acoustic_silence_ms >= sess.endpoint_ms
semantic_done = semantic_silence_ms >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR
if acoustic_done or semantic_done:
await self._finalize(sess, reason="endpoint" if acoustic_done else "endpoint_semantic")
# 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 _finalize(self, sess: StreamSession, reason: str) -> None:
if sess.endpoint_sent:
return
sess.endpoint_sent = True
# vLLM ggf. committen, damit ein letztes transcription.done kommt.
if sess.vllm_ws is not None:
try:
await sess.vllm_ws.send(json.dumps({"type": VLLM_SEND_COMMIT}))
await asyncio.sleep(0.15) # kurz auf finalen Delta warten
except Exception:
pass
final_text = sess.last_partial.strip()
duration_s = self._buffer_duration_ms(sess) / 1000.0
logger.info("Stream %s: FINAL (reason=%s, %.1fs): %r",
sess.request_id[:8], reason, duration_s, final_text[:120])
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
t0 = time.time()
try:
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
except Exception:
logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
final_text = sess.last_partial
stt_ms = int((time.time() - t0) * 1000)
duration_s = audio.size / 16000.0
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
if self._ws is not None:
endpoint_payload = {
payload = {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": final_text,
"reason": reason,
"durationS": duration_s,
"sttMs": 0,
"sttMs": stt_ms,
"voice": sess.voice,
"speed": sess.speed,
"interrupted": sess.interrupted,
}
if sess.location:
endpoint_payload["location"] = sess.location
await _send(self._ws, "stt_endpoint", endpoint_payload)
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,
@@ -447,7 +430,6 @@ async def run_loop(sessions: SessionManager) -> None:
sessions.attach_ws(ws)
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
await _send(ws, "config_request", {"service": "voxtral"})
async for raw in ws:
try:
msg = json.loads(raw)
@@ -456,35 +438,73 @@ async def run_loop(sessions: SessionManager) -> None:
mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {}
if mtype == "stt_stream_start":
asyncio.create_task(sessions.start_session(payload))
sessions.start_session(payload)
elif mtype == "stt_audio_chunk":
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
# stt_request (Legacy One-Shot) macht Voxtral hier NICHT —
# dafuer bleibt die whisper-Bridge (Fallback).
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
except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
use_tls = False
tls_fallback_tried = True
logger.info("TLS-Fallback: versuche ws:// (kein TLS)")
continue
await asyncio.sleep(retry_s)
retry_s = min(retry_s * 2, 30)
use_tls = RVS_TLS # fuer den naechsten Zyklus zuruecksetzen
use_tls = RVS_TLS
async def main() -> None:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST/RVS_TOKEN fehlen — .env pruefen. Abbruch.")
return
sessions = SessionManager()
logger.info("Voxtral-Bridge startet — vLLM=%s Modell=%s", VOXTRAL_VLLM_URL, VOXTRAL_MODEL)
await asyncio.gather(
run_loop(sessions),
sessions.run_endpointer(),
)
runner = VoxtralRunner()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
sessions = SessionManager(runner)
logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
if __name__ == "__main__":
+9 -4
View File
@@ -1,5 +1,10 @@
# Voxtral-Bridge ist reine CPU-Glue (RVS-WS <-> vLLM-Realtime-WS). Das Modell
# selbst laeuft im separaten voxtral-vllm-Container (GPU). Deshalb hier KEIN
# torch/vllm — nur der WebSocket-Client + numpy fuer die RMS-Energiemessung.
websockets>=12.0
# Voxtral-3B via Transformers. torch/torchaudio kommen cu124-gepinnt aus dem
# Dockerfile (nicht hier, sonst zieht pip das Default-CUDA-Wheel).
transformers>=4.54
mistral-common[audio]>=1.8.1
accelerate>=0.30
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
soundfile>=0.12
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
numpy>=1.24
websockets>=12.0
+231
View File
@@ -0,0 +1,231 @@
"""
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 _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
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())
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
if len(raw) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
continue
try:
emb = embed(raw)
embeddings.append(emb)
durations.append(len(raw) / 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