Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:02:44 +02:00
18 changed files with 835 additions and 78 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20304
versionName "0.2.3.4"
versionCode 20309
versionName "0.2.3.9"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -59,7 +59,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 1500L
private const val STARTUP_SUPPRESSION_MS = 600L
}
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.3.4",
"version": "0.2.3.9",
"private": true,
"scripts": {
"android": "react-native run-android",
+77 -15
View File
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs } from '../services/audio';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@@ -384,6 +384,8 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false);
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
@@ -662,6 +664,7 @@ const ChatScreen: React.FC = () => {
const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed();
bargeInEnabledRef.current = await loadBargeInEnabled();
const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints');
@@ -1398,13 +1401,30 @@ const ChatScreen: React.FC = () => {
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen — onPlaybackFinished liest converseRef. Default true.
converseRef.current = (message.payload as any).converse !== false;
// Passiv-Lauschen (30s) NUR wenn das Brain explizit converse:true schickt.
// Vorher default true → jeder Befehl (auch "Spiele Spotify" mit gesproche-
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
converseRef.current = (message.payload as any).converse === true;
const _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
// kein 30s-Fenster (skipPassive=true).
wakeWordService.endConversation(true).catch(() => {});
if (_isSilent) {
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
if (converseRef.current) {
// Befehlskette laeuft WEITER ([[WEITER]]): Mikro NICHT schliessen,
// sondern das passive Lausch-Fenster oeffnen (endConversation(false)),
// damit der naechste Kettenbefehl direkt gesprochen werden kann. ARIA
// haelt bewusst offen, bis sie [[ENDE]] (converse=false) schickt.
if (wakeWordService.isConversing()) {
wakeWordService.endConversation(false).catch(() => {});
}
} else {
// Einzelbefehl / [[ENDE]] → ARIA "drueckt selbst Stop": jede offene
// Aufnahme schliessen + zurueck aufs Wake-Word, egal in welchem Zustand
// (conversing, passives Lauschen ODER offene Streaming-Aufnahme).
ariaStopRecording('silent-command').catch(() => {});
}
}
}
@@ -1810,7 +1830,9 @@ const ChatScreen: React.FC = () => {
// Prozess nicht killt wenn die App im Hintergrund ist.
const unsubTtsStart = audioService.onPlaybackStarted(() => {
acquireBackgroundAudio('tts').catch(() => {});
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
// Barge-Listening (Mikro waehrend TTS) NUR im Barge-in-Modus. Default aus =
// Halb-Duplex: ARIA spricht ungestoert zu Ende, dann erst geht das Mikro auf.
if (bargeInEnabledRef.current && wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
wakeWordService.startBargeListening().catch(() => {});
}
});
@@ -2231,15 +2253,16 @@ const ChatScreen: React.FC = () => {
advanceQueue(pid);
}, [advanceQueue]);
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
// Nimmt der User das Mikro waehrend ARIA SPRICHT, ist das ein echter Interrupt:
// TTS stoppen UND die laufende Brain-Antwort abbrechen (cancel_request). Sonst
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
const interruptAriaIfBusy = useCallback(() => {
if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
return true;
}
return false;
}, []);
@@ -2288,11 +2311,50 @@ const ChatScreen: React.FC = () => {
return true;
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
// Befehl ist ausgefuehrt, ARIA hat die Rueckinfo (Skill-Ergebnis) und antwortet
// NICHT vorgelesen. Weil ohne TTS kein onPlaybackFinished kommt, muss der
// Aufnahme-/Konversations-Zustand hier aktiv aufgeraeumt werden, sonst bleibt
// das Ohr haengen bzw. das Aufnahme-Fenster laeuft leer weiter (Stefans
// Reproduktion: "spotify play" und das Mikro wartet trotzdem 30s).
// Unterschied zum manuellen Stop: der verwirft NICHT, sondern finalisiert die
// Aufnahme (User will seinen Satz verarbeitet haben) — hier ist der Befehl
// schon durch, ein evtl. offenes Folge-Fenster wird verworfen.
const ariaStopRecording = useCallback(async (reason: string): Promise<void> => {
converseRef.current = false;
// 1) Passiv-Lauschen: sauber beenden (cancelt den Stream selbst, startet
// KEINE neue passive Aufnahme).
if (wakeWordService.getState() === 'listening') {
await wakeWordService.exitPassiveListening('manual').catch(() => {});
return;
}
// 2) Noch offene Streaming-Aufnahme (aktiv / Barge-In) verwerfen.
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording(reason).catch(() => {});
}
// 3) Konversation beenden → zurueck aufs Wake-Word (skipPassive: kein 30s-Fenster).
if (wakeWordService.isConversing()) {
await wakeWordService.endConversation(true).catch(() => {});
} else if (!wakeWordService.isActive()) {
setWakeWordActive(false);
}
}, []);
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
// Manueller Stop = endgueltig: auch die NACH der Antwort kommende
// onPlaybackFinished darf kein 30s-Passiv-Fenster mehr oeffnen.
converseRef.current = false;
// Stop = ALLES beenden, vorhersehbar. Spricht ARIA gerade, hart stoppen +
// laufende Brain-Antwort abbrechen (sonst "sagt sie ihren letzten Satz").
if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user stop');
rvs.send('cancel_request' as any, { hard: true, source: 'voice-stop' });
}
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
+23 -1
View File
@@ -75,6 +75,8 @@ import {
MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY,
loadBargeInEnabled,
saveBargeInEnabled,
VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX,
@@ -204,6 +206,8 @@ const SettingsScreen: React.FC = () => {
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
const [bargeIn, setBargeIn] = useState<boolean>(false);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false);
@@ -329,6 +333,7 @@ const SettingsScreen: React.FC = () => {
}
}
});
loadBargeInEnabled().then(setBargeIn).catch(() => {});
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') {
const n = parseFloat(saved);
@@ -1666,7 +1671,24 @@ const SettingsScreen: React.FC = () => {
{currentSection === 'voice_input' && (<>
<Text style={styles.sectionTitle}>Spracheingabe</Text>
<View style={styles.card}>
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Barge-in (unterbrechen)</Text>
<Text style={styles.toggleHint}>
AUS (empfohlen): ARIA spricht ihre Antwort ZU ENDE, dann geht das
Mikro auf — sauber, kein Selbst-Echo, du hoerst sie ganz. AN: du
kannst sie waehrend des Sprechens per Wake-Wort unterbrechen.
</Text>
</View>
<Switch
value={bargeIn}
onValueChange={(v) => { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={bargeIn ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Stille-Toleranz</Text>
<Text style={styles.toggleHint}>
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
+23 -4
View File
@@ -152,14 +152,33 @@ export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die
// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv;
// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings.
// im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
// unter "Stille-Toleranz" konfigurierbar.
export const STT_ENDPOINT_DEFAULT_MS = 2400;
export const STT_ENDPOINT_MIN_MS = 1000;
export const STT_ENDPOINT_MAX_MS = 4000;
export const STT_ENDPOINT_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
// Barge-in-Modus: darf man ARIA waehrend ihrer TTS-Antwort unterbrechen (reden)?
// Default AUS = sauberes Halb-Duplex (ARIA spricht aus, DANN oeffnet das Mikro —
// kein Selbst-Echo, kein Mischmasch). AN = waehrend TTS auf Wake-Wort lauschen.
export const BARGE_IN_STORAGE_KEY = 'aria_barge_in_enabled';
export async function loadBargeInEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(BARGE_IN_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BARGE_IN_STORAGE_KEY, String(enabled));
} catch {}
}
export async function loadSttEndpointMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
+14 -11
View File
@@ -54,10 +54,10 @@ export async function savePassiveListenMs(ms: number): Promise<void> {
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher,
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1.
export const WAKE_THRESHOLD_DEFAULT = 0.6;
// weniger Fehlauslösung, aber man muss deutlicher/lauter sprechen (fuehlt sich
// "traege" an). Fehlausloeser werden ueber Speaker-ID (E3) ohnehin verworfen,
// deshalb darf der Default empfindlicher sein. 0.45 (war 0.6/0.5). 0..1.
export const WAKE_THRESHOLD_DEFAULT = 0.45;
export const WAKE_THRESHOLD_MIN = 0.3;
export const WAKE_THRESHOLD_MAX = 0.9;
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
@@ -103,7 +103,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
const DEFAULT_PATIENCE = 2;
// patience=1 statt 2: nur EIN Frame ueber Threshold noetig → deutlich schneller.
// Speaker-ID filtert Fehlausloeser, also ist das vertretbar.
const DEFAULT_PATIENCE = 1;
const DEFAULT_DEBOUNCE_MS = 1500;
interface OpenWakeWordModule {
@@ -310,7 +312,7 @@ class WakeWordService {
/** Cooldown setzen alle Wake-Word-Detections in den naechsten ms ignorieren.
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
* die openWakeWord faelschlich als Trigger interpretiert. */
setResumeCooldown(ms: number = 1500): void {
setResumeCooldown(ms: number = 500): void {
this.cooldownUntilMs = Date.now() + ms;
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
}
@@ -323,13 +325,14 @@ class WakeWordService {
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
}
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
* 1s statt 3s 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
* App-Oeffnen wurden verschluckt). */
setForeground(): void {
this.inBackground = false;
this.cooldownUntilMs = Date.now() + 3000;
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
this.cooldownUntilMs = Date.now() + 1000;
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
}
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
+97 -5
View File
@@ -1347,6 +1347,77 @@ def _extract_await_marker(text: str) -> tuple:
return text, False
# ── Sprach-/Gespraechs-Steuermarker (ARIA deklariert die Phase SELBST) ──
#
# Voice-First: ARIA erkennt aus dem Text, ob Stefan einen BEFEHL gibt (etwas tun)
# oder eine FRAGE stellt (etwas wissen), und ob das Gespraech/eine Befehlskette
# weiterlaeuft oder endet. Sie haengt dazu Marker ans Ende ihrer Antwort — genau
# wie [[AWAIT]], und sie werden ebenso entfernt (nicht angezeigt/vorgelesen/in
# History). Der Marker ist AUTORITATIV — er ueberschreibt das Skill-Manifest-Flag,
# denn dasselbe Skill (z.B. VM-/GUI-Steuerung) ist mal Befehl, mal Auskunft; nur
# ARIA weiss aus dem Kontext, was gerade gemeint ist.
#
# [[STUMM]] -> reiner Steuerbefehl: NICHT vorlesen (speak=false). Allein =
# Einzelbefehl → danach zurueck aufs Wake-Word (converse=false).
# [[WEITER]] -> Konversation/Befehlskette laeuft weiter: Mikro offen halten
# (converse=true) — kein erneutes "Computer" noetig.
# [[ENDE]] -> Konversation/Kette beenden: zurueck aufs Wake-Word (converse=false).
_SILENT_MARKER_RE = re.compile(r"\[\[\s*STUMM\s*\]\]", re.IGNORECASE)
_CONT_MARKER_RE = re.compile(r"\[\[\s*WEITER\s*\]\]", re.IGNORECASE)
_END_MARKER_RE = re.compile(r"\[\[\s*ENDE\s*\]\]", re.IGNORECASE)
def _extract_flow_markers(text: str) -> tuple:
"""Zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem finalen Text.
Gibt (clean_text, speak_override, converse_override) zurueck; ein Override ist
None, wenn der jeweilige Marker fehlt (dann gilt Default/Skill-Flag).
Regeln: [[STUMM]] alleine = Einzelbefehl auch converse=false (Mikro zu),
ausser [[WEITER]] haelt es explizit offen. [[ENDE]] gewinnt gegen [[WEITER]]."""
if not text:
return text, None, None
speak_ov = None
conv_ov = None
if _SILENT_MARKER_RE.search(text):
speak_ov = False
text = _SILENT_MARKER_RE.sub("", text)
if _END_MARKER_RE.search(text):
conv_ov = False
text = _END_MARKER_RE.sub("", text)
if _CONT_MARKER_RE.search(text):
# [[ENDE]] hat Vorrang — widerspruechliche Marker → beenden.
if conv_ov is None:
conv_ov = True
text = _CONT_MARKER_RE.sub("", text)
# Stiller Einzelbefehl ohne explizites Weiterlauschen → Mikro zu.
if speak_ov is False and conv_ov is None:
conv_ov = False
return text.strip(), speak_ov, conv_ov
# Explizite "Konversation beenden"-Phrasen vom USER — deterministisch, NICHT auf
# ARIAs [[ENDE]]-Marker angewiesen. Stefan will "Konversation Ende" o.ae. als
# festen Trigger: danach zurueck aufs Wake-Word, egal was ARIA sonst tut. Eine in
# derselben Nachricht enthaltene Frage beantwortet sie normal (wird vorgelesen),
# aber converse wird auf false gezwungen. Nomen + Ende-Wort in EINEM Satzteil
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
# o.ae. nicht faelschlich matcht.
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)"
_CONV_END_VERB = r"(?:ende|beenden|beende|aus|stop|stopp|schluss)"
_END_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b"
rf"|\b(?:beende|schlie(?:ß|ss)e?)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
re.IGNORECASE,
)
def _user_wants_conversation_end(text: str) -> bool:
"""True, wenn der User in dieser Nachricht explizit die Konversation/Kette
beenden will (deterministisch, unabhaengig vom LLM-Marker)."""
if not text:
return False
return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
@@ -1755,6 +1826,11 @@ class Agent:
if not user_message:
raise ValueError("Leere Nachricht")
# Expliziter "Konversation/Kette beenden"-Wunsch (deterministisch). Wird an
# JEDEM Return auf converse=false angewendet — unabhaengig davon, ob ARIA
# den [[ENDE]]-Marker setzt. Stefans fester Trigger "Konversation Ende".
_wants_end = _user_wants_conversation_end(user_message)
# Events vom letzten Turn weglassen
self._pending_events = []
@@ -1782,6 +1858,8 @@ class Agent:
speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
if _wants_end:
converse = False
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False
@@ -1801,6 +1879,8 @@ class Agent:
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True)
if _wants_end:
converse = False
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False
@@ -2033,16 +2113,28 @@ class Agent:
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
final_reply, awaiting_reply = _extract_await_marker(final_reply)
# ARIAs Phasen-Marker ([[STUMM]]/[[WEITER]]/[[ENDE]]) ziehen — VOR History,
# damit sie nicht angezeigt/vorgelesen/gespeichert werden.
final_reply, _speak_ov, _conv_ov = _extract_flow_markers(final_reply)
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
# ARIAs Phasen-Marker sind AUTORITATIV: sie kennt aus dem Text den Unter-
# schied Befehl/Frage und Kette/Ende, den das Skill-Manifest nicht kennt.
speak = bool(getattr(self, "_claude_turn_speak", True))
converse = bool(getattr(self, "_claude_turn_converse", True))
if _speak_ov is not None:
speak = _speak_ov
if _conv_ov is not None:
converse = _conv_ov
# Expliziter User-Wunsch "Konversation beenden" gewinnt IMMER — Frage wird
# noch beantwortet (speak bleibt), aber danach zurueck aufs Wake-Word.
if _wants_end:
converse = False
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)),
awaiting_reply)
return (final_reply, "claude", speak, converse, awaiting_reply)
# ── Tool-Dispatcher ───────────────────────────────────────
+42 -1
View File
@@ -162,6 +162,46 @@ def build_time_section() -> str:
]
return "\n".join(lines)
def build_voice_flow_section() -> str:
"""Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs.
Frage, Kette vs. Ende) und deklariert sie per Marker wie [[AWAIT]]. Die
Marker werden im Brain entfernt (nie angezeigt/vorgelesen)."""
return "\n".join([
"## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)",
"Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase "
"ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf "
"zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, "
"nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.",
"",
"- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas "
"GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird "
"NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. "
"Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — "
"AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim "
"Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs "
"Wake-Word.",
"- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das "
"Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes "
"\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb "
"dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') "
"oder das Gespraech klar weitergeht.",
"- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz "
"das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette "
"Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, "
"beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng "
"`[[ENDE]]` an.",
"",
"Regeln:",
"- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, "
"ohne Marker (wird vorgelesen).",
"- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro "
"offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.",
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
])
TYPE_HEADINGS = {
"identity": "## Wer du bist",
"rule": "## Sicherheitsregeln & Prinzipien",
@@ -463,7 +503,8 @@ def build_system_prompt(
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()]
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
"", build_voice_flow_section()]
if skills:
parts.append("")
parts.append(build_skills_section(skills))
+19 -1
View File
@@ -788,6 +788,18 @@
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
Status wird geladen...
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
</label>
</div>
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
@@ -1899,6 +1911,11 @@
if (slider) slider.value = msg.voiceIdThreshold;
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
}
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
{
const cb = document.getElementById('diag-voice-id-enabled');
if (cb) cb.checked = !!msg.voiceIdEnabled;
}
return;
}
@@ -3600,13 +3617,14 @@
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
send({
action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
voiceIdThreshold,
voiceIdThreshold, voiceIdEnabled,
});
const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) {
+6
View File
@@ -2681,6 +2681,12 @@ wss.on("connection", (ws) => {
const t = parseFloat(msg.voiceIdThreshold);
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
}
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
// config-Broadcast; aus = gar keine Pruefung.
if (msg.voiceIdEnabled !== undefined) {
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
}
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
+1
View File
@@ -157,6 +157,7 @@ services:
capabilities: [gpu]
volumes:
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
environment:
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
+1 -1
View File
@@ -21,6 +21,6 @@ COPY requirements.txt .
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
COPY bridge.py .
COPY bridge.py speaker_id.py ./
CMD ["python3", "bridge.py"]
+188 -28
View File
@@ -38,6 +38,8 @@ import numpy as np
import soundfile as sf
import websockets
import speaker_id # Speaker-ID (nur Stefans Stimme) — portiert aus der whisper-Bridge
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
@@ -59,6 +61,7 @@ STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "
STREAM_DEFAULT_ENDPOINT_MS = 2400
STREAM_DEFAULT_HARD_CAP_MS = 300000
STREAM_MIN_AUDIO_MS = 600
STREAM_SPEAKER_CHECK_MS = 1500 # ab so viel Audio einmalig Speaker-ID pruefen
STREAM_SESSION_TTL_S = 120
STREAM_ENERGY_WINDOW_MS = 300
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
@@ -66,6 +69,22 @@ STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
STREAM_VOICE_FACTOR = 2.5
STREAM_VOICE_RMS_MIN = 0.005
STREAM_VOICE_RMS_MAX = 0.020
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
# vorbelegt werden.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
@@ -121,7 +140,9 @@ class VoxtralRunner:
)
inputs = inputs.to(VOXTRAL_DEVICE, dtype=torch.bfloat16)
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=512)
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
# mehrminutige Aufnahmen abgeschnitten.
outputs = model.generate(**inputs, max_new_tokens=4096)
trimmed = outputs[:, inputs.input_ids.shape[1]:]
text = proc.batch_decode(trimmed, skip_special_tokens=True)
return (text[0] if text else "").strip()
@@ -163,6 +184,18 @@ class StreamSession:
noise_floor: float = 0.0
closed: bool = False
endpoint_sent: bool = False
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
speech_signaled: bool = False
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
voiced_frames: int = 0
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
speaker_checked: bool = False
speaker_match: Optional[bool] = None
speaker_similarity: float = 0.0
class SessionManager:
@@ -257,6 +290,63 @@ class SessionManager:
else:
sess.noise_floor = 0.98 * nf + 0.02 * rms
async def _check_speaker(self, sess: StreamSession) -> None:
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
return
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
sess.speaker_match = True
return
try:
loop = asyncio.get_running_loop()
is_match, sim = await loop.run_in_executor(None, speaker_id.verify, head)
except Exception as exc:
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
sess.request_id[:8], exc)
sess.speaker_match = True
return
sess.speaker_match = is_match
sess.speaker_similarity = sim
logger.info("Stream %s: speaker-check sim=%.2f%s (thr=%.2f)",
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
speaker_id.DEFAULT_THRESHOLD)
if not is_match:
await self._finalize_speaker_mismatch(sess, sim)
async def _finalize_speaker_mismatch(self, sess: StreamSession, similarity: float) -> None:
"""Fremde Stimme: synthetisches leeres stt_endpoint (reason=speaker_mismatch),
Session droppen kein Voxtral-Transcribe, kein Brain-Call."""
if sess.endpoint_sent:
return
sess.endpoint_sent = True
duration_s = self._buffer_ms(sess) / 1000.0
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
sess.request_id[:8], similarity, duration_s)
if self._ws is not None:
payload = {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": "speaker_mismatch",
"durationS": duration_s, "sttMs": 0,
"voice": sess.voice, "speed": sess.speed,
"interrupted": sess.interrupted,
"speakerSimilarity": float(similarity),
}
if sess.location:
payload["location"] = sess.location
await _send(self._ws, "stt_endpoint", payload)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": "speaker_mismatch",
})
self.drop(sess.request_id)
async def run_endpointer(self) -> None:
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
STREAM_TRANSCRIBE_INTERVAL_MS)
@@ -284,45 +374,70 @@ class SessionManager:
return
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
return
# adaptive akustische Sprach-Aktivitaet
# Speaker-ID einmalig: ist es Stefans Stimme? Fremde → Session verwerfen
# (kein Transcribe, kein Brain-Call). Ohne Enrollment fail-open.
if not sess.speaker_checked and self._buffer_ms(sess) >= STREAM_SPEAKER_CHECK_MS:
await self._check_speaker(sess)
if sess.speaker_match is False:
return
# Adaptive akustische Sprach-Aktivitaet (M0.1). KEINE Live-Partials mehr:
# Voxtral-3B transkribiert den ganzen WACHSENDEN Buffer und braucht dafuer
# bei langen Aufnahmen 5-6 s — zu langsam fuer Live-Text, UND diese Latenz
# hat den semantischen Endpoint faelschlich ausgeloest (Partial-Latenz >
# Timeout → willkuerliche Abbrueche nach 20-40 s). Deshalb: Turn-Ende rein
# AKUSTISCH, transkribiert wird nur EINMAL im _finalize.
rms = self._tail_rms(sess)
if rms >= self._voice_threshold(sess):
sess.last_voice_at = now
else:
self._update_noise_floor(sess, rms)
# Endpoint-Entscheidung, sobald Text erkannt wurde
if sess.last_growth_at > 0.0:
ac_sil = (now - sess.last_voice_at) * 1000.0 if sess.last_voice_at > 0 else 0.0
se_sil = (now - sess.last_growth_at) * 1000.0
ac_done = sess.last_voice_at > 0 and ac_sil >= sess.endpoint_ms
se_done = se_sil >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR
if ac_done or se_done:
await self._finalize(sess, "endpoint" if ac_done else "endpoint_semantic")
return
# Partial-Transkription (throttled)
if (now - sess.last_transcribe_at) * 1000.0 < STREAM_TRANSCRIBE_INTERVAL_MS:
return
sess.last_transcribe_at = now
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
try:
text = (await self.runner.transcribe(audio, sess.language)).strip()
except Exception:
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
return
if text and text != sess.last_partial:
sess.last_partial = text
sess.last_growth_at = now
if self._ws is not None:
sess.voiced_frames += 1
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
if (not sess.speech_signaled and self._ws is not None
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
sess.speech_signaled = True
await _send(self._ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": text,
"text": "",
})
else:
self._update_noise_floor(sess, rms)
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
await self._finalize(sess, "endpoint")
async def _finalize(self, sess: StreamSession, reason: str) -> None:
if sess.endpoint_sent:
return
sess.endpoint_sent = True
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
# aus Fast-Nichts gern einen Fuellsatz ("keine Ahnung" o.ae.), der dann als
# PHANTOM-Nachricht ans Brain geht und das Gespraech entgleisen laesst
# (Stefans Repro: "kam Nachricht von mir, obwohl ich nichts sagte"). Leeres
# Endpoint = no-speech → App re-armt still. Der manuelle Stop (stream_end)
# ist ausgenommen: dort hat der User bewusst gesprochen (kurze Woerter ok).
if reason != "stream_end" and sess.voiced_frames < STREAM_MIN_VOICED_FRAMES:
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
sess.request_id[:8], sess.voiced_frames, STREAM_MIN_VOICED_FRAMES, reason)
if self._ws is not None:
nospeech = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}",
"durationS": 0.0, "sttMs": 0}
await _send(self._ws, "stt_endpoint", nospeech)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}"})
self.drop(sess.request_id)
return
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
t0 = time.time()
try:
@@ -395,6 +510,51 @@ async def run_loop(sessions: SessionManager) -> None:
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")
try:
status = speaker_id.status()
await _send(ws, "voice_id_status_response",
{"requestId": req_id, "ok": True, **status})
except Exception as exc:
await _send(ws, "voice_id_status_response",
{"requestId": req_id, "ok": False, "error": str(exc)[:200]})
elif mtype == "voice_id_enroll_request":
req_id = payload.get("requestId", "")
samples = payload.get("samples") or []
logger.info("voice_id_enroll_request: %d Samples (id=%s)", len(samples), req_id[:8])
try:
result = await asyncio.get_running_loop().run_in_executor(
None, speaker_id.enroll_from_samples, samples)
await _send(ws, "voice_id_enroll_response", {
"requestId": req_id, "ok": True,
"sample_count": result.get("sample_count", 0),
"rejected": result.get("rejected", []),
"updated_at": result.get("updated_at"),
"embedding_dim": result.get("embedding_dim"),
})
except Exception as exc:
logger.warning("voice_id_enroll failed: %s", exc)
await _send(ws, "voice_id_enroll_response",
{"requestId": req_id, "ok": False, "error": str(exc)[:300]})
elif mtype == "voice_id_delete_request":
req_id = payload.get("requestId", "")
removed = speaker_id.delete_fingerprint()
await _send(ws, "voice_id_delete_response",
{"requestId": req_id, "ok": True, "removed": removed})
elif mtype == "config":
if "voiceIdThreshold" in payload:
try:
t = float(payload.get("voiceIdThreshold", 0.5))
if 0.0 <= t <= 1.0:
speaker_id.DEFAULT_THRESHOLD = t
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError):
pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
+1
View File
@@ -3,6 +3,7 @@
transformers>=4.54
mistral-common[audio]>=1.8.1
accelerate>=0.30
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
soundfile>=0.12
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
numpy>=1.24
+272
View File
@@ -0,0 +1,272 @@
"""
Speaker-ID Backend fuer ARIAs Stimmen-Erkennung.
Nutzt SpeechBrain ECAPA-TDNN (192-dim Embeddings, auf VoxCeleb-1+2 trainiert).
Fingerprint = gemittelter, L2-normalisierter Embedding-Vektor aus N
Enrollment-Samples. Verify: cosine_similarity(neue_aufnahme, fingerprint).
Persistenz: /voice-id/fingerprint.json (Float-Liste + Metadaten).
Modell-Cache: /root/.cache/huggingface/ (Bind-Mount mit f5tts geteilt).
Verhalten OHNE Enrollment (kein Fingerprint vorhanden):
verify() (True, 0.0) Fail-open, damit Speaker-ID-Gating den
ungeenrollten Brain-Pfad nicht versehentlich blockiert.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
import numpy as np
logger = logging.getLogger(__name__)
VOICE_ID_DIR = Path(os.environ.get("VOICE_ID_DIR", "/voice-id"))
FINGERPRINT_FILE = VOICE_ID_DIR / "fingerprint.json"
# Cosine-Threshold: 0.5 ist konservativ (wenig false-positives), 0.3 ist
# locker (mehr Treffer auch bei Nebengeraeuschen). Stefan kann's per
# Diagnostic-Setting feintunen.
DEFAULT_THRESHOLD = 0.5
# Minimal-Sample-Laenge fuer ein verlaessliches Embedding (~1s @ 16kHz int16 = 32000 bytes)
MIN_SAMPLE_BYTES = 32000
_model = None
def _ensure_loaded():
"""Lazy-Load des ECAPA-TDNN. Holt das Modell beim ersten Aufruf von HF;
danach cached im HF-Cache-Volume. Erste Init: ~30s download + load,
danach <1s warm. Wirft bei Fehler Caller muss catchen + fail-open."""
global _model
if _model is not None:
return _model
import torch
from speechbrain.inference.speaker import EncoderClassifier
device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info("[speaker-id] loading ECAPA-TDNN on %s ...", device)
_model = EncoderClassifier.from_hparams(
source="speechbrain/spkrec-ecapa-voxceleb",
savedir="/root/.cache/huggingface/speechbrain-ecapa",
run_opts={"device": device},
)
logger.info("[speaker-id] model ready (device=%s)", device)
return _model
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
import os
import subprocess
import tempfile
tmp = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
tf.write(audio_bytes)
tmp = tf.name
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode != 0 or not proc.stdout:
raise ValueError(
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
return proc.stdout
finally:
if tmp:
try:
os.unlink(tmp)
except Exception:
pass
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV Header strippen +
Format validieren; MP4/AAC via ffmpeg dekodieren. Ergebnis: rohes PCM."""
if (len(audio_bytes) >= 44
and audio_bytes[:4] == b"RIFF"
and audio_bytes[8:12] == b"WAVE"):
import io
import wave
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
sr = wav.getframerate()
ch = wav.getnchannels()
sw = wav.getsampwidth()
if sr != 16000:
raise ValueError(f"WAV-Samplerate {sr} != 16000")
if ch != 1:
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
if sw != 2:
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
return wav.readframes(wav.getnframes())
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
return _decode_compressed_to_pcm(audio_bytes)
return audio_bytes
def _audio_bytes_to_tensor(audio_bytes: bytes):
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
import torch
raw = _normalize_audio_bytes(audio_bytes)
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
return torch.from_numpy(arr).unsqueeze(0)
def embed(audio_bytes: bytes) -> np.ndarray:
"""Berechnet das Speaker-Embedding fuer einen Audio-Chunk.
Erwartet 16kHz int16 LE PCM Mono. Returns 192-dim numpy float32."""
import torch
model = _ensure_loaded()
wav = _audio_bytes_to_tensor(audio_bytes)
with torch.no_grad():
emb = model.encode_batch(wav)
return emb.squeeze().cpu().numpy().astype(np.float32)
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Kosinus-Aehnlichkeit zwischen zwei 1D-Vektoren, Range [-1, 1].
Hoeher = aehnlicher. Bei normalisierten Vektoren ist das gleich dem Skalarprodukt."""
na = np.linalg.norm(a)
nb = np.linalg.norm(b)
if na < 1e-9 or nb < 1e-9:
return 0.0
return float(np.dot(a, b) / (na * nb))
def save_fingerprint(embeddings: list[np.ndarray], sample_durations_s: list[float]) -> dict:
"""Mittelt + L2-normalisiert die Embeddings und schreibt sie nach
FINGERPRINT_FILE. Returns das gespeicherte Dict."""
if not embeddings:
raise ValueError("Keine Embeddings zum Speichern")
VOICE_ID_DIR.mkdir(parents=True, exist_ok=True)
stacked = np.stack(embeddings)
mean = stacked.mean(axis=0)
mean = mean / max(np.linalg.norm(mean), 1e-9)
data = {
"version": 1,
"embedding": mean.tolist(),
"embedding_dim": int(mean.shape[0]),
"sample_count": len(embeddings),
"sample_durations_s": [float(s) for s in sample_durations_s],
"updated_at": int(time.time()),
}
FINGERPRINT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.info("[speaker-id] fingerprint gespeichert: %d Samples, dim=%d, total_s=%.1f",
len(embeddings), mean.shape[0], sum(sample_durations_s))
return data
def load_fingerprint() -> Optional[dict]:
"""Returns das Fingerprint-Dict oder None wenn noch nicht enrolled."""
if not FINGERPRINT_FILE.exists():
return None
try:
return json.loads(FINGERPRINT_FILE.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("[speaker-id] fingerprint laden fehlgeschlagen: %s", exc)
return None
def delete_fingerprint() -> bool:
"""Loescht den Fingerprint (z.B. fuer Re-Enrollment). True wenn was weg ist."""
if FINGERPRINT_FILE.exists():
FINGERPRINT_FILE.unlink()
logger.info("[speaker-id] fingerprint geloescht")
return True
return False
def verify(audio_bytes: bytes, threshold: Optional[float] = None) -> tuple[bool, float]:
"""Returns (is_match, similarity).
Wenn threshold=None: nutzt den Modul-Default (DEFAULT_THRESHOLD) der wird
vom config-Broadcast zur Laufzeit auf den Diagnostic-Slider-Wert gesetzt.
Default-Arg-Bindung waere zur Def-Zeit, also bewusst None statt direkt.
Fail-open: wenn kein Fingerprint vorhanden ist oder das Embedding-Modell
crasht, returnt (True, 0.0) kein Filtering. Sonst wuerde ein kaputter
Speaker-ID-Service die ganze Aufnahme blockieren."""
if threshold is None:
threshold = DEFAULT_THRESHOLD
fp = load_fingerprint()
if fp is None:
return True, 0.0
if len(audio_bytes) < MIN_SAMPLE_BYTES:
# Zu wenig Audio fuer ein verlaessliches Embedding → durchlassen
return True, 0.0
try:
saved_emb = np.array(fp["embedding"], dtype=np.float32)
new_emb = embed(audio_bytes)
except Exception as exc:
logger.warning("[speaker-id] verify embed failed: %s — fail-open", exc)
return True, 0.0
sim = cosine_similarity(new_emb, saved_emb)
return sim >= threshold, sim
def status() -> dict:
"""Status-Snapshot fuer die App / Diagnostic."""
fp = load_fingerprint()
return {
"enrolled": fp is not None,
"sample_count": fp.get("sample_count", 0) if fp else 0,
"sample_durations_s": fp.get("sample_durations_s", []) if fp else [],
"updated_at": fp.get("updated_at") if fp else None,
"embedding_dim": fp.get("embedding_dim") if fp else None,
"default_threshold": DEFAULT_THRESHOLD,
}
def enroll_from_samples(samples_b64: list[str]) -> dict:
"""Verarbeitet base64-Samples (16kHz int16 LE PCM Mono) zu einem neuen
Fingerprint. Returns Status-Dict. Wirft ValueError wenn nichts brauchbar ist."""
if not samples_b64:
raise ValueError("Keine Samples uebergeben")
embeddings: list[np.ndarray] = []
durations: list[float] = []
rejected: list[dict] = []
for idx, s in enumerate(samples_b64):
try:
raw = base64.b64decode(s)
except Exception as exc:
rejected.append({"index": idx, "reason": f"base64: {exc}"})
continue
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
try:
pcm = _normalize_audio_bytes(raw)
except Exception as exc:
rejected.append({"index": idx, "reason": f"decode: {exc}"})
continue
if len(pcm) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
continue
try:
emb = embed(pcm)
embeddings.append(emb)
durations.append(len(pcm) / 2 / 16000.0)
except Exception as exc:
rejected.append({"index": idx, "reason": f"embed: {exc}"})
if not embeddings:
raise ValueError(
f"Keine Samples konnten verarbeitet werden ({len(rejected)} rejected). "
f"Details: {rejected[:3]}"
)
fingerprint = save_fingerprint(embeddings, durations)
fingerprint["rejected"] = rejected
return fingerprint
+19
View File
@@ -86,6 +86,16 @@ STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
def _set_speaker_id_enabled(val: bool) -> None:
global SPEAKER_ID_ENABLED
SPEAKER_ID_ENABLED = bool(val)
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
@@ -467,6 +477,11 @@ class SessionManager:
Ohne Fingerprint fail-open (match=True). Bei mismatch wird die
Session sofort beendet mit synthetischem stt_endpoint."""
sess.speaker_checked = True
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
if not SPEAKER_ID_ENABLED:
sess.speaker_match = True
sess.speaker_similarity = 0.0
return
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
@@ -975,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
except (TypeError, ValueError):
pass
if "voiceIdEnabled" in payload:
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
"AN" if SPEAKER_ID_ENABLED else "AUS")
if "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE
+48 -7
View File
@@ -61,10 +61,40 @@ def _ensure_loaded():
return _model
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
import os
import subprocess
import tempfile
tmp = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
tf.write(audio_bytes)
tmp = tf.name
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode != 0 or not proc.stdout:
raise ValueError(
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
return proc.stdout
finally:
if tmp:
try:
os.unlink(tmp)
except Exception:
pass
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
Ergebnis: rohes PCM."""
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV Header strippen +
Format validieren; MP4/AAC via ffmpeg dekodieren. Ergebnis: rohes PCM."""
if (len(audio_bytes) >= 44
and audio_bytes[:4] == b"RIFF"
and audio_bytes[8:12] == b"WAVE"):
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
if sw != 2:
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
return wav.readframes(wav.getnframes())
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
return _decode_compressed_to_pcm(audio_bytes)
return audio_bytes
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
except Exception as exc:
rejected.append({"index": idx, "reason": f"base64: {exc}"})
continue
if len(raw) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
try:
pcm = _normalize_audio_bytes(raw)
except Exception as exc:
rejected.append({"index": idx, "reason": f"decode: {exc}"})
continue
if len(pcm) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
continue
try:
emb = embed(raw)
emb = embed(pcm)
embeddings.append(emb)
durations.append(len(raw) / 2 / 16000.0)
durations.append(len(pcm) / 2 / 16000.0)
except Exception as exc:
rejected.append({"index": idx, "reason": f"embed: {exc}"})
if not embeddings: