Compare commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 10:48:01 +02:00
10 changed files with 345 additions and 400 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 20304
versionName "0.2.3.2" versionName "0.2.3.4"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.3.2", "version": "0.2.3.4",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+35 -4
View File
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload'; import 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 { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs } 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';
@@ -1668,7 +1679,9 @@ const ChatScreen: React.FC = () => {
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(), endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000, // Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current, 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 +1709,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') {
@@ -1771,7 +1791,8 @@ const ChatScreen: React.FC = () => {
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(), endpointMs: await loadSttEndpointMs(),
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) {
@@ -2255,7 +2276,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) {
@@ -2647,6 +2668,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}
+30 -61
View File
@@ -67,6 +67,10 @@ import {
CONV_WINDOW_MIN_SEC, CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC, CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY, CONV_WINDOW_STORAGE_KEY,
STT_ENDPOINT_DEFAULT_MS,
STT_ENDPOINT_MIN_MS,
STT_ENDPOINT_MAX_MS,
STT_ENDPOINT_STORAGE_KEY,
MAX_RECORDING_DEFAULT_SEC, MAX_RECORDING_DEFAULT_SEC,
MAX_RECORDING_MIN_SEC, MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC, MAX_RECORDING_MAX_SEC,
@@ -195,6 +199,9 @@ 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);
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override // null = automatisch (adaptive Baseline), sonst manueller dB-Override
@@ -306,6 +313,14 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseInt(saved, 10);
if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) {
setSttEndpointSec(n / 1000);
}
}
});
AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY).then(saved => { AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY).then(saved => {
if (saved != null) { if (saved != null) {
const n = parseFloat(saved); const n = parseFloat(saved);
@@ -1655,30 +1670,30 @@ const SettingsScreen: React.FC = () => {
<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>
@@ -1749,56 +1764,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
+14 -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,32 @@ 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:
- 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 .
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
+162 -231
View File
@@ -1,50 +1,41 @@
#!/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 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
logging.basicConfig( logging.basicConfig(
@@ -60,29 +51,18 @@ 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_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
@@ -91,21 +71,73 @@ STREAM_VOICE_RMS_MAX = 0.020
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():
outputs = model.generate(**inputs, max_new_tokens=512)
trimmed = outputs[:, inputs.input_ids.shape[1]:]
text = proc.batch_decode(trimmed, skip_special_tokens=True)
return (text[0] if text else "").strip()
finally:
if wav_path:
try:
os.unlink(wav_path)
except Exception:
pass
async def transcribe(self, audio_f32: np.ndarray, language: str) -> str:
loop = asyncio.get_running_loop()
async with self._lock:
return await loop.run_in_executor(None, self._transcribe_blocking, audio_f32, language)
@dataclass @dataclass
class StreamSession: class StreamSession:
request_id: str request_id: str
@@ -126,28 +158,26 @@ 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
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
vllm_ws: object = None
vllm_reader: object = None
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 +190,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,159 +203,44 @@ 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: sess.pcm_buffer.extend(base64.b64decode(pcm_b64))
pcm = base64.b64decode(pcm_b64) except Exception:
except Exception: pass
return True
sess.pcm_buffer.extend(pcm)
sess.last_chunk_at = time.time() 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 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: def end_session(self, request_id: str) -> None:
sess = self._sessions.get(request_id) sess = self._sessions.get(request_id)
if sess is not None: if sess is not None:
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
@@ -343,7 +258,8 @@ class SessionManager:
sess.noise_floor = 0.98 * nf + 0.02 * rms sess.noise_floor = 0.98 * nf + 0.02 * rms
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 +267,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,14 +276,13 @@ 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 # adaptive akustische Sprach-Aktivitaet
rms = self._tail_rms(sess) rms = self._tail_rms(sess)
@@ -375,45 +290,65 @@ class SessionManager:
sess.last_voice_at = now sess.last_voice_at = now
else: else:
self._update_noise_floor(sess, rms) self._update_noise_floor(sess, rms)
# Endpoint: akustisch (Primaer) oder semantisch (Backstop), sobald Text da # Endpoint-Entscheidung, sobald Text erkannt wurde
if sess.last_growth_at > 0.0: 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 ac_sil = (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 se_sil = (now - sess.last_growth_at) * 1000.0
acoustic_done = sess.last_voice_at > 0 and acoustic_silence_ms >= sess.endpoint_ms ac_done = sess.last_voice_at > 0 and ac_sil >= sess.endpoint_ms
semantic_done = semantic_silence_ms >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR se_done = se_sil >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR
if acoustic_done or semantic_done: if ac_done or se_done:
await self._finalize(sess, reason="endpoint" if acoustic_done else "endpoint_semantic") await self._finalize(sess, "endpoint" if ac_done else "endpoint_semantic")
return
# Partial-Transkription (throttled)
if (now - sess.last_transcribe_at) * 1000.0 < STREAM_TRANSCRIBE_INTERVAL_MS:
return
sess.last_transcribe_at = now
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
try:
text = (await self.runner.transcribe(audio, sess.language)).strip()
except Exception:
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
return
if text and text != sess.last_partial:
sess.last_partial = text
sess.last_growth_at = now
if self._ws is not None:
await _send(self._ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": text,
})
async def _finalize(self, sess: StreamSession, reason: str) -> None: 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. audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
if sess.vllm_ws is not None: t0 = time.time()
try: try:
await sess.vllm_ws.send(json.dumps({"type": VLLM_SEND_COMMIT})) final_text = (await self.runner.transcribe(audio, sess.language)).strip()
await asyncio.sleep(0.15) # kurz auf finalen Delta warten except Exception:
except Exception: logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
pass final_text = sess.last_partial
final_text = sess.last_partial.strip() stt_ms = int((time.time() - t0) * 1000)
duration_s = self._buffer_duration_ms(sess) / 1000.0 duration_s = audio.size / 16000.0
logger.info("Stream %s: FINAL (reason=%s, %.1fs): %r", logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
sess.request_id[:8], reason, duration_s, final_text[:120]) sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
if self._ws is not None: if self._ws is not None:
endpoint_payload = { 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 +382,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 +390,32 @@ 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 —
# dafuer bleibt die whisper-Bridge (Fallback).
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__":
+8 -4
View File
@@ -1,5 +1,9 @@
# 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
soundfile>=0.12
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
numpy>=1.24 numpy>=1.24
websockets>=12.0