Compare commits

...

8 Commits

Author SHA1 Message Date
duffyduck f714cfc336 release: bump version to 0.1.9.3 2026-06-06 21:11:50 +02:00
duffyduck a0dc0cf20e feat(speaker-id): Phase 5 — Passive-Listen-Window nach jeder Konversation
Neuer State 'listening' im WakeWordService. Nach endConversation faellt
ARIA nicht direkt zu armed zurueck, sondern ins passive Lauschen fuer
PASSIVE_LISTEN_DEFAULT_MS (Default 30s, in AsyncStorage konfigurierbar).
In dem Fenster braucht Stefan kein Wake-Word mehr — er kann einfach
weitersprechen, Speaker-ID-Gating in der Whisper-Bridge filtert fremde
Stimmen (TV, Frau, Hintergrundgespraeche).

Flow:
  armed → wake → conversing → TTS → resume → (Nichts gesagt) →
  endConversation → enterPassiveListening('listening' + Timer) →
  startPassiveStreamingRecording (kein User-Bubble, kein wake-ready-Sound)
  → Speaker-ID-Gating in Bridge → Speech detected:
    exitPassiveListening('speech') → 'conversing' → normaler Flow
  → Nichts in N Sek:
    Timer feuert → exitPassiveListening('timeout') → 'armed' (Wake an)

Implementation:
- wakeword.ts: WakeWordState += 'listening'. enterPassiveListening +
  exitPassiveListening + onPassiveListen-Callback + Cancel-Timer-Hooks
  in stop(). PASSIVE_LISTEN_DEFAULT_MS/STORAGE_KEY + load/save Helpers.
- ChatScreen.tsx: state-Type um 'listening' erweitert. State-Listener
  schliesst Conversation-Focus auch in 'listening' (Spotify bleibt
  pausiert). onPassiveListen → startPassiveStreamingRecording mit
  noSpeechTimeoutMs=passiveMs. STT-Endpoint-Handler: bei text != ''
  und state=='listening' → exitPassiveListening('speech'); bei
  text == '' und state=='listening' → naechste passive Aufnahme.
  Beim Wechsel listening→armed/off: laufende streaming-Aufnahme
  cancellen damit OpenWakeWord beim Re-Arm das Mic kriegt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:51:07 +02:00
duffyduck ac53af5c24 feat(speaker-id): Phase 3 — Speaker-Gating im Streaming-STT
Sobald eine Streaming-Session ~1.5s Audio im Buffer hat, wird einmal pro
Session der Speaker-ID-Check ausgefuehrt (im Executor, ~50-100ms auf GPU).
Bei Match → Session laeuft normal weiter. Bei Mismatch → synthetisches
stt_endpoint mit text='' reason='speaker_mismatch' + stt_stream_done →
App ruft endConversation. Kein Whisper-Transcribe fuer fremde Stimmen →
Token + Latenz gespart.

- StreamSession: 3 neue Felder (speaker_checked, speaker_match,
  speaker_similarity).
- SessionManager._check_speaker / _finalize_speaker_mismatch:
  Check + sauberes Beenden bei Mismatch.
- _tick_session: Check-Gate vor STREAM_MIN_AUDIO_MS-Check eingehaengt.
- speaker_id.verify: threshold=None statt =DEFAULT_THRESHOLD damit
  config-Broadcast-Updates zur Laufzeit greifen (Default-Arg wird sonst
  zur Def-Zeit gebunden).

Fail-open: ohne Fingerprint returnt verify() (True, 0.0) — keine
Auswirkung. Stefan kann ohne Enrollment weiter wie bisher arbeiten.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:41:49 +02:00
duffyduck e3fe27f736 feat(speaker-id): Phase 2 — Enrollment-UI (App) + Voice-ID-Section (Diagnostic)
App-Seite:
- VoiceIdEnrollment.tsx (neue Komponente, ~370 Zeilen): Status-Karte
  (loading/unenrolled/enrolled/error), Sample-Recorder mit Countdown
  (4s fest pro Sample), Liste mit einzelnem Loeschen, Save-Button
  (disabled bis 5 Samples), Fingerprint-Delete mit Confirm.
- SettingsScreen.tsx: neue Section 🎤 'Stimme einrichten' zwischen
  Wake-Word und Sprachausgabe.
- Sample-Format: WAV via audioService.startRecording — wird
  whisper-bridge-seitig per wave-Modul gestrippt.

Diagnostic-Seite:
- Neue settings-section 'Voice-ID (Sprecher-Erkennung)': Status-Anzeige
  (live ueber voice_id_status_response), Threshold-Slider 0.30-0.70
  (persistiert in voice_config.json, broadcast als config-Message),
  Refresh + Delete-Button.
- server.js: 2 neue actions (voice_id_status, voice_id_delete),
  send_voice_config nimmt voiceIdThreshold mit auf.

Backend:
- speaker_id.py: _normalize_audio_bytes erkennt jetzt WAV-Header
  (RIFF/WAVE) und strippt auf rohes PCM — sonst werfen die ECAPA-
  Embeddings auf den 44-Byte-Header rein.
- bridge.py: config-Broadcast-Handler setzt voiceIdThreshold auf
  speaker_id.DEFAULT_THRESHOLD (wird erst in Phase 3 beim Gating
  genutzt, persistiert aber schon).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:36:06 +02:00
duffyduck 6e19adab87 feat(speaker-id): Phase 1 — SpeechBrain ECAPA-TDNN Backend in whisper-bridge
Speaker-ID-Modul (Hermes-Style „echtes Gespraech ohne Wake-Word"-Vision,
Phase 1 von 5). Erkennt Stefans Stimme via 192-dim Embedding + Cosine-
Match gegen einen persistierten Fingerprint.

Module:
- speaker_id.py: lazy-loaded ECAPA-TDNN (HuggingFace), enroll/verify/
  status/delete. Fingerprint = L2-normalisierter Mittelwert aus N
  Enrollment-Samples in /voice-id/fingerprint.json.
  Fail-open: kein Fingerprint → verify() returnt (True, 0.0).
- bridge.py: 3 Message-Handler — voice_id_status_request,
  voice_id_enroll_request (samples[]: base64 16kHz int16 PCM),
  voice_id_delete_request. Enrollment laeuft im Executor (Torch
  blockt sonst die Event-Loop).
- Dockerfile: torch 2.3.1 + torchaudio mit CUDA-12.1-Wheels (sonst
  zieht speechbrain CPU-only Torch rein). Container ~1 GB groesser.
- docker-compose.yml: ./voice-id:/voice-id Bind-Mount fuer Fingerprint-
  Persistenz (ueberlebt Container-Restart).
- rvs/server.js: 6 neue Message-Types in ALLOWED_TYPES.

Phase 2 (next): App-Enrollment-Flow + Diagnostic-Voice-ID-Section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:26:12 +02:00
duffyduck 095a10aaf0 release: bump version to 0.1.9.2 2026-06-06 09:30:13 +02:00
duffyduck e3a224478d fix(wakeword): Mic an andere Apps freigeben (WhatsApp-Voicenote etc.)
Bug: ARIAs VOICE_COMMUNICATION-Lock liess WhatsApp-Sprachnachrichten,
Sprach-Suchen u.ae. nur Stille aufnehmen — Android-Audio-Policy gibt
der zweiten App formal Audio, liefert aber Null-Samples solange unsere
Pipeline aktiv ist.

Fix: AudioRecordingCallback (API 24+) registriert sich beim start() und
beobachtet andere Mic-Sessions. Fremder Mic-User detected → unsere
AudioRecord + Effects sofort freigeben (externallyPaused=true), WakeLock
+ Callback bleiben aktiv. Fremder weg → 300ms warten (Audio-Stack-
Settling), nochmal pruefen, dann reaktivieren.

Refactor mit drin: start()/stop() benutzen jetzt zwei Helper
(acquireAndStartRecording, stopAndReleaseRecording) damit die Mic-Setup-
Logik nicht zwischen start() und resumeAfterExternal() dupliziert wird.

Trade-offs:
- Wake-Word taub solange andere App das Mic nutzt — akzeptabel.
- API < 24: kein Callback verfuegbar, alter Stand (kein Mic-Sharing).
- Phone-Call kollidiert nicht mit phoneCallService.pauseForCall —
  beide pausieren/reaktivieren idempotent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 09:29:00 +02:00
duffyduck 61c9183033 refactor(brain): Fast-Path als Skill-Capability — fast_patterns im Manifest
Frueher: Spotify-spezifische Patterns hardcoded in agent.py — jeder neue
Steuer-Skill haette wieder Brain-Code-Aenderungen gebraucht.
Jetzt: jeder Skill deklariert seine eigenen Patterns im Manifest unter
fast_patterns: [{match, args, reply}]. Brain iteriert generisch, kein
Skill bekommt Sonderbehandlung.

- agent.py: _try_skill_fast_path liest aus skills.list_skills(), keine
  Spotify-Konstanten mehr. skill_create/skill_update Tool-Schema kennt
  fast_patterns (mit Beispiel + Wann-nutzen-Hinweis).
- skills.py: _normalize_fast_patterns validiert Regex + filtert kaputte
  Eintraege; create_skill/update_skill akzeptieren das Feld.
- main.py: einmalige Lifespan-Migration — wenn spotify-Skill existiert
  und kein fast_patterns hat, werden die alten Hardcoded-Patterns
  rueberkopiert. Idempotent, laeuft bei jedem Restart sicher mehrfach.
- seed_rules.py: neue Regel `seed/skill-rule/fast-patterns-for-control`
  erklaert ARIA wann sie das Feature nutzen soll (reines Steuern: ja,
  kreativer Output / Parametrisierung: nein) — mit Beispiel.

Trade-off: Volume-Patterns (lauter/leiser) fallen aus dem Fast-Path raus,
weil die Multi-Step-Logik (GET state → compute → PUT) sich nicht
deklarativ ausdruecken laesst. Wer das zurueck will: Spotify-Skill um
einen action=volume_relative-Arg erweitern der die Mathe intern macht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:56:15 +02:00
19 changed files with 1532 additions and 193 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10901
versionName "0.1.9.1"
versionCode 10903
versionName "0.1.9.3"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -7,11 +7,16 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioRecordingConfiguration
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
@@ -104,6 +109,20 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L
// Audio-Sharing mit anderen Apps:
// Wenn z.B. WhatsApp eine Sprachnachricht aufnimmt, dann hält ARIAs
// VOICE_COMMUNICATION-Lock zwar das System nicht offiziell exklusiv,
// aber die Foreground-App bekommt nur Stille — die WhatsApp-Aufnahme
// ist tonlos. Loesung: AudioRecordingCallback hoeren, sobald eine andere
// App das Mic anfordert → unsere AudioRecord freigeben (externallyPaused=true).
// Wenn die andere App fertig ist → reaktivieren. Wakeword pausiert solange.
private var recordingCallback: AudioManager.AudioRecordingCallback? = null
@Volatile private var externallyPaused: Boolean = false
private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) }
private val audioManager: AudioManager by lazy {
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
/**
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
@@ -167,54 +186,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
}
try {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
// zur TTS-Wiedergabe gelauscht wird.
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf,
)
if (record.state != AudioRecord.STATE_INITIALIZED) {
record.release()
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)")
return
}
audioRecord = record
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
resetInferenceState()
running.set(true)
record.startRecording()
recordingStartedMs = System.currentTimeMillis()
acquireAndStartRecording()
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
@@ -231,10 +203,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
}
// AudioRecordingCallback registrieren: andere Apps (WhatsApp-
// Sprachnachricht, Telefonate etc.) wollen das Mic — wir geben
// es vorruebergehend frei statt sie ins Leere recorden zu lassen.
registerRecordingCallback()
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
promise.resolve(true)
@@ -247,6 +219,75 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
}
}
/** Reine AudioRecord + Effects + Capture-Thread-Acquisition. Wirft bei
* Fehler — Caller faengt + reportet. Kein WakeLock, keine Callbacks. */
private fun acquireAndStartRecording() {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
// zur TTS-Wiedergabe gelauscht wird.
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf,
)
if (record.state != AudioRecord.STATE_INITIALIZED) {
record.release()
throw IllegalStateException("AudioRecord nicht initialisiert (Mikro belegt?)")
}
audioRecord = record
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
resetInferenceState()
running.set(true)
record.startRecording()
recordingStartedMs = System.currentTimeMillis()
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
}
}
/** Reine AudioRecord + Effects + Capture-Thread-Release. Sicher (catch all).
* Kein WakeLock-Release, kein Unregistrieren der Callbacks. */
private fun stopAndReleaseRecording() {
running.set(false)
try { captureThread?.join(1500) } catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
}
private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
@@ -256,15 +297,9 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
@ReactMethod
fun stop(promise: Promise) {
running.set(false)
try {
captureThread?.join(1500)
} catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
unregisterRecordingCallback()
externallyPaused = false
stopAndReleaseRecording()
releaseWakeLock()
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
@@ -272,18 +307,94 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
@ReactMethod
fun dispose(promise: Promise) {
running.set(false)
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
unregisterRecordingCallback()
externallyPaused = false
stopAndReleaseRecording()
releaseWakeLock()
disposeSessions()
promise.resolve(true)
}
// ── External-Mic-Sharing (AudioRecordingCallback) ──────────────────────
//
// Wenn eine andere App das Mic anfordert (WhatsApp-Voicenote, Telefonie,
// Sprach-Suche im Browser etc.), kriegt die zwar formal Audio — aber
// unsere VOICE_COMMUNICATION-Pipeline blockiert die naively neue Aufnahme
// mit Stille (Android-Audio-Policy). Loesung: AudioRecordingCallback
// beobachten, andere Recorder-Sessions detecten, und unsere Pipeline
// temporaer freigeben. Sobald die andere App fertig ist → reaktivieren.
//
// Effekt: Wake-Word funktioniert solange nicht — fairer Kompromiss.
private fun registerRecordingCallback() {
if (recordingCallback != null) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Log.i(TAG, "AudioRecordingCallback nicht verfuegbar (API < 24) — Mic-Sharing inaktiv")
return
}
val cb = object : AudioManager.AudioRecordingCallback() {
override fun onRecordingConfigChanged(configs: MutableList<AudioRecordingConfiguration>?) {
handleRecordingConfigChange(configs)
}
}
try {
audioManager.registerAudioRecordingCallback(cb, mainHandler)
recordingCallback = cb
Log.i(TAG, "AudioRecordingCallback registriert — beobachtet andere Mic-User")
} catch (e: Exception) {
Log.w(TAG, "registerAudioRecordingCallback failed: ${e.message}")
}
}
private fun unregisterRecordingCallback() {
val cb = recordingCallback ?: return
try { audioManager.unregisterAudioRecordingCallback(cb) } catch (_: Exception) {}
recordingCallback = null
}
private fun handleRecordingConfigChange(configs: MutableList<AudioRecordingConfiguration>?) {
if (configs == null) return
// Unsere eigene Session anhand der audioSessionId filtern. Wenn wir
// gerade keinen AudioRecord halten (externallyPaused), ist alles
// andere "extern" — dann zaehlt jeder Eintrag.
val ourSessionId = audioRecord?.audioSessionId
val externalActive = configs.any {
ourSessionId == null || it.clientAudioSessionId != ourSessionId
}
if (running.get() && externalActive) {
Log.i(TAG, "Andere App nutzt Mic — Wake-Word pausiert (configs=${configs.size})")
externallyPaused = true
stopAndReleaseRecording()
return
}
if (externallyPaused && !externalActive) {
Log.i(TAG, "Mic wieder frei — Wake-Word reaktiviert in 300ms")
// Kurze Pause: der "andere" hat eben losgelassen, Audio-Stack braucht
// ein paar ms bis VOICE_COMMUNICATION wieder sauber initialisiert.
mainHandler.postDelayed({
if (!externallyPaused) return@postDelayed // schon resumed
// Sicherheitscheck: wenn inzwischen jemand wieder rein ist
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val cur = audioManager.activeRecordingConfigurations
if (cur != null && cur.isNotEmpty()) {
Log.i(TAG, "Resume verworfen — anderer Mic-User noch da (${cur.size})")
return@postDelayed
}
}
externallyPaused = false
try {
acquireAndStartRecording()
Log.i(TAG, "Wake-Word nach External-Pause reaktiviert")
} catch (e: Exception) {
Log.w(TAG, "Resume nach External-Pause failed: ${e.message}")
// bleiben unten — falls anderer App das Mic doch wieder
// freigibt, feuert der Callback erneut.
externallyPaused = true
}
}, 300L)
}
}
private fun releaseWakeLock() {
try {
wakeLock?.takeIf { it.isHeld }?.release()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.9.1",
"version": "0.1.9.3",
"private": true,
"scripts": {
"android": "react-native run-android",
@@ -0,0 +1,426 @@
/**
* Voice-ID Enrollment + Status — App-seitig.
*
* User nimmt 5-7 Samples (je 4s) seiner Stimme auf, App schickt sie an
* die whisper-bridge via RVS (voice_id_enroll_request). Bridge berechnet
* SpeechBrain-ECAPA-Embeddings, mittelt sie zu einem Fingerprint, speichert
* /voice-id/fingerprint.json.
*
* Verwendung: in SettingsScreen für Section 'voice_id' eingebunden.
* Holt Status bei Mount + nach jedem Enroll/Delete neu ab.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
ToastAndroid,
TouchableOpacity,
View,
} from 'react-native';
import audioService from '../services/audio';
import rvs from '../services/rvs';
const SAMPLE_DURATION_MS = 4000; // Pro Sample 4s aufnehmen
const SAMPLES_REQUIRED = 5; // Mindest-Sampleanzahl fuer Save
type Sample = {
base64: string;
durationMs: number;
};
type Status =
| { state: 'loading' }
| { state: 'unenrolled' }
| { state: 'enrolled'; sampleCount: number; durations: number[]; updatedAt: number; dim: number }
| { state: 'error'; message: string };
function _newReqId(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
}
export const VoiceIdEnrollment: React.FC = () => {
const [status, setStatus] = useState<Status>({ state: 'loading' });
const [samples, setSamples] = useState<Sample[]>([]);
const [recording, setRecording] = useState(false);
const [recordCountdown, setRecordCountdown] = useState(0);
const [enrollPending, setEnrollPending] = useState(false);
const [pendingReqId, setPendingReqId] = useState<string | null>(null);
// Status laden
const refreshStatus = useCallback(() => {
setStatus({ state: 'loading' });
const reqId = _newReqId('vid');
setPendingReqId(reqId);
rvs.send('voice_id_status_request' as any, { requestId: reqId });
}, []);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
// RVS-Antworten verarbeiten
useEffect(() => {
const unsub = rvs.onMessage((msg: any) => {
if (!msg) return;
const p = msg.payload || {};
if (msg.type === 'voice_id_status_response') {
if (p.ok === false) {
setStatus({ state: 'error', message: p.error || 'Whisper-Bridge nicht erreichbar' });
return;
}
if (p.enrolled) {
setStatus({
state: 'enrolled',
sampleCount: p.sample_count || 0,
durations: p.sample_durations_s || [],
updatedAt: p.updated_at || 0,
dim: p.embedding_dim || 0,
});
} else {
setStatus({ state: 'unenrolled' });
}
} else if (msg.type === 'voice_id_enroll_response') {
setEnrollPending(false);
if (p.ok === false) {
Alert.alert('Enrollment fehlgeschlagen', p.error || 'Unbekannter Fehler');
return;
}
const rejected = (p.rejected || []).length;
ToastAndroid.show(
`✓ Stimme gespeichert (${p.sample_count} Samples${rejected ? `, ${rejected} verworfen` : ''})`,
ToastAndroid.LONG,
);
setSamples([]);
refreshStatus();
} else if (msg.type === 'voice_id_delete_response') {
ToastAndroid.show(p.removed ? '✓ Stimme gelöscht' : 'Es war keine gespeichert', ToastAndroid.SHORT);
refreshStatus();
}
});
return () => unsub();
}, [refreshStatus]);
// Ein Sample aufnehmen — fest 4s, dann auto-stop
const recordSample = useCallback(async () => {
if (recording || enrollPending) return;
setRecording(true);
setRecordCountdown(SAMPLE_DURATION_MS / 1000);
try {
const ok = await audioService.startRecording(false);
if (!ok) {
ToastAndroid.show('Aufnahme konnte nicht gestartet werden', ToastAndroid.LONG);
setRecording(false);
setRecordCountdown(0);
return;
}
// Countdown-Timer (rein UI)
const tickInterval = setInterval(() => {
setRecordCountdown(c => Math.max(0, c - 1));
}, 1000);
// Auto-Stop nach festen 4s
await new Promise(r => setTimeout(r, SAMPLE_DURATION_MS));
clearInterval(tickInterval);
const result = await audioService.stopRecording();
setRecordCountdown(0);
setRecording(false);
if (!result || !result.base64) {
ToastAndroid.show('Aufnahme leer — nochmal probieren', ToastAndroid.LONG);
return;
}
setSamples(prev => [...prev, { base64: result.base64, durationMs: result.durationMs }]);
} catch (err: any) {
console.warn('[VoiceId] recordSample:', err);
try { await audioService.cancelRecording(); } catch {}
setRecording(false);
setRecordCountdown(0);
ToastAndroid.show('Aufnahmefehler: ' + (err?.message || err), ToastAndroid.LONG);
}
}, [recording, enrollPending]);
const removeSample = useCallback((idx: number) => {
setSamples(prev => prev.filter((_, i) => i !== idx));
}, []);
const sendEnrollment = useCallback(() => {
if (samples.length < SAMPLES_REQUIRED) {
Alert.alert('Noch nicht genug',
`Bitte mindestens ${SAMPLES_REQUIRED} Samples aufnehmen — aktuell ${samples.length}.`);
return;
}
if (enrollPending) return;
setEnrollPending(true);
const reqId = _newReqId('videnroll');
rvs.send('voice_id_enroll_request' as any, {
requestId: reqId,
samples: samples.map(s => s.base64),
});
// Sicherheits-Timeout: wenn nach 60s nichts kommt, freigeben
setTimeout(() => {
setEnrollPending(prev => {
if (prev) {
ToastAndroid.show('Enrollment-Timeout — bitte erneut versuchen', ToastAndroid.LONG);
}
return false;
});
}, 60_000);
}, [samples, enrollPending]);
const deleteFingerprint = useCallback(() => {
Alert.alert(
'Stimme löschen?',
'Danach muss ARIA neu enrolled werden, sonst greift Speaker-ID-Filter nicht.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen', style: 'destructive', onPress: () => {
const reqId = _newReqId('viddel');
rvs.send('voice_id_delete_request' as any, { requestId: reqId });
},
},
],
);
}, []);
// ── Render ──────────────────────────────────────────────
return (
<ScrollView contentContainerStyle={{ paddingBottom: 30 }}>
<Text style={s.intro}>
ARIA erkennt deine Stimme an einem Fingerprint (SpeechBrain ECAPA-TDNN, 192 Dimensionen).
Andere Sprecher (TV, Hintergrund, andere Personen) werden gefiltert keine Brain-Calls,
keine Tokens. {'\n\n'}
Sprich {SAMPLES_REQUIRED} Mal je {SAMPLE_DURATION_MS / 1000}s ganz normal verschiedene
Sätze, ruhige Umgebung empfohlen.
</Text>
{/* Status-Karte */}
<View style={s.card}>
<Text style={s.cardLabel}>Status</Text>
{status.state === 'loading' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ActivityIndicator color="#0096FF" />
<Text style={s.statusText}>Wird abgefragt...</Text>
</View>
)}
{status.state === 'unenrolled' && (
<Text style={[s.statusText, { color: '#FFD60A' }]}> Nicht enrolled Stimme einrichten </Text>
)}
{status.state === 'enrolled' && (
<>
<Text style={[s.statusText, { color: '#34C759' }]}>
Enrolled {status.sampleCount} Samples
({status.durations.reduce((a, b) => a + b, 0).toFixed(1)}s gesamt)
</Text>
<Text style={s.statusSub}>
Aktualisiert {new Date(status.updatedAt * 1000).toLocaleString('de-DE')} · dim={status.dim}
</Text>
</>
)}
{status.state === 'error' && (
<Text style={[s.statusText, { color: '#FF6E6E' }]}> {status.message}</Text>
)}
</View>
{/* Aufnahme-Bereich */}
<View style={s.card}>
<Text style={s.cardLabel}>Samples ({samples.length}/{SAMPLES_REQUIRED})</Text>
{samples.length === 0 && !recording && (
<Text style={s.hint}>Tipp: sprich klare normale Sätze, je 3-4 Sekunden Audio.</Text>
)}
{samples.map((sample, idx) => (
<View key={idx} style={s.sampleRow}>
<Text style={s.sampleText}>
Sample {idx + 1} · {(sample.durationMs / 1000).toFixed(1)}s
</Text>
<TouchableOpacity onPress={() => removeSample(idx)} disabled={enrollPending}>
<Text style={{ color: '#FF6E6E', fontSize: 18 }}></Text>
</TouchableOpacity>
</View>
))}
<TouchableOpacity
onPress={recordSample}
disabled={recording || enrollPending}
style={[s.recordBtn, (recording || enrollPending) && { opacity: 0.5 }]}
>
{recording ? (
<>
<ActivityIndicator color="#fff" />
<Text style={s.recordBtnText}>Aufnahme läuft {recordCountdown}s</Text>
</>
) : (
<Text style={s.recordBtnText}> Sample {samples.length + 1} aufnehmen</Text>
)}
</TouchableOpacity>
{samples.length > 0 && !recording && (
<TouchableOpacity
onPress={() => setSamples([])}
disabled={enrollPending}
style={s.resetBtn}
>
<Text style={s.resetBtnText}>Alle verwerfen</Text>
</TouchableOpacity>
)}
</View>
{/* Aktionen */}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
<TouchableOpacity
onPress={sendEnrollment}
disabled={samples.length < SAMPLES_REQUIRED || enrollPending}
style={[
s.primaryBtn,
(samples.length < SAMPLES_REQUIRED || enrollPending) && { opacity: 0.4 },
]}
>
{enrollPending ? (
<>
<ActivityIndicator color="#fff" />
<Text style={s.primaryBtnText}>Wird verarbeitet</Text>
</>
) : (
<Text style={s.primaryBtnText}>
Speichern ({samples.length}/{SAMPLES_REQUIRED})
</Text>
)}
</TouchableOpacity>
</View>
{/* Verwaltung */}
{status.state === 'enrolled' && (
<View style={[s.card, { marginTop: 20 }]}>
<Text style={s.cardLabel}>Verwaltung</Text>
<TouchableOpacity onPress={refreshStatus} style={s.secondaryBtn}>
<Text style={s.secondaryBtnText}>🔄 Status aktualisieren</Text>
</TouchableOpacity>
<TouchableOpacity onPress={deleteFingerprint} style={s.dangerBtn}>
<Text style={s.dangerBtnText}>🗑 Fingerprint löschen (Re-Enrollment nötig)</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
};
const s = StyleSheet.create({
intro: {
color: '#8888AA',
fontSize: 13,
lineHeight: 19,
marginBottom: 16,
paddingHorizontal: 4,
},
card: {
backgroundColor: 'rgba(30,30,46,0.6)',
borderRadius: 8,
padding: 14,
marginBottom: 10,
},
cardLabel: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
},
statusText: {
color: '#E0E0F0',
fontSize: 14,
fontWeight: '600',
},
statusSub: {
color: '#555570',
fontSize: 11,
marginTop: 4,
},
hint: {
color: '#555570',
fontSize: 12,
fontStyle: 'italic',
marginBottom: 8,
},
sampleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 6,
borderBottomWidth: 1,
borderColor: '#2A2A3E',
},
sampleText: {
color: '#E0E0F0',
fontSize: 13,
},
recordBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#E55C5C',
borderRadius: 8,
paddingVertical: 14,
marginTop: 12,
},
recordBtnText: {
color: '#fff',
fontSize: 15,
fontWeight: '700',
},
resetBtn: {
alignItems: 'center',
paddingVertical: 8,
marginTop: 6,
},
resetBtnText: {
color: '#FFD60A',
fontSize: 12,
},
primaryBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#34C759',
borderRadius: 8,
paddingVertical: 14,
},
primaryBtnText: {
color: '#fff',
fontSize: 15,
fontWeight: '700',
},
secondaryBtn: {
backgroundColor: 'rgba(0,150,255,0.15)',
borderRadius: 6,
paddingVertical: 10,
alignItems: 'center',
marginTop: 6,
},
secondaryBtnText: {
color: '#0096FF',
fontSize: 13,
fontWeight: '600',
},
dangerBtn: {
backgroundColor: 'rgba(229,92,92,0.15)',
borderRadius: 6,
paddingVertical: 10,
alignItems: 'center',
marginTop: 6,
},
dangerBtnText: {
color: '#E55C5C',
fontSize: 13,
fontWeight: '600',
},
});
export default VoiceIdEnrollment;
+65 -8
View File
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
import ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound';
import {
@@ -273,7 +273,7 @@ const ChatScreen: React.FC = () => {
const [gpsEnabled, setGpsEnabled] = useState(false);
const [wakeWordActive, setWakeWordActive] = useState(false);
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing' | 'listening'>('off');
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
const [inboxVisible, setInboxVisible] = useState(false);
@@ -487,9 +487,16 @@ const ChatScreen: React.FC = () => {
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
// 'armed' oder 'off' fallen, darf Spotify wieder.
if (s === 'conversing') audioService.acquireConversationFocus();
// 'armed' oder 'off' fallen, darf Spotify wieder. 'listening' soll
// Spotify ebenfalls leise halten (User darf jederzeit weitersprechen).
if (s === 'conversing' || s === 'listening') audioService.acquireConversationFocus();
else audioService.releaseConversationFocus();
// Beim Verlassen von 'listening' (Timer abgelaufen) eine ggf. noch
// laufende passive Streaming-Aufnahme killen, sonst hat OpenWakeWord
// keinen Zugriff aufs Mic beim Re-Arm.
if ((s === 'armed' || s === 'off') && audioService.isStreamingRecording()) {
audioService.cancelStreamingRecording('wakeword-state-' + s);
}
// Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
// (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
// bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
@@ -1346,12 +1353,18 @@ const ChatScreen: React.FC = () => {
// - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
// aria-bridge bekommt das gleiche Event und triggert Brain
// direkt. App muss nix mehr senden.
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error).
// Konversation beenden wie frueher der "kein Speech"-Fall.
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error /
// speaker_mismatch). Konversation beenden, oder bei
// passive-listening: nochmal lauschen.
const unsubEndpoint = audioService.onSttEndpoint((ev) => {
if (ev.text && ev.text.trim()) {
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
// Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang
// zu 'conversing' damit der normale Flow greift (TTS, resume, etc.)
if (wakeWordService.getState() === 'listening') {
wakeWordService.exitPassiveListening('speech').catch(() => {});
}
// Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
// chat(sender=aria) wie im Legacy-Pfad.
} else {
@@ -1361,11 +1374,28 @@ const ChatScreen: React.FC = () => {
if (ev.audioRequestId) {
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
}
wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false);
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
// Sonst endConversation wie bisher.
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
startPassiveStreamingRecording();
} else {
wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false);
}
}
});
// Passive-Listen-Callback: Wake-Word-Service hat in den passiven Modus
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
// Whisper-Bridge filtert fremde Stimmen weg.
const unsubPassive = wakeWordService.onPassiveListen(() => {
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
startPassiveStreamingRecording();
});
// Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
@@ -1430,11 +1460,38 @@ const ChatScreen: React.FC = () => {
unsubWake();
unsubEndpoint();
unsubBarge();
unsubPassive();
unsubTtsStart();
unsubTtsEnd();
};
}, [wakeWordActive]);
// Passive-Listen-Aufnahme: ohne User-Bubble, ohne Wake-Sound, Speaker-ID-
// Gating in der Whisper-Bridge entscheidet ob Stefan spricht oder z.B.
// die Frau / TV. Bei text != '' → wakeWordService.exitPassiveListening('speech')
// schaltet auf conversing, Brain antwortet, TTS spielt, resume → endConv →
// ... und passive listening startet von vorne (mit frischem Timer).
// useCallback damit der useEffect oben die Funktion stabil capturen kann.
const startPassiveStreamingRecording = useCallback(async () => {
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const location = await getCurrentLocation();
const passiveMs = await loadPassiveListenMs();
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: false,
location: location || null,
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
endpointMs: 1500,
hardCapMs: Math.max(passiveMs + 5000, 35000),
});
if (!ok) {
console.warn('[Chat] passive streaming start failed — exit passive listening');
wakeWordService.exitPassiveListening('manual').catch(() => {});
}
}, []);
// Wake Word Toggle Handler
const toggleWakeWord = useCallback(async () => {
if (wakeWordActive) {
+8
View File
@@ -91,6 +91,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
import TriggerBrowser from '../components/TriggerBrowser';
import SkillBrowser from '../components/SkillBrowser';
import OAuthBrowser from '../components/OAuthBrowser';
import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
import {
isWakeReadySoundEnabled,
@@ -136,6 +137,7 @@ const SETTINGS_SECTIONS = [
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
{ id: 'voice_id', icon: '🎤', label: 'Stimme einrichten', desc: 'Sprecher-Erkennung — nur deine Stimme triggert ARIA' },
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
@@ -1836,6 +1838,12 @@ const SettingsScreen: React.FC = () => {
</View>
</>)}
{/* === Voice-ID Enrollment (Sprecher-Erkennung) === */}
{currentSection === 'voice_id' && (<>
<Text style={styles.sectionTitle}>Stimme einrichten</Text>
<VoiceIdEnrollment />
</>)}
{/* === Sprachausgabe (geraetelokal) === */}
{currentSection === 'voice_output' && (<>
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
+115 -1
View File
@@ -26,8 +26,30 @@ import { acquireBackgroundAudio } from './backgroundAudio';
type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
type PassiveListenCallback = () => void;
export type WakeWordState = 'off' | 'armed' | 'conversing';
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
export async function loadPassiveListenMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
}
} catch {}
return PASSIVE_LISTEN_DEFAULT_MS;
}
export async function savePassiveListenMs(ms: number): Promise<void> {
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
}
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
@@ -103,6 +125,12 @@ class WakeWordService {
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
private detectionInProgress: boolean = false;
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
* beendet den listening-State und geht zurueck zu armed. */
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
private passiveListenCallbacks: PassiveListenCallback[] = [];
private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false;
@@ -225,6 +253,7 @@ class WakeWordService {
/** Komplett ausschalten (Ohr abschalten) */
async stop(): Promise<void> {
console.log('[WakeWord] Ohr deaktiviert');
this.cancelPassiveListenTimer();
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
@@ -407,6 +436,17 @@ class WakeWordService {
this.bargeListening = false;
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
// Anrede weitersprechen.
const passiveMs = await loadPassiveListenMs();
if (passiveMs > 0 && this.nativeReady) {
this.enterPassiveListening(passiveMs);
return;
}
if (this.nativeReady && OpenWakeWord) {
// Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
// OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
@@ -435,6 +475,80 @@ class WakeWordService {
this.setState('off');
}
/** Eintritt in den Passive-Listen-Modus: state='listening', Timer fuer
* Auto-Ende setzen, Callbacks feuern damit ChatScreen die passive
* Streaming-Aufnahme startet. OpenWakeWord bleibt AUS (Mic-Exklusivitaet —
* audioService braucht das Mikro fuer die passive Aufnahme).
* Speaker-ID-Gating (Phase 3) filtert fremde Stimmen auf der Bridge. */
private enterPassiveListening(durationMs: number): void {
this.cancelPassiveListenTimer();
this.setState('listening');
const seconds = Math.round(durationMs / 1000);
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
import('./logger').then(m => m.reportAppDebug('wake.passive',
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
this.passiveListenTimer = setTimeout(() => {
this.passiveListenTimer = null;
this.exitPassiveListening('timeout').catch(() => {});
}, durationMs);
this.passiveListenCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] passive cb err:', e); }
});
}
/** Verlassen des Passive-Listen-Modus.
* reason='speech' → User hat was gesagt (STT-Endpoint mit text) → uebergang
* in 'conversing' (Brain antwortet, TTS spielt, dann resume → endConversation
* → wieder passive listening, repeat).
* reason='timeout' → 30s nichts gehoert → zurueck zu armed (Wake-Word wieder an).
* reason='manual' → User hat App geschlossen / stopped → zurueck zu armed. */
async exitPassiveListening(reason: 'timeout' | 'speech' | 'manual'): Promise<void> {
if (this.state !== 'listening') return;
this.cancelPassiveListenTimer();
console.log('[WakeWord] Passive-Listen Ende (reason=%s)', reason);
import('./logger').then(m => m.reportAppDebug('wake.passive',
`exit reason=${reason}`)).catch(()=>{});
if (reason === 'speech') {
// Wechsel zu 'conversing' damit das Standard-Conversation-Flow greift
// (Brain-Response, TTS, resume etc.). Wake-Word bleibt aus (Mic belegt).
this.setState('conversing');
return;
}
// timeout oder manual → Wake-Word reaktivieren, armed-State.
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.start();
console.log('[WakeWord] zurueck zu armed nach passive-listen');
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
console.warn('[WakeWord] re-arm nach passive-listen failed:', err);
}
}
this.setState('off');
}
private cancelPassiveListenTimer(): void {
if (this.passiveListenTimer) {
clearTimeout(this.passiveListenTimer);
this.passiveListenTimer = null;
}
}
/** Subscribe auf Passive-Listen-Events: feuert wenn der Service in den
* passiven Modus eintritt. ChatScreen startet hier eine streaming-
* Aufnahme OHNE User-Bubble (passiv lauschen). */
onPassiveListen(callback: PassiveListenCallback): () => void {
this.passiveListenCallbacks.push(callback);
return () => {
this.passiveListenCallbacks = this.passiveListenCallbacks.filter(c => c !== callback);
};
}
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
+90 -111
View File
@@ -127,6 +127,25 @@ META_TOOLS = [
"items": {"type": "object"},
"description": "Argumente-Schema [{name, type, required, description}]",
},
"fast_patterns": {
"type": "array",
"items": {"type": "object"},
"description": (
"OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
"pause/next, Rollade hoch/runter etc.) eine Liste von "
"[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen "
"match (anchored Regex, case-insensitive) matched, ruft das "
"Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — "
"ohne Claude (~5s Latenz gespart). Match wird gegen den "
"normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; "
"schreibe Patterns mit ^...$ damit nur exakte Befehle matchen "
"und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer "
"Skills mit kreativem Output / parametrisierter Logik — die "
"brauchen Claude. Beispiel: "
"[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"},"
"\"reply\":\"Spotify: pausiert ⏸\"}]"
),
},
},
"required": ["name", "description", "entry_code"],
},
@@ -193,6 +212,16 @@ META_TOOLS = [
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
),
},
"fast_patterns": {
"type": "array",
"items": {"type": "object"},
"description": (
"Optional komplette Fast-Path-Patterns-Liste UEBERSCHREIBEN — "
"[{match, args, reply}]. Siehe skill_create-Beschreibung fuer "
"Format. Leere Liste = alle Fast-Paths entfernen (alles geht "
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
),
},
},
"required": ["name"],
},
@@ -782,61 +811,26 @@ META_TOOLS = [
]
# ── Spotify Fast-Path ──────────────────────────────────────────────────
# ── Fast-Path (Skill-deklariert) ───────────────────────────────────────
#
# Einfache Media-Commands (nächster Track, Pause, lauter, ...) gehen
# direkt aufs spotify-Skill statt durch die volle Claude-Reasoning-Pipeline.
# Latenz: ~1-1.5s statt 5-10s. Stefan-Bug 06/2026: "ARIA braucht ewig nur
# fuer 'nächster Track'". Wenn ein Pattern nicht matcht, faellt der Call
# wie bisher in die normale chat()-Loop und Claude entscheidet — keine
# Funktionalitaet geht verloren.
# Skills koennen in ihrem Manifest `fast_patterns` deklarieren — eine Liste
# von {match: regex, args: dict, reply: str}. Wenn ein User-Text gegen
# ein Pattern matcht, ruft das Brain direkt run_skill(name, args) auf und
# returnt `reply` an den User — Claude wird komplett uebersprungen. Spart
# 5-10s LLM-Latenz pro "reines Steuern"-Befehl.
#
# Patterns sind anchored (^...$) gegen normalisierten Text (lowercase,
# Endsatzzeichen weg, Whitespace gestrafft). Bewusst eng gefasst: lieber
# einmal in Claude fallen als ein Kontextsatz wie "ich war kurz zurueck"
# faelschlich als "previous track" interpretieren.
_SPOTIFY_FAST_PATTERNS: list[tuple[str, str, str, Optional[int]]] = [
# (regex, action, http-method, volume-delta)
# NEXT
(r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$", "next", "POST", None),
(r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$", "next", "POST", None),
# PREVIOUS
(r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$", "previous", "POST", None),
(r"^(zurueck|zurück)$", "previous", "POST", None),
# PAUSE
(r"^(pause|pausiere|pausieren|stop|stopp|halt)$", "pause", "PUT", None),
(r"^(musik|spotify) (pause|aus|stop|stopp)$", "pause", "PUT", None),
# PLAY / RESUME
(r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$", "play", "PUT", None),
(r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$", "play", "PUT", None),
# VOLUME — Delta wird auf den aktuell ermittelten Volume-Wert aufaddiert
(r"^(lauter|musik lauter|spotify lauter|volume hoch|lautstärke hoch)$", "volume", "PUT", 10),
(r"^(leiser|musik leiser|spotify leiser|volume runter|lautstärke runter)$", "volume", "PUT", -10),
(r"^(viel lauter|deutlich lauter)$", "volume", "PUT", 20),
(r"^(viel leiser|deutlich leiser)$", "volume", "PUT", -20),
]
# Patterns sollten anchored (^...$) gegen den normalisierten Text (lower-
# case, Endsatzzeichen weg, Whitespace gestrafft) geschrieben sein. Lieber
# eng matchen als breit — false-positives sind teurer als ein Cache-Miss.
#
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
# skill_create / skill_update, das Brain orchestriert nur.
def _spotify_fast_match(text: str) -> Optional[tuple[str, str, Optional[int]]]:
"""Returns (action, method, volume_delta) wenn ein Pattern matcht — sonst None."""
def _normalize_for_fast_match(text: str) -> str:
norm = (text or "").strip().lower()
norm = re.sub(r"[.!?]+$", "", norm)
norm = re.sub(r"\s+", " ", norm)
if not norm:
return None
for rx, action, method, delta in _SPOTIFY_FAST_PATTERNS:
if re.match(rx, norm):
return action, method, delta
return None
def _run_spotify_call(path: str, method: str, body: Optional[dict] = None) -> dict:
"""Fuehrt einen Spotify-Skill-Call aus. Skill-Args: path, method, body (JSON-String).
Returns das run_skill-Ergebnis."""
args: dict = {"path": path, "method": method}
if body is not None:
args["body"] = json.dumps(body)
return skills_mod.run_skill("spotify", args, timeout_sec=15)
return norm
def _skill_to_tool(s: dict) -> dict:
@@ -906,71 +900,52 @@ class Agent:
self._pending_events = []
return events
def _try_spotify_fast_path(self, user_message: str) -> Optional[str]:
"""Wenn die Nachricht ein einfacher Media-Command ist, direkt aufs
spotify-Skill routen und ein kurzes Reply zurueckgeben — Claude wird
komplett uebersprungen. Returnt None wenn kein Pattern matcht oder das
spotify-Skill nicht installiert ist (dann faellt's normal in Claude)."""
m = _spotify_fast_match(user_message)
if m is None:
return None
action, method, delta = m
def _try_skill_fast_path(self, user_message: str) -> Optional[str]:
"""Iteriert ueber alle aktiven Skills und probiert deren fast_patterns
gegen den normalisierten User-Text. Erster Treffer gewinnt — Skill
wird direkt aufgerufen, Reply geht ohne Claude zurueck.
# Skill muss installiert + aktiv sein. Sonst Fall-Through zu Claude.
try:
manifest = skills_mod.read_manifest("spotify")
except Exception:
manifest = None
if not manifest or not manifest.get("active", True):
logger.info("[spotify-fast] skill nicht verfuegbar — fall through zu Claude")
Returnt None wenn kein Pattern matcht. Bei Skill-Ausfuehrungs-Fehler
(ok=False) wird eine ehrliche Fehler-Reply gegeben statt durch Claude
zu fallen — sonst kostet ein gescheiterter Fast-Path doppelt (~1s
Skill-Versuch + 5-10s Claude). Bei unerwarteter Exception fallen wir
durch zu Claude (Claude kann ggf. besser diagnostizieren)."""
norm = _normalize_for_fast_match(user_message)
if not norm:
return None
logger.info("[spotify-fast] match action=%s method=%s delta=%s msg=%r",
action, method, delta, user_message[:60])
def _err_reply(label: str, res: dict) -> str:
# ok=False kommt von 401 (nicht eingeloggt), 404 (kein aktives
# Gerät) etc. — Skill schreibt den Spotify-Error nach stderr.
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120]
return f"Spotify: {label} fehlgeschlagen — {hint or 'siehe Brain-Log'}"
try:
if action == "next":
res = _run_spotify_call("/v1/me/player/next", method)
return "Spotify: nächster Track ⏭" if res.get("ok") else _err_reply("Skip", res)
if action == "previous":
res = _run_spotify_call("/v1/me/player/previous", method)
return "Spotify: vorheriger Track ⏮" if res.get("ok") else _err_reply("Zurück", res)
if action == "pause":
res = _run_spotify_call("/v1/me/player/pause", method)
return "Spotify: pausiert ⏸" if res.get("ok") else _err_reply("Pause", res)
if action == "play":
res = _run_spotify_call("/v1/me/player/play", method)
return "Spotify: spielt ▶" if res.get("ok") else _err_reply("Play", res)
if action == "volume" and delta is not None:
state = _run_spotify_call("/v1/me/player", "GET")
if not state.get("ok"):
return _err_reply("Lautstärke-Status", state)
cur_vol = 50
active_skills = [s for s in skills_mod.list_skills(active_only=False)
if s.get("active", True)]
for skill in active_skills:
patterns = skill.get("fast_patterns") or []
if not patterns:
continue
skill_name = skill.get("name") or ""
for pat in patterns:
rx = pat.get("match") or ""
if not rx:
continue
try:
out = (state.get("stdout") or "").strip()
if out:
data = json.loads(out)
dev = data.get("device") or {}
cur_vol = int(dev.get("volume_percent", 50))
if not re.match(rx, norm, re.IGNORECASE):
continue
except re.error:
# Sollte durch _normalize_fast_patterns rausgefiltert sein.
continue
args = pat.get("args") or {}
reply = pat.get("reply") or f"{skill_name}: ok"
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
skill_name, rx, user_message[:60])
try:
res = skills_mod.run_skill(skill_name, dict(args), timeout_sec=15)
except Exception as exc:
logger.warning("[spotify-fast] volume-state parse: %s", exc)
new_vol = max(0, min(100, cur_vol + delta))
res = _run_spotify_call(f"/v1/me/player/volume?volume_percent={new_vol}", "PUT")
logger.warning("[fast-path] %s exception — fall through zu Claude: %s",
skill_name, exc)
return None
if not res.get("ok"):
return _err_reply("Lautstärke", res)
arrow = "🔊" if delta > 0 else "🔉"
return f"Spotify: Lautstärke {new_vol}% {arrow}"
except Exception as exc:
logger.warning("[spotify-fast] action=%s exception — fall through zu Claude: %s",
action, exc)
return None
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120]
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
return reply
return None
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
@@ -985,9 +960,10 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Spotify Fast-Path: einfache Media-Commands ueberspringen Claude komplett.
# Spart 4-9s Latenz fuer 'naechster Track', 'Pause', 'lauter' etc.
fast_reply = self._try_spotify_fast_path(user_message)
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
fast_reply = self._try_skill_fast_path(user_message)
if fast_reply is not None:
self.conversation.add("user", user_message, source=source)
self.conversation.add("assistant", fast_reply)
@@ -1133,6 +1109,7 @@ class Agent:
args=arguments.get("args", []),
pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None,
author="aria",
)
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1196,6 +1173,8 @@ class Agent:
patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
patch["config_schema"] = arguments["config_schema"]
if "fast_patterns" in arguments and isinstance(arguments["fast_patterns"], list):
patch["fast_patterns"] = arguments["fast_patterns"]
if not patch:
return "FEHLER: keine Felder zum Update angegeben."
try:
+57
View File
@@ -45,6 +45,54 @@ logger = logging.getLogger("aria-brain")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
def _seed_spotify_fast_patterns() -> None:
"""One-shot Migration: schreibt Standard-Steuer-Patterns ins Spotify-Skill
wenn das Skill existiert + aktiv ist + noch keine fast_patterns hat.
Nach diesem Run kann ARIA die Patterns frei via skill_update aendern."""
manifest = skills_mod.read_manifest("spotify")
if not manifest:
logger.info("[migrate] spotify skill nicht vorhanden — nichts zu tun")
return
if manifest.get("fast_patterns"):
logger.info("[migrate] spotify hat schon fast_patterns (%d) — skip",
len(manifest["fast_patterns"]))
return
default_patterns = [
# NEXT
{"match": r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$",
"args": {"path": "/v1/me/player/next", "method": "POST"},
"reply": "Spotify: nächster Track ⏭"},
{"match": r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$",
"args": {"path": "/v1/me/player/next", "method": "POST"},
"reply": "Spotify: nächster Track ⏭"},
# PREVIOUS
{"match": r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$",
"args": {"path": "/v1/me/player/previous", "method": "POST"},
"reply": "Spotify: vorheriger Track ⏮"},
{"match": r"^(zurueck|zurück)$",
"args": {"path": "/v1/me/player/previous", "method": "POST"},
"reply": "Spotify: vorheriger Track ⏮"},
# PAUSE
{"match": r"^(pause|pausiere|pausieren|stop|stopp|halt)$",
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
"reply": "Spotify: pausiert ⏸"},
{"match": r"^(musik|spotify) (pause|aus|stop|stopp)$",
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
"reply": "Spotify: pausiert ⏸"},
# PLAY
{"match": r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$",
"args": {"path": "/v1/me/player/play", "method": "PUT"},
"reply": "Spotify: spielt ▶"},
{"match": r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$",
"args": {"path": "/v1/me/player/play", "method": "PUT"},
"reply": "Spotify: spielt ▶"},
]
skills_mod.update_skill("spotify", {"fast_patterns": default_patterns})
logger.info("[migrate] spotify fast_patterns gesetzt (%d Eintraege)",
len(default_patterns))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
@@ -54,6 +102,15 @@ async def lifespan(app: FastAPI):
logger.info("Lifespan: seed_rules angewendet (%s)", result)
except Exception as exc:
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
# Einmalige Migration: Spotify-Skill ohne fast_patterns kriegt die Standard-
# Patterns injiziert. Idempotent — wenn schon welche da sind, nichts tun.
# ARIA kann sie spaeter via skill_update beliebig erweitern/ersetzen.
try:
_seed_spotify_fast_patterns()
except Exception as exc:
logger.warning("Lifespan: spotify fast_patterns Migration: %s", exc)
task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
+48
View File
@@ -131,6 +131,54 @@ SEED_RULES: List[dict] = [
"Skill-Friedhof und Stefan muss aufraeumen."
),
},
{
"migration_key": "seed/skill-rule/fast-patterns-for-control",
"type": "rule",
"title": "Skill-Regel: fast_patterns fuer reines Steuern (spart 5-10s Latenz)",
"category": "skills",
"content": (
"Wenn Du einen Skill baust oder aktualisierst, der **reine Steuer-"
"Befehle** behandelt (Licht an/aus, Spotify pause/next, Rollade "
"hoch/runter, Heizung +1°), trag ins Manifest `fast_patterns` ein. "
"Format pro Eintrag: `{match: \"^regex$\", args: {...}, reply: \"Text\"}`.\n"
"\n"
"Wirkung: das Brain matched eingehende User-Texte BEVOR Claude gerufen "
"wird. Match → run_skill(name, args) direkt → reply zurueck → Claude "
"uebersprungen. Stefan spart 5-10 Sekunden pro Befehl. Praktisch "
"Pflicht im Auto, wo Latenz nervt.\n"
"\n"
"REGELN beim Patterns schreiben:\n"
" - Mit `^` und `$` anchorn — sonst matched `pause` mitten in `pause "
"die musik dann erzaehl mir nen witz` und zerschiesst den Befehl.\n"
" - Case-insensitive (Brain matched mit re.IGNORECASE), Endsatzzeichen "
"werden vorher entfernt — schreibe Lowercase ohne Punkt.\n"
" - Mehrere Varianten = mehrere Eintraege (`^pause$`, `^pausiere$`, "
"`^stop$`). Sprachlich wechselt Stefan zwischen synonymen Kurzformen.\n"
" - reply = kurze Bestaetigung in genau einem Satz, gerne mit Emoji.\n"
"\n"
"NIE fast_patterns fuer:\n"
" - Skills mit kreativem Output (zusammenfassen, generieren, raten).\n"
" - Skills mit Parametern die aus Freitext extrahiert werden muessten "
" ('spiele jazz' geht nicht — was ist 'jazz'? Lass Claude entscheiden).\n"
" - Skills mit Multi-Step-Logik (z.B. Volumen +10 = erst Status holen, "
" rechnen, setzen). Wenn unbedingt: in den Skill-Code packen und "
" dem Skill einen `action`-Arg geben.\n"
"\n"
"Beispiel komplett:\n"
"```\n"
"fast_patterns = [\n"
" {\"match\": \"^pause$\",\n"
" \"args\": {\"path\": \"/v1/me/player/pause\", \"method\": \"PUT\"},\n"
" \"reply\": \"Spotify: pausiert ⏸\"}\n"
"]\n"
"```\n"
"\n"
"Stefan-Hinweis 06/2026: das war frueher hardcoded in agent.py fuer "
"Spotify und musste fuer jeden neuen Steuer-Skill nachgepflegt werden. "
"Jetzt steckt's pro Skill im Manifest — dein Job, ARIA, das gleich "
"mitzudenken wenn der Use-Case passt."
),
},
{
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
"type": "rule",
+36
View File
@@ -164,6 +164,7 @@ def create_skill(
pip_packages: Optional[list[str]] = None,
author: str = "aria",
config_schema: Optional[list] = None,
fast_patterns: Optional[list] = None,
) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -213,6 +214,7 @@ def create_skill(
"version": "1.0",
"author": author,
"config_schema": _normalize_config_schema(config_schema),
"fast_patterns": _normalize_fast_patterns(fast_patterns),
"version_history": [],
}
write_manifest(name, manifest)
@@ -261,6 +263,38 @@ def _normalize_config_schema(schema: Optional[list]) -> list:
return out
def _normalize_fast_patterns(patterns: Optional[list]) -> list:
"""Filter + Normalisiert fast_patterns. Erwartet Liste von Dicts mit:
- match (str) : Regex, wird gegen normalisierten User-Text (lowercase,
Endsatzzeichen weg, Whitespace gestrafft) gematched.
Sollte mit ^...$ anchored sein damit keine Teilmatches
reinrutschen. re.IGNORECASE wird automatisch gesetzt.
- args (dict?): Args fuer run_skill — leerer Dict wenn weggelassen.
- reply (str) : Fixe Antwort die ohne Claude an den User geht.
Patterns mit kaputter Regex werden ausgefiltert + geloggt — sonst wuerde
der ganze Fast-Path-Pass jedes Mal crashen wenn ARIA mal ein Pattern
falsch baut."""
if not patterns:
return []
out = []
for p in patterns:
if not isinstance(p, dict):
continue
match = (p.get("match") or "").strip()
reply = (p.get("reply") or "").strip()
if not match or not reply:
continue
try:
re.compile(match)
except re.error as exc:
logger.warning("fast_patterns: Regex %r kaputt — geskippt: %s", match, exc)
continue
args = p.get("args") if isinstance(p.get("args"), dict) else {}
out.append({"match": match, "args": args, "reply": reply[:300]})
return out
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
venv = skill_dir / "venv"
logger.info("venv erstellen: %s", venv)
@@ -307,6 +341,8 @@ def update_skill(name: str, patch: dict) -> dict:
manifest[k] = v
if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
if "fast_patterns" in patch:
manifest["fast_patterns"] = _normalize_fast_patterns(patch["fast_patterns"])
# Code austauschen
if "entry_code" in patch and patch["entry_code"]:
+91
View File
@@ -764,6 +764,42 @@
</div>
</div>
<!-- Voice-ID (Sprecher-Erkennung) -->
<div class="settings-section">
<h2>Voice-ID (Sprecher-Erkennung)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
ARIA erkennt Stefans Stimme anhand eines Fingerprints (SpeechBrain ECAPA-TDNN).
Andere Sprecher (TV, Hintergrund-Gespraeche) werden gefiltert — keine Brain-
Calls, keine Tokens. Enrollment passiert in der App (Settings → Stimme einrichten),
weil das Handy-Mikro auch im Betrieb hoert.
</div>
<div class="card" style="max-width:500px;">
<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;">Match-Threshold:</label>
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
oninput="document.getElementById('voice-id-threshold-display').textContent = this.value"
onchange="sendVoiceConfig()"
style="flex:1;">
<span id="voice-id-threshold-display" style="color:#E0E0F0;font-family:monospace;min-width:40px;text-align:right;">0.50</span>
</div>
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
Niedriger = mehr Treffer auch bei Nebengeraeuschen (false-positives).
Hoeher = strenger, kann Stefan auch mal verpassen. 0.50 ist konservativer Default.
</div>
<div style="display:flex;gap:8px;">
<button class="btn secondary" onclick="refreshVoiceIdStatus()" style="padding:6px 14px;font-size:12px;">
🔄 Status aktualisieren
</button>
<button class="btn danger" onclick="deleteVoiceId()" style="padding:6px 14px;font-size:12px;">
🗑 Fingerprint löschen
</button>
</div>
</div>
</div>
<!-- Runtime-Konfiguration -->
<div class="settings-section">
<h2>Runtime-Konfiguration</h2>
@@ -1475,6 +1511,46 @@
setIfPresent('diag-flux-keyword-raw', msg.fluxKeywordRaw);
setIfPresent('diag-flux-keyword-switch', msg.fluxKeywordSwitch);
setIfPresent('diag-flux-hf-token', msg.huggingfaceToken);
// Voice-ID-Threshold wiederherstellen (Default 0.50)
if (msg.voiceIdThreshold !== undefined && msg.voiceIdThreshold !== null) {
const slider = document.getElementById('diag-voice-id-threshold');
const display = document.getElementById('voice-id-threshold-display');
if (slider) slider.value = msg.voiceIdThreshold;
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
}
return;
}
if (msg.type === 'voice_id_status_response') {
const el = document.getElementById('voice-id-status');
if (!el) return;
if (msg.payload && msg.payload.ok === false) {
el.innerHTML = '<span style="color:#FF6E6E;">⚠ Whisper-Bridge nicht erreichbar: ' +
(msg.payload.error || 'unbekannt') + '</span>';
return;
}
const p = msg.payload || msg;
if (p.enrolled) {
const when = p.updated_at ? new Date(p.updated_at * 1000).toLocaleString('de-DE') : '?';
const totalSec = (p.sample_durations_s || []).reduce((a, b) => a + b, 0);
el.innerHTML = '<span style="color:#34C759;">✓ Enrolled</span> · ' +
p.sample_count + ' Samples (' + totalSec.toFixed(1) + 's) · ' +
'aktualisiert ' + when + ' · dim=' + (p.embedding_dim || '?');
} else {
el.innerHTML = '<span style="color:#FFD60A;">○ Nicht enrolled</span> — ' +
'in der App unter "Stimme einrichten" 5-10× je 3s aufnehmen.';
}
return;
}
if (msg.type === 'voice_id_delete_response') {
const p = msg.payload || msg;
if (p.removed) {
alert('Fingerprint gelöscht — Voice-ID-Gating fällt zurück auf Fail-Open.');
} else {
alert('Es war kein Fingerprint vorhanden.');
}
refreshVoiceIdStatus();
return;
}
@@ -2607,6 +2683,17 @@
});
}
function refreshVoiceIdStatus() {
const el = document.getElementById('voice-id-status');
if (el) el.textContent = '⏳ Status wird abgefragt...';
send({ action: 'voice_id_status' });
}
function deleteVoiceId() {
if (!confirm('Voice-ID-Fingerprint loeschen?\n\nDanach muss in der App neu enrolled werden.')) return;
send({ action: 'voice_id_delete' });
}
function deleteXttsVoice(name) {
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
send({ action: 'xtts_delete_voice', name });
@@ -2823,12 +2910,15 @@
const fluxKeywordRaw = document.getElementById('diag-flux-keyword-raw')?.value;
const fluxKeywordSwitch = document.getElementById('diag-flux-keyword-switch')?.value;
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
send({
action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
voiceIdThreshold,
});
const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) {
@@ -3354,6 +3444,7 @@
loadRuntimeConfig();
loadOnboardingQR();
loadOAuthServices();
refreshVoiceIdStatus();
} else if (tab === 'brain') {
loadBrainStatus();
loadBrainMemoryList();
+15
View File
@@ -2367,6 +2367,12 @@ wss.on("connection", (ws) => {
if (msg.huggingfaceToken !== undefined) {
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
}
// Voice-ID Match-Threshold (0.30-0.70). Wird von der whisper-bridge
// ueber den config-Broadcast aufgenommen — Phase 3 nutzt's beim Gating.
if (msg.voiceIdThreshold !== undefined && !isNaN(msg.voiceIdThreshold)) {
const t = parseFloat(msg.voiceIdThreshold);
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
}
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
@@ -2390,6 +2396,15 @@ wss.on("connection", (ws) => {
handleGetModel(ws);
} else if (msg.action === "set_model") {
handleSetModel(ws, msg.model);
} else if (msg.action === "voice_id_status") {
// An whisper-bridge weiterleiten + Antwort an Browser zurueck
const reqId = `vid_${Date.now().toString(36)}`;
sendToRVS_withResponse("voice_id_status_request", { requestId: reqId },
"voice_id_status_response", ws);
} else if (msg.action === "voice_id_delete") {
const reqId = `viddel_${Date.now().toString(36)}`;
sendToRVS_withResponse("voice_id_delete_request", { requestId: reqId },
"voice_id_delete_response", ws);
}
// get_openclaw_config entfernt — aria-core ist raus.
} catch {}
+6
View File
@@ -42,6 +42,12 @@ const ALLOWED_TYPES = new Set([
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
"stt_partial", "stt_endpoint", "stt_stream_done",
// Speaker-ID / Voice-Enrollment (Phase 1+2): App schickt 5-10 Samples zur
// whisper-bridge, die berechnet einen Voice-Fingerprint (Embedding-Vektor)
// und nutzt ihn um nur Stefans Stimme an Whisper STT durchzulassen.
"voice_id_status_request", "voice_id_status_response",
"voice_id_enroll_request", "voice_id_enroll_response",
"voice_id_delete_request", "voice_id_delete_response",
// File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
"file_version_list_request", "file_version_list_response",
+3
View File
@@ -85,4 +85,7 @@ services:
# ein Modell muss nur einmal pro
# Maschine geladen werden, kein
# Re-Download bei Container-Restart.
- ./voice-id:/voice-id # Speaker-ID-Fingerprint (Stefans
# Stimm-Embedding) persistent zwischen
# Container-Restarts.
restart: unless-stopped
+10 -2
View File
@@ -1,14 +1,22 @@
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg \
python3 python3-pip ffmpeg git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# PyTorch CUDA-Wheels zuerst (sonst zieht speechbrain CPU-only Torch rein
# falls f5tts den Cache noch nicht geseedet hat).
RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
--index-url https://download.pytorch.org/whl/cu121
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py .
COPY bridge.py speaker_id.py ./
CMD ["python3", "bridge.py"]
+146
View File
@@ -33,6 +33,8 @@ import sys
import tempfile
import time
from dataclasses import dataclass, field
import speaker_id
from typing import Optional
import numpy as np
@@ -61,6 +63,7 @@ ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
STREAM_TRANSCRIBE_INTERVAL_MS = 700 # alle 700ms transkribieren waehrend Stream laeuft
STREAM_SPEAKER_CHECK_MS = 1500 # Mindest-Audio fuer Speaker-ID-Pruefung
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
@@ -309,6 +312,12 @@ class StreamSession:
last_transcribe_at: float = 0.0
closed: bool = False # nach stream_end gesetzt
endpoint_sent: bool = False # Endpoint nur einmal feuern
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
# synthetischem stt_endpoint(text='', reason='speaker_mismatch').
speaker_checked: bool = False
speaker_match: Optional[bool] = None
speaker_similarity: float = 0.0
class SessionManager:
@@ -420,6 +429,77 @@ class SessionManager:
sid[:8], now - sess.last_chunk_at)
self.drop(sid)
async def _check_speaker(self, sess: StreamSession, ws) -> None:
"""Speaker-ID einmalig pro Session: nimmt die ersten ~1.5s Audio,
rechnet das Embedding, vergleicht mit dem persistierten Fingerprint.
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
Session sofort beendet mit synthetischem stt_endpoint."""
sess.speaker_checked = True
# 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:
# Zu wenig — durchlassen
sess.speaker_match = True
sess.speaker_similarity = 0.0
return
try:
loop = asyncio.get_running_loop()
is_match, sim = await loop.run_in_executor(
None, speaker_id.verify, head_bytes,
)
except Exception as exc:
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
sess.request_id[:8], exc)
sess.speaker_match = True
sess.speaker_similarity = 0.0
return
sess.speaker_match = is_match
sess.speaker_similarity = sim
logger.info("Stream %s: speaker-check sim=%.2f%s (threshold=%.2f)",
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
speaker_id.DEFAULT_THRESHOLD)
await _debug_log(ws, "speaker.check",
f"id={sess.request_id[:12]} sim={sim:.2f} "
f"thr={speaker_id.DEFAULT_THRESHOLD:.2f} "
f"{'MATCH' if is_match else 'REJECT'}")
if not is_match:
await self._finalize_speaker_mismatch(sess, ws, sim)
async def _finalize_speaker_mismatch(self, sess: StreamSession, ws,
similarity: float) -> None:
"""Bei Speaker-Mismatch: synthetisches stt_endpoint (text='', reason=
'speaker_mismatch') schicken damit der App-Pfad sauber endet
(endConversation), Session droppen. Kein Whisper-Transcribe.
Spart die Token + die STT-Latenz fuer fremde Stimmen."""
if sess.endpoint_sent:
return
sess.endpoint_sent = True
duration_s = self._buffer_duration_ms(sess) / 1000.0
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
sess.request_id[:8], similarity, duration_s)
endpoint_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:
endpoint_payload["location"] = sess.location
await _send(ws, "stt_endpoint", endpoint_payload)
await _send(ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "",
"reason": "speaker_mismatch",
})
self.drop(sess.request_id)
async def _tick_session(self, sess: StreamSession, now: float) -> None:
ws = self._ws
if ws is None:
@@ -440,6 +520,15 @@ class SessionManager:
await self._finalize(sess, ws, reason="stream_end")
return
# Speaker-ID Gating: sobald genug Audio da ist, einmalig pruefen ob's
# Stefan ist. Bei Mismatch → synthetisches Endpoint, Session zu.
# Wenn kein Fingerprint persistiert ist, returnt verify() fail-open
# mit (True, 0.0) — keine Auswirkung.
if not sess.speaker_checked and audio_ms >= STREAM_SPEAKER_CHECK_MS:
await self._check_speaker(sess, ws)
if sess.speaker_match is False:
return # Session bereits beendet via _finalize_speaker_mismatch
# Noch zu wenig Audio fuer eine erste Transkription
if audio_ms < STREAM_MIN_AUDIO_MS:
return
@@ -729,10 +818,67 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
sessions.end_session(req_id)
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")
try:
status = speaker_id.status()
except Exception as exc:
await _send(ws, "voice_id_status_response", {
"requestId": req_id, "ok": False, "error": str(exc)[:200],
})
continue
await _send(ws, "voice_id_status_response", {
"requestId": req_id, "ok": True, **status,
})
elif mtype == "voice_id_enroll_request":
# samples: Liste von base64-kodierten int16-LE-PCM-Buffern,
# 16kHz mono, je ~3-5s. App nimmt sie nacheinander auf und
# schickt sie zusammen.
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
)
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],
})
continue
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"),
})
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":
# Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog
# damit Stefan im laufenden Betrieb via Diagnostic-Settings
# die Logs an/aus schalten kann.
# Voice-ID Match-Threshold (von Diagnostic gesendet) auf das
# speaker_id-Modul setzen — wird erst in Phase 3 beim Gating
# genutzt, aber persistiert bereits jetzt.
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 "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE
+3
View File
@@ -2,3 +2,6 @@ faster-whisper==1.0.3
websockets>=12.0
numpy>=1.24
requests>=2.31
# Speaker-ID via SpeechBrain ECAPA-TDNN — Stimme von Stefan zuverlaessig
# rauskennen damit Hintergrund-Gespraeche keine Brain-Calls triggern.
speechbrain>=1.0.0
+231
View File
@@ -0,0 +1,231 @@
"""
Speaker-ID Backend fuer ARIAs Stimmen-Erkennung.
Nutzt SpeechBrain ECAPA-TDNN (192-dim Embeddings, auf VoxCeleb-1+2 trainiert).
Fingerprint = gemittelter, L2-normalisierter Embedding-Vektor aus N
Enrollment-Samples. Verify: cosine_similarity(neue_aufnahme, fingerprint).
Persistenz: /voice-id/fingerprint.json (Float-Liste + Metadaten).
Modell-Cache: /root/.cache/huggingface/ (Bind-Mount mit f5tts geteilt).
Verhalten OHNE Enrollment (kein Fingerprint vorhanden):
verify() → (True, 0.0) — Fail-open, damit Speaker-ID-Gating den
ungeenrollten Brain-Pfad nicht versehentlich blockiert.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
import numpy as np
logger = logging.getLogger(__name__)
VOICE_ID_DIR = Path(os.environ.get("VOICE_ID_DIR", "/voice-id"))
FINGERPRINT_FILE = VOICE_ID_DIR / "fingerprint.json"
# Cosine-Threshold: 0.5 ist konservativ (wenig false-positives), 0.3 ist
# locker (mehr Treffer auch bei Nebengeraeuschen). Stefan kann's per
# Diagnostic-Setting feintunen.
DEFAULT_THRESHOLD = 0.5
# Minimal-Sample-Laenge fuer ein verlaessliches Embedding (~1s @ 16kHz int16 = 32000 bytes)
MIN_SAMPLE_BYTES = 32000
_model = None
def _ensure_loaded():
"""Lazy-Load des ECAPA-TDNN. Holt das Modell beim ersten Aufruf von HF;
danach cached im HF-Cache-Volume. Erste Init: ~30s download + load,
danach <1s warm. Wirft bei Fehler — Caller muss catchen + fail-open."""
global _model
if _model is not None:
return _model
import torch
from speechbrain.inference.speaker import EncoderClassifier
device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info("[speaker-id] loading ECAPA-TDNN on %s ...", device)
_model = EncoderClassifier.from_hparams(
source="speechbrain/spkrec-ecapa-voxceleb",
savedir="/root/.cache/huggingface/speechbrain-ecapa",
run_opts={"device": device},
)
logger.info("[speaker-id] model ready (device=%s)", device)
return _model
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
Ergebnis: rohes PCM."""
if (len(audio_bytes) >= 44
and audio_bytes[:4] == b"RIFF"
and audio_bytes[8:12] == b"WAVE"):
import io
import wave
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
sr = wav.getframerate()
ch = wav.getnchannels()
sw = wav.getsampwidth()
if sr != 16000:
raise ValueError(f"WAV-Samplerate {sr} != 16000")
if ch != 1:
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
if sw != 2:
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
return wav.readframes(wav.getnframes())
return audio_bytes
def _audio_bytes_to_tensor(audio_bytes: bytes):
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
import torch
raw = _normalize_audio_bytes(audio_bytes)
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
return torch.from_numpy(arr).unsqueeze(0)
def embed(audio_bytes: bytes) -> np.ndarray:
"""Berechnet das Speaker-Embedding fuer einen Audio-Chunk.
Erwartet 16kHz int16 LE PCM Mono. Returns 192-dim numpy float32."""
import torch
model = _ensure_loaded()
wav = _audio_bytes_to_tensor(audio_bytes)
with torch.no_grad():
emb = model.encode_batch(wav)
return emb.squeeze().cpu().numpy().astype(np.float32)
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Kosinus-Aehnlichkeit zwischen zwei 1D-Vektoren, Range [-1, 1].
Hoeher = aehnlicher. Bei normalisierten Vektoren ist das gleich dem Skalarprodukt."""
na = np.linalg.norm(a)
nb = np.linalg.norm(b)
if na < 1e-9 or nb < 1e-9:
return 0.0
return float(np.dot(a, b) / (na * nb))
def save_fingerprint(embeddings: list[np.ndarray], sample_durations_s: list[float]) -> dict:
"""Mittelt + L2-normalisiert die Embeddings und schreibt sie nach
FINGERPRINT_FILE. Returns das gespeicherte Dict."""
if not embeddings:
raise ValueError("Keine Embeddings zum Speichern")
VOICE_ID_DIR.mkdir(parents=True, exist_ok=True)
stacked = np.stack(embeddings)
mean = stacked.mean(axis=0)
mean = mean / max(np.linalg.norm(mean), 1e-9)
data = {
"version": 1,
"embedding": mean.tolist(),
"embedding_dim": int(mean.shape[0]),
"sample_count": len(embeddings),
"sample_durations_s": [float(s) for s in sample_durations_s],
"updated_at": int(time.time()),
}
FINGERPRINT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.info("[speaker-id] fingerprint gespeichert: %d Samples, dim=%d, total_s=%.1f",
len(embeddings), mean.shape[0], sum(sample_durations_s))
return data
def load_fingerprint() -> Optional[dict]:
"""Returns das Fingerprint-Dict oder None wenn noch nicht enrolled."""
if not FINGERPRINT_FILE.exists():
return None
try:
return json.loads(FINGERPRINT_FILE.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("[speaker-id] fingerprint laden fehlgeschlagen: %s", exc)
return None
def delete_fingerprint() -> bool:
"""Loescht den Fingerprint (z.B. fuer Re-Enrollment). True wenn was weg ist."""
if FINGERPRINT_FILE.exists():
FINGERPRINT_FILE.unlink()
logger.info("[speaker-id] fingerprint geloescht")
return True
return False
def verify(audio_bytes: bytes, threshold: Optional[float] = None) -> tuple[bool, float]:
"""Returns (is_match, similarity).
Wenn threshold=None: nutzt den Modul-Default (DEFAULT_THRESHOLD) — der wird
vom config-Broadcast zur Laufzeit auf den Diagnostic-Slider-Wert gesetzt.
Default-Arg-Bindung waere zur Def-Zeit, also bewusst None statt direkt.
Fail-open: wenn kein Fingerprint vorhanden ist oder das Embedding-Modell
crasht, returnt (True, 0.0) — kein Filtering. Sonst wuerde ein kaputter
Speaker-ID-Service die ganze Aufnahme blockieren."""
if threshold is None:
threshold = DEFAULT_THRESHOLD
fp = load_fingerprint()
if fp is None:
return True, 0.0
if len(audio_bytes) < MIN_SAMPLE_BYTES:
# Zu wenig Audio fuer ein verlaessliches Embedding → durchlassen
return True, 0.0
try:
saved_emb = np.array(fp["embedding"], dtype=np.float32)
new_emb = embed(audio_bytes)
except Exception as exc:
logger.warning("[speaker-id] verify embed failed: %s — fail-open", exc)
return True, 0.0
sim = cosine_similarity(new_emb, saved_emb)
return sim >= threshold, sim
def status() -> dict:
"""Status-Snapshot fuer die App / Diagnostic."""
fp = load_fingerprint()
return {
"enrolled": fp is not None,
"sample_count": fp.get("sample_count", 0) if fp else 0,
"sample_durations_s": fp.get("sample_durations_s", []) if fp else [],
"updated_at": fp.get("updated_at") if fp else None,
"embedding_dim": fp.get("embedding_dim") if fp else None,
"default_threshold": DEFAULT_THRESHOLD,
}
def enroll_from_samples(samples_b64: list[str]) -> dict:
"""Verarbeitet base64-Samples (16kHz int16 LE PCM Mono) zu einem neuen
Fingerprint. Returns Status-Dict. Wirft ValueError wenn nichts brauchbar ist."""
if not samples_b64:
raise ValueError("Keine Samples uebergeben")
embeddings: list[np.ndarray] = []
durations: list[float] = []
rejected: list[dict] = []
for idx, s in enumerate(samples_b64):
try:
raw = base64.b64decode(s)
except Exception as exc:
rejected.append({"index": idx, "reason": f"base64: {exc}"})
continue
if len(raw) < MIN_SAMPLE_BYTES:
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
continue
try:
emb = embed(raw)
embeddings.append(emb)
durations.append(len(raw) / 2 / 16000.0)
except Exception as exc:
rejected.append({"index": idx, "reason": f"embed: {exc}"})
if not embeddings:
raise ValueError(
f"Keine Samples konnten verarbeitet werden ({len(rejected)} rejected). "
f"Details: {rejected[:3]}"
)
fingerprint = save_fingerprint(embeddings, durations)
fingerprint["rejected"] = rejected
return fingerprint