Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:18:06 +02:00
15 changed files with 564 additions and 22 deletions
+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 20404 versionCode 20408
versionName "0.2.4.4" versionName "0.2.4.8"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026). // weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten. // Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 600L private const val STARTUP_SUPPRESSION_MS = 600L
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
} }
private val env: OrtEnvironment = OrtEnvironment.getEnvironment() private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0 private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L private var lastDetectionMs: Long = 0L
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
private var pcmRingPos = 0
private var pcmRingFilled = false
private val pcmRingLock = Any()
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster // Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L private var recordingStartedMs: Long = 0L
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
embBuffer.clear() embBuffer.clear()
consecutiveAboveThreshold = 0 consecutiveAboveThreshold = 0
lastDetectionMs = 0L lastDetectionMs = 0L
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
// in den Bestaetigungs-Schnipsel bluten.
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
}
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
private fun snapshotPreTrigger(): String? {
val out: ByteArray
synchronized(pcmRingLock) {
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
if (n <= 0) return null
out = ByteArray(n * 2)
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
for (i in 0 until n) {
val s = pcmRing[idx].toInt()
out[i * 2] = (s and 0xFF).toByte()
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
idx += 1
if (idx >= PCM_RING_SAMPLES) idx = 0
}
}
return try {
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
null
}
} }
private fun emitDetected() { private fun emitDetected() {
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)") Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
return return
} }
val preTriggerB64 = snapshotPreTrigger()
val params = com.facebook.react.bridge.Arguments.createMap().apply { val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName) putString("model", modelName)
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
} }
try { try {
reactApplicationContext reactApplicationContext
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
read += n read += n
} }
if (!running.get()) break if (!running.get()) break
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
synchronized(pcmRingLock) {
for (i in 0 until CHUNK_SAMPLES) {
pcmRing[pcmRingPos] = buf[i]
pcmRingPos += 1
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
}
}
try { try {
processChunk(buf) processChunk(buf)
} catch (e: Exception) { } catch (e: Exception) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.4.4", "version": "0.2.4.8",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+69 -8
View File
@@ -384,6 +384,14 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest // stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort. // es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true); const converseRef = useRef<boolean>(true);
// Passiv-Lausch-Fenster (Weiterreden nach ARIAs Antwort): Umgebungsgeraeusch
// (Musik/TV) darf das Fenster NICHT vorzeitig beenden. Bei einem no-speech-
// Endpoint (Silero verwirft Musik) wird — solange die Stille-Toleranz ab
// Fenster-Oeffnung noch laeuft — nochmal gelauscht statt sofort aufs Wake-Word
// zurueckzufallen. Start-Zeit + Re-Listen-Zaehler + Budget hier gemerkt.
const passiveListenStartRef = useRef<number>(0);
const passiveReListenCountRef = useRef<number>(0);
const passiveToleranceRef = useRef<number>(5000);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro). // Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false); const bargeInEnabledRef = useRef<boolean>(false);
@@ -1406,8 +1414,39 @@ const ChatScreen: React.FC = () => {
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden // ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter. // sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
converseRef.current = (message.payload as any).converse === true; converseRef.current = (message.payload as any).converse === true;
const _wakeOff = (message.payload as any).wake_off === true;
const _wakeOn = (message.payload as any).wake_on === true;
const _isSilent = (message.payload as any).speak === false; const _isSilent = (message.payload as any).speak === false;
if (_isSilent) { if (_wakeOn) {
// "Wake-Word an" per Text/Aufnahme-Button → Listener wieder starten
// (gleicher Weg wie toggleWakeWord-on). Geht auch wenn das Ohr taub war,
// weil der Befehl NICHT ueber "Computer" kam.
(async () => {
try {
const started = await wakeWordService.start();
setWakeWordActive(started);
console.log('[Chat] Wake-Word per Befehl AN gestartet:', started);
} catch (e) {
console.warn('[Chat] Wake-Word AN fehlgeschlagen:', e);
}
})();
} else if (_wakeOff) {
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
(async () => {
try {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('wake-off-voice');
} else {
await audioService.stopRecording();
}
} catch {}
try { await wakeWordService.stop(); } catch {}
setWakeWordActive(false);
console.log('[Chat] Wake-Word per Sprachbefehl AUS — Ohr-Button zum Wieder-Anmachen');
})();
} else if (_isSilent) {
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen. // Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations- // Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr. // Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
@@ -1659,8 +1698,12 @@ const ChatScreen: React.FC = () => {
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive). // Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch // converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word. // ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
// Im Hintergrund normalerweise direkt re-armen (kein Multi-Turn) — ABER
// wenn Hintergrund-Wake bewusst AN ist, will der User auch im Hintergrund
// ein Gespraech fuehren, also den Konversationsmodus offen halten.
const bg = AppState.currentState !== 'active'; const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {}); const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
}); });
return () => unsubPlayback(); return () => unsubPlayback();
}, []); }, []);
@@ -1760,12 +1803,24 @@ const ChatScreen: React.FC = () => {
!(m.audioRequestId === ev.audioRequestId !(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet')))); && m.text.includes('Spracheingabe wird verarbeitet'))));
} }
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (= // Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word. // mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.) // Fenster-Oeffnung noch laeuft, nochmal lauschen. So killt Umgebungs-
// musik das Weiterreden nicht: die Musik wird verworfen, das Fenster
// bleibt bis zur Toleranz offen, du kannst innerhalb reden. Erst wenn
// die Toleranz wirklich um ist (oder zu viele Runden) → aufs Wake-Word.
if (wakeWordService.getState() === 'listening') { if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word'); const elapsed = Date.now() - passiveListenStartRef.current;
wakeWordService.exitPassiveListening('timeout').catch(() => {}); const budget = passiveToleranceRef.current || 5000;
if (elapsed < budget && passiveReListenCountRef.current < 15) {
passiveReListenCountRef.current += 1;
console.log('[Chat] Passive-Listen: leeres Endpoint (%s) — Umgebung, re-listen (%dms/%dms, #%d)',
ev.reason, elapsed, budget, passiveReListenCountRef.current);
startPassiveStreamingRecording();
} else {
console.log('[Chat] Passive-Listen: Stille-Toleranz aufgebraucht (%dms) — Ende, zurueck aufs Wake-Word', elapsed);
wakeWordService.exitPassiveListening('timeout').catch(() => {});
}
} else { } else {
wakeWordService.endConversation(); wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false); if (!wakeWordService.isActive()) setWakeWordActive(false);
@@ -1777,8 +1832,14 @@ const ChatScreen: React.FC = () => {
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme // geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der // OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
// Whisper-Bridge filtert fremde Stimmen weg. // Whisper-Bridge filtert fremde Stimmen weg.
const unsubPassive = wakeWordService.onPassiveListen(() => { const unsubPassive = wakeWordService.onPassiveListen(async () => {
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme'); console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
// Fenster NEU geoeffnet (nach ARIAs Antwort): Budget-Uhr + Re-Listen-Zaehler
// zuruecksetzen. Re-Listen ruft startPassiveStreamingRecording direkt (nicht
// ueber diesen Callback), also bleibt der Startzeitpunkt erhalten.
passiveListenStartRef.current = Date.now();
passiveReListenCountRef.current = 0;
passiveToleranceRef.current = await loadSttEndpointMs();
startPassiveStreamingRecording(); startPassiveStreamingRecording();
}); });
+27
View File
@@ -114,6 +114,8 @@ import wakeWordService, {
saveWakeThreshold, saveWakeThreshold,
loadBgWakeEnabled, loadBgWakeEnabled,
saveBgWakeEnabled, saveBgWakeEnabled,
loadWakeConfirmEnabled,
saveWakeConfirmEnabled,
} from '../services/wakeword'; } from '../services/wakeword';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
@@ -216,6 +218,8 @@ const SettingsScreen: React.FC = () => {
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT); const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus. // Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
const [bgWake, setBgWake] = useState<boolean>(false); const [bgWake, setBgWake] = useState<boolean>(false);
// Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus.
const [wakeConfirm, setWakeConfirm] = useState<boolean>(false);
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -341,6 +345,7 @@ const SettingsScreen: React.FC = () => {
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {}); loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadBgWakeEnabled().then(setBgWake).catch(() => {}); loadBgWakeEnabled().then(setBgWake).catch(() => {});
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1921,6 +1926,28 @@ const SettingsScreen: React.FC = () => {
/> />
</View> </View>
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Wake-Wort per Voxtral bestätigen</Text>
<Text style={styles.toggleHint}>
Gegen Musik-Fehltrigger: nach {KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}"
prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur
Musik/TV) — erst dann Gong + Mikro. Kostet ~0,51 s Extra vor dem
Gong. Empfohlen zusammen mit Hintergrund-Zuhören.
</Text>
</View>
<Switch
value={wakeConfirm}
onValueChange={(val) => {
setWakeConfirm(val);
saveWakeConfirmEnabled(val).catch(() => {});
wakeWordService.setWakeConfirmEnabled(val);
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text> <Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
+33
View File
@@ -171,6 +171,39 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
} catch {} } catch {}
} }
/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via
* Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und
* wartet auf stt_transcribe_result (matching requestId) mit Timeout.
* Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler →
* Aufrufer macht dann fail-open (Wake normal durchlassen). */
export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise<string | null> {
if (!pcmBase64) return null;
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string | null>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(null), timeoutMs);
function finish(val: string | null) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_transcribe_result') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.text === 'string' ? p.text : '');
});
rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' });
} catch {
finish(null);
}
});
}
export async function loadSttEndpointMs(): Promise<number> { export async function loadSttEndpointMs(): Promise<number> {
try { try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY); const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
+92 -6
View File
@@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
} catch {} } catch {}
} }
// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio
// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?").
// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro
// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im
// Event (neueres APK) — ohne das macht die App normal weiter.
export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled';
export async function loadWakeConfirmEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveWakeConfirmEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_CONFIRM_STORAGE_KEY, String(enabled));
} catch {}
}
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in /** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes * android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut * Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -159,6 +180,9 @@ class WakeWordService {
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und * Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */ * bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
private bgWakeEnabled: boolean = false; private bgWakeEnabled: boolean = false;
/** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false.
* Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */
private wakeConfirmEnabled: boolean = false;
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere /** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS * WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite * resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
@@ -241,15 +265,19 @@ class WakeWordService {
try { try {
const threshold = await loadWakeThreshold(); const threshold = await loadWakeThreshold();
this.bgWakeEnabled = await loadBgWakeEnabled(); this.bgWakeEnabled = await loadBgWakeEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled); this.wakeConfirmEnabled = await loadWakeConfirmEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s',
threshold, this.bgWakeEnabled, this.wakeConfirmEnabled);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS); await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal // Subscribe nur einmal
if (!this.eventSub) { if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord); const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
this.eventSub = emitter.addListener('WakeWordDetected', () => { this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => {
console.log('[WakeWord] Native Detection-Event empfangen'); console.log('[WakeWord] Native Detection-Event empfangen');
this.onWakeDetected().catch(err => // payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
console.warn('[WakeWord] onWakeDetected crashed:', err)); // nur in neueren APKs vorhanden; ohne = fail-open (kein Verify).
this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null)
.catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err));
}); });
} }
this.nativeReady = true; this.nativeReady = true;
@@ -348,6 +376,18 @@ class WakeWordService {
console.log('[WakeWord] Hintergrund-Wake = %s', enabled); console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
} }
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
setWakeConfirmEnabled(enabled: boolean): void {
this.wakeConfirmEnabled = enabled;
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
}
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
isBgWakeEnabled(): boolean {
return this.bgWakeEnabled;
}
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown /** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume. * als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem * 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
@@ -358,8 +398,10 @@ class WakeWordService {
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv'); console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
} }
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
private async onWakeDetected(): Promise<void> { * preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
if (this.inBackground && !this.bgWakeEnabled) { if (this.inBackground && !this.bgWakeEnabled) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)'); console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{}); import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
@@ -405,6 +447,22 @@ class WakeWordService {
// Kein erneutes setState — wir bleiben in 'conversing'. // Kein erneutes setState — wir bleiben in 'conversing'.
return; return;
} }
// Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel
// von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen
// (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail-
// open: ohne PCM / bei Timeout/Fehler laeuft es normal durch.
if (this.wakeConfirmEnabled && preTriggerPcm) {
const confirmed = await this.confirmWake(preTriggerPcm);
if (!confirmed) {
this.detectionInProgress = false;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (e) {
console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e);
}
}
return;
}
}
this.setState('conversing'); this.setState('conversing');
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt // Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang // Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
@@ -418,6 +476,34 @@ class WakeWordService {
}); });
} }
/** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt
* (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */
private async confirmWake(pcm: string): Promise<boolean> {
try {
const audio = await import('./audio');
const text = await audio.transcribeBlob(pcm);
if (text === null) {
console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)');
return true;
}
const norm = text.toLowerCase();
// Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern,
// taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen).
const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ')
.split(/\s+/).filter(w => w.length >= 4);
if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar
const ok = kwWords.some(w => norm.includes(w));
console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s',
text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)');
import('./logger').then(m => m.reportAppDebug('wake.confirm',
`text="${text.slice(0, 40)}" kw=${kwWords.join('|')}${ok ? 'ok' : 'reject'}`)).catch(() => {});
return ok;
} catch (e) {
console.warn('[WakeWord] confirmWake err → fail-open:', e);
return true;
}
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann /** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im * "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert. * Native-Modul verhindert dass ARIAs eigene Stimme triggert.
+121 -1
View File
@@ -132,6 +132,34 @@ WEB_SEARCH_TOOL = {
# Meta-Tool: ARIA kann selbst neue Skills bauen # Meta-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [ META_TOOLS = [
{
"type": "function",
"function": {
"name": "ear_control",
"description": (
"Schaltet dein Ohr (den Wake-Word-Listener) an oder aus. Nutze das, "
"wenn Stefan will dass du aufhoerst zuzuhoeren — EGAL wie er es "
"formuliert: 'leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute "
"Nacht', 'mach mal Pause vom Zuhoeren', 'ich geh ins Bett, du kannst "
"aus' → action='off'. Wenn er dich wieder aktivieren will ('Ohr an', "
"'wach auf', 'hoer wieder zu') → action='on'. Reiner Steuerbefehl: "
"die App stoppt/startet den Listener, du bestaetigst nur kurz (wird "
"nicht vorgelesen). Nach 'off' geht Wieder-An per 'Ohr an' (Text/"
"Aufnahme-Knopf) oder App-Button."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["off", "on"],
"description": "off = Ohr aus (nicht mehr zuhoeren), on = Ohr wieder an",
},
},
"required": ["action"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -1442,6 +1470,59 @@ def _user_wants_conversation_continue(text: str) -> bool:
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text))) return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
# ── Wake-Word AUS per Sprachbefehl ──────────────────────────────────────────
# "Wake-Word aus", "mach das Ohr aus", "hoer auf zuzuhoeren", "geh schlafen" …
# → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte Ruhe).
# Wieder-An geht nur ueber den App-Button (bewusst, weil dann taub). Reiner
# Steuerbefehl: still (speak=false), kein Weiterlauschen (converse=false).
_WAKE_NOUN = r"(?:wake[\s-]?word|wakeword|ohr(?:en)?|mikro(?:fon)?|zuh[oö]r\w*|lausch\w*)"
_WAKE_OFF_VERB = (r"(?:aus(?:schalten|stellen)?|abschalten|abstellen|deaktivier\w*|"
r"beenden|beende|stopp?\w*|schlafen|ruhe)")
_WAKE_OFF_RE = re.compile(
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_OFF_VERB}\b"
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
# Schlaf-Idiome NUR als klare Imperative an ARIA ('geh/leg dich schlafen',
# 'leg dich aufs Ohr/hin'). Mehrdeutige Gruesse ('gute Nacht', 'schlaf gut')
# bewusst NICHT — das sind Verabschiedungen, kein Ohr-Aus-Befehl; die faengt
# ARIA per ear_control aus dem Kontext ab, wenn's wirklich gemeint ist.
rf"|(?:geh|leg\s+dich)\s+schlafen"
rf"|leg\s+dich\s+(?:aufs?\s+ohr|hin)",
re.IGNORECASE,
)
def _user_wants_wake_off(text: str) -> bool:
"""True, wenn der User den Wake-Word-Listener per Sprache abschalten will."""
if not text:
return False
return bool(_WAKE_OFF_RE.search(_strip_leading_hint_blocks(text)))
# ── Wake-Word AN per Befehl (Text ODER manueller Aufnahme-Button) ────────────
# Gegenstueck zu wake_off. Das "Wieder-An" per Stimme geht NICHT ueber "Computer"
# (das hoert ja nicht mehr), aber ueber eine Text-Nachricht oder den manuellen
# Aufnahme-Button — beide laufen unabhaengig vom Wake-Word-Listener. Die App
# startet den Listener dann wieder (wakeWordService.start()).
_WAKE_ON_VERB = r"(?:an(?:schalten|machen|stellen)?|einschalten|aktivier\w*|starte\w*|reaktivier\w*)"
_WAKE_ON_RE = re.compile(
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_ON_VERB}\b"
rf"|\b(?:aktivier\w*|reaktivier\w*|starte\w*)\b[^.!?]{{0,15}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+(?:mir\s+)?wieder\s+zu"
rf"|(?:wieder|erneut)\s+(?:zu\s*)?(?:h[oö]r|lausch)\w*"
rf"|wach\s+auf|aufwachen",
re.IGNORECASE,
)
def _user_wants_wake_on(text: str) -> bool:
"""True, wenn der User den Wake-Word-Listener per Befehl wieder anschalten will."""
if not text:
return False
return bool(_WAKE_ON_RE.search(_strip_leading_hint_blocks(text)))
def _normalize_for_fast_match(text: str) -> str: def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower() norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm) norm = _fold_umlauts(norm)
@@ -1865,6 +1946,27 @@ class Agent:
active_project_id = (project_id or "").strip() active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Wake-Word AUS per Sprache: reiner Steuerbefehl, KEIN Claude/Fast-Path
# noetig. Still (speak=false) + kein Weiterlauschen (converse=false). Das
# tatsaechliche Stoppen des Listeners macht die App anhand von wake_off in
# der Antwort (ChatOut) — hier signalisieren wir es nur. Wieder-An: App-Button.
if _user_wants_wake_off(user_message):
reply = "Ohr aus. Sag 'Wake-Word an' (per Text oder Aufnahme-Knopf) oder tipp den Ohr-Button, wenn ich wieder lauschen soll. 🔇"
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
return reply, "wake-off", False, False, False
# Wake-Word AN per Befehl (Text/Aufnahme-Button): App startet den Listener.
if _user_wants_wake_on(user_message):
reply = "Ohr wieder an — ich lausche auf 'Computer'. 👂"
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-on] Befehl erkannt — App startet Listener")
return reply, "wake-on", False, False, False
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett. # Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain # Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz. # iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
@@ -2063,6 +2165,7 @@ class Agent:
# Konversation bleiben gesprochen. # Konversation bleiben gesprochen.
self._claude_turn_speak = True self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
try: try:
for iteration in range(self.MAX_TOOL_ITERATIONS): for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools, result = self.proxy.chat_full(messages, tools=tools,
@@ -2176,8 +2279,16 @@ class Agent:
converse = False converse = False
elif _wants_continue: elif _wants_continue:
converse = True converse = True
# ear_control-Tool aufgerufen → Ohr schalten. answered_by=wake-off/wake-on
# nutzt die bestehende Plumbing (main.py → wake_off/wake_on → Bridge → App).
# Reiner Steuerbefehl: still + kein Weiterlauschen.
answered_by = "claude"
if self._ear_control == "off":
answered_by, speak, converse = "wake-off", False, False
elif self._ear_control == "on":
answered_by, speak, converse = "wake-on", False, False
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert). # awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude", speak, converse, awaiting_reply) return (final_reply, answered_by, speak, converse, awaiting_reply)
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -2772,6 +2883,15 @@ class Agent:
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als " f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline." f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
) )
if name == "ear_control":
action = (arguments.get("action") or "").strip().lower()
if action not in ("off", "on"):
return "FEHLER: action muss 'off' oder 'on' sein."
self._ear_control = action
logger.info("[ear_control] action=%s — App schaltet den Listener", action)
return ("Ohr wird ausgeschaltet — Wieder-An per 'Ohr an' (Text/"
"Aufnahme-Knopf) oder App-Button." if action == "off"
else "Ohr wird wieder eingeschaltet.")
if name == "memory_search": if name == "memory_search":
query = (arguments.get("query") or "").strip() query = (arguments.get("query") or "").strip()
if not query: if not query:
+7
View File
@@ -676,6 +676,11 @@ class ChatOut(BaseModel):
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die # Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen. # naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False awaiting_reply: bool = False
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
# Wake-Word-Listener komplett (Mikro frei).
wake_off: bool = False
# "Wake-Word an" per Befehl (Text/Aufnahme-Button) → App startet den Listener.
wake_on: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -785,6 +790,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
speak=speak, speak=speak,
converse=converse, converse=converse,
awaiting_reply=awaiting_reply, awaiting_reply=awaiting_reply,
wake_off=(answered_by == "wake-off"),
wake_on=(answered_by == "wake-on"),
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+7
View File
@@ -199,6 +199,13 @@ def build_voice_flow_section() -> str:
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche " "- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').", "danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.", "- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
"",
"**Ohr aus/an:** Wenn Stefan will dass du aufhoerst zuzuhoeren — egal wie "
"formuliert ('leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute Nacht', "
"'mach Pause vom Zuhoeren') — ruf das Tool `ear_control(action='off')`. "
"Wieder aktivieren ('Ohr an', 'wach auf', 'hoer wieder zu') → "
"`ear_control(action='on')`. Die App stoppt/startet dann den Listener; du "
"bestaetigst nur kurz.",
]) ])
+12 -1
View File
@@ -1622,6 +1622,11 @@ class ARIABridge:
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf # die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen. # DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False, "awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
# User hat "Wake-Word aus" gesagt → App stoppt den Listener komplett
# (Mikro frei).
"wake_off": bool(payload.get("wake_off", False)) if isinstance(payload, dict) else False,
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener.
"wake_on": bool(payload.get("wake_on", False)) if isinstance(payload, dict) else False,
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -2017,6 +2022,10 @@ class ARIABridge:
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die # Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter. # Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
awaiting_reply = bool(data.get("awaiting_reply", False)) awaiting_reply = bool(data.get("awaiting_reply", False))
# User hat per Sprache "Wake-Word aus" gesagt → App stoppt den Listener.
wake_off = bool(data.get("wake_off", False))
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener wieder.
wake_on = bool(data.get("wake_on", False))
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
@@ -2108,7 +2117,9 @@ class ARIABridge:
"answeredBy": answered_by, "answeredBy": answered_by,
"speak": speak, "speak": speak,
"converse": converse, "converse": converse,
"awaiting_reply": awaiting_reply}) "awaiting_reply": awaiting_reply,
"wake_off": wake_off,
"wake_on": wake_on})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)
+3 -2
View File
@@ -2299,9 +2299,10 @@
} }
function sendDiagAttachments() { function sendDiagAttachments() {
// Alle pending Dateien an RVS senden // Alle pending Dateien an RVS senden — projectId des aktuellen Focus
// mitschicken, sonst landet die Datei-Bubble im Hauptchat statt im Projekt.
for (const f of diagPendingFiles) { for (const f of diagPendingFiles) {
send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64 }); send({ action: 'send_file', name: f.name, type: f.type, size: f.size, base64: f.base64, projectId: focusedContextId });
} }
if (diagPendingFiles.length > 0) { if (diagPendingFiles.length > 0) {
addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei'); addChat('sent', `${diagPendingFiles.length} Anhang/Anhaenge`, 'Datei');
+1 -1
View File
@@ -2568,7 +2568,7 @@ wss.on("connection", (ws) => {
// Datei von Diagnostic an Bridge via RVS senden // Datei von Diagnostic an Bridge via RVS senden
sendToRVS_raw({ sendToRVS_raw({
type: "file", type: "file",
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 }, payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64, projectId: msg.projectId || "" },
timestamp: Date.now(), timestamp: Date.now(),
}); });
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`); log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
+136
View File
@@ -112,6 +112,60 @@ def _collapse_repetitions(text: str) -> str:
out = new out = new
return out.strip() return out.strip()
# ── Silero VAD: echte Sprach-Erkennung VOR dem Transkribieren ──────────────
# Der Muster-Filter oben kennt nur spezifische Artefakte. Generische Phantome
# ("Ich bin ein guter Mann" aus Fast-Stille) kann ein Text-Regex nicht fangen —
# aber ein VAD schon, weil es am AUDIO entscheidet, nicht am Text. Silero trennt
# Sprache zuverlaessig von Stille / Rauschen / MUSIK. Kein Speech-Segment →
# no-speech → nicht transkribieren → kein Phantom (und Musik/Instrumental fliegt
# gleich mit raus).
# FAIL-OPEN: klappt das VAD nicht (Import/Load/Inferenz), wird trotzdem normal
# transkribiert. Die STT darf NIE komplett sterben (Speaker-ID-Lektion).
SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "true", "yes")
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
# Speech-Endpoint gegen laute Umgebungsmusik: der RMS-Stille-Endpoint feuert bei
# durchgehender Musik NIE (Energie bleibt oben). Deshalb waehrend lauter Phasen
# periodisch (alle X ms) mit Silero pruefen, ob im letzten endpoint_ms-Fenster
# ueberhaupt noch Sprache ist — wenn nicht (nur Musik/Stille), Turn beenden.
STREAM_SPEECH_ENDPOINT_CHECK_MS = int(os.getenv("STREAM_SPEECH_ENDPOINT_CHECK_MS", "700"))
_vad_state = {"model": None, "get_ts": None, "failed": False}
def _speech_segments(audio_f32):
"""Silero-VAD-Sprachsegmente (Liste von {start,end} Sample-Indizes) im
16kHz-float32-Audio. Rueckgabe:
[] kein Speech (Stille/Rauschen/Musik) Phantom-Verdacht, verwerfen.
[...] Speech vorhanden.
None VAD nicht verfuegbar fail-open (Aufrufer transkribiert normal)."""
if not SILERO_VAD_ENABLED or _vad_state["failed"]:
return None
if _vad_state["model"] is None:
try:
from silero_vad import load_silero_vad, get_speech_timestamps
_vad_state["model"] = load_silero_vad()
_vad_state["get_ts"] = get_speech_timestamps
logger.info("Silero VAD geladen (threshold=%.2f, min_speech=%dms)",
SILERO_VAD_THRESHOLD, SILERO_MIN_SPEECH_MS)
except Exception:
logger.exception("Silero VAD Laden fehlgeschlagen — dauerhaft aus (fail-open)")
_vad_state["failed"] = True
return None
try:
import torch as _torch
segs = _vad_state["get_ts"](
_torch.from_numpy(audio_f32), _vad_state["model"],
sampling_rate=16000, threshold=SILERO_VAD_THRESHOLD,
min_speech_duration_ms=SILERO_MIN_SPEECH_MS,
)
return segs or []
except Exception:
logger.exception("Silero VAD Inferenz fehlgeschlagen — dieser Turn fail-open")
return None
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"- # Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter # Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config- # Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
@@ -232,6 +286,7 @@ class StreamSession:
last_growth_at: float = 0.0 last_growth_at: float = 0.0
last_transcribe_at: float = 0.0 last_transcribe_at: float = 0.0
last_voice_at: float = 0.0 last_voice_at: float = 0.0
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
noise_floor: float = 0.0 noise_floor: float = 0.0
closed: bool = False closed: bool = False
endpoint_sent: bool = False endpoint_sent: bool = False
@@ -457,6 +512,26 @@ class SessionManager:
"audioRequestId": sess.audio_request_id, "audioRequestId": sess.audio_request_id,
"text": "", "text": "",
}) })
# Speech-Endpoint gegen laute Umgebungsmusik: es ist gerade laut (rms
# ueber Schwelle) — aber ist es Sprache oder Musik? Der RMS-Endpoint
# unten wuerde bei Musik NIE feuern (last_voice_at bleibt frisch).
# Deshalb gedrosselt mit Silero das letzte endpoint_ms-Fenster pruefen:
# KEINE Sprache drin (nur Musik) → Turn ist zu Ende. Real gesprochene
# Turns haben Sprache im Fenster → laufen weiter.
if (self._buffer_ms(sess) >= sess.endpoint_ms
and (now - sess.last_speech_check_at) * 1000.0 >= STREAM_SPEECH_ENDPOINT_CHECK_MS):
sess.last_speech_check_at = now
try:
tail_bytes = int(sess.endpoint_ms / 1000.0 * sess.sample_rate) * 2
tail = pcm_s16le_to_float32(bytes(sess.pcm_buffer[-tail_bytes:]))
segs = _speech_segments(tail)
if segs is not None and len(segs) == 0:
logger.info("Stream %s: Speech-Endpoint (keine Sprache im letzten %dms — Musik/Stille) → finalize",
sess.request_id[:8], sess.endpoint_ms)
await self._finalize(sess, "endpoint")
return
except Exception:
logger.exception("Speech-Endpoint-Check fehlgeschlagen — ignoriert")
else: else:
self._update_noise_floor(sess, rms) self._update_noise_floor(sess, rms)
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt # No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
@@ -474,6 +549,21 @@ class SessionManager:
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms: if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
await self._finalize(sess, "endpoint") await self._finalize(sess, "endpoint")
async def _emit_no_speech(self, sess: "StreamSession", reason_label: str) -> None:
"""Leeres no-speech-Endpoint senden + Session droppen (kein Transkript).
App re-armt still, zurueck aufs Wake-Word."""
if self._ws is not None:
payload = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": reason_label,
"durationS": 0.0, "sttMs": 0}
await _send(self._ws, "stt_endpoint", payload)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": reason_label})
self.drop(sess.request_id)
async def _finalize(self, sess: StreamSession, reason: str) -> None: async def _finalize(self, sess: StreamSession, reason: str) -> None:
if sess.endpoint_sent: if sess.endpoint_sent:
return return
@@ -508,6 +598,28 @@ class SessionManager:
self.drop(sess.request_id) self.drop(sess.request_id)
return return
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer)) audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
# Silero VAD: ist ueberhaupt echte Sprache im Audio? Das entscheidet am
# AUDIO, nicht am Text — faengt also generische Phantome ("Ich bin ein
# guter Mann") UND Musik/Rauschen, die der Muster-Filter nicht kennt.
# Kein Speech-Segment → no-speech, gar nicht erst transkribieren.
# fail-open: segs=None (VAD nicht verfuegbar) → normal weiter.
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
logger.info("Stream %s: Silero VAD — keine Sprache (%.1fs, reason=%s) → no-speech",
sess.request_id[:8], audio.size / 16000.0, reason)
await self._emit_no_speech(sess, f"vad_no_speech:{reason}")
return
if segs:
# Auf die Sprach-Spanne trimmen (Stille-Raender weg → Voxtral
# halluziniert an den Enden weniger). Kleiner Pad gegen abgeschnittene
# leise Wort-Anfaenge/-Enden.
pad = int(SILERO_PAD_MS / 1000.0 * 16000)
s0 = max(0, segs[0]["start"] - pad)
s1 = min(int(audio.size), segs[-1]["end"] + pad)
if s1 > s0 and (s1 - s0) < audio.size:
audio = audio[s0:s1]
t0 = time.time() t0 = time.time()
try: try:
final_text = (await self.runner.transcribe(audio, sess.language)).strip() final_text = (await self.runner.transcribe(audio, sess.language)).strip()
@@ -614,6 +726,30 @@ async def run_loop(sessions: SessionManager) -> None:
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", ""))
elif mtype == "stt_transcribe_blob":
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
# den Vor-Trigger-Audio, wir sagen was gesagt wurde, die App
# prueft ob "Computer" drin ist. Silero vorgeschaltet:
# Musik/Rauschen → leerer Text (nicht bestaetigt).
req_id = payload.get("requestId", "")
try:
pcm = base64.b64decode(payload.get("pcm", ""))
audio = pcm_s16le_to_float32(pcm)
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
text = ""
else:
text = (await sessions.runner.transcribe(
audio, payload.get("language", "de"))).strip()
logger.info("stt_transcribe_blob (%.1fs) → %r",
audio.size / 16000.0, text[:60])
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": text})
except Exception as exc:
logger.warning("stt_transcribe_blob fehlgeschlagen: %s", exc)
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": "", "error": str(exc)[:200]})
elif mtype == "voice_id_status_request": elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "") req_id = payload.get("requestId", "")
try: try:
+1
View File
@@ -4,6 +4,7 @@ transformers>=4.54
mistral-common[audio]>=1.8.1 mistral-common[audio]>=1.8.1
accelerate>=0.30 accelerate>=0.30
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
silero-vad>=5.1 # neuronales VAD: echte Sprache vs Stille/Rauschen/Musik
soundfile>=0.12 soundfile>=0.12
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
numpy>=1.24 numpy>=1.24