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" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10901 versionCode 10903
versionName "0.1.9.1" versionName "0.1.9.3"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -7,11 +7,16 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord import android.media.AudioRecord
import android.media.AudioRecordingConfiguration
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor import android.media.audiofx.NoiseSuppressor
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -104,6 +109,20 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster // Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L private var recordingStartedMs: Long = 0L
// 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. * Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy") * modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
@@ -167,54 +186,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
} }
try { try {
val minBuf = AudioRecord.getMinBufferSize( acquireAndStartRecording()
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()
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und // PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet. // 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}") Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
} }
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply { // AudioRecordingCallback registrieren: andere Apps (WhatsApp-
isDaemon = true // Sprachnachricht, Telefonate etc.) wollen das Mic — wir geben
start() // es vorruebergehend frei statt sie ins Leere recorden zu lassen.
} registerRecordingCallback()
Log.i(TAG, "Lauschen gestartet (model=$modelName)") Log.i(TAG, "Lauschen gestartet (model=$modelName)")
promise.resolve(true) 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() { private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {} try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {} try { ns?.release() } catch (_: Exception) {}
@@ -256,15 +297,9 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
@ReactMethod @ReactMethod
fun stop(promise: Promise) { fun stop(promise: Promise) {
running.set(false) unregisterRecordingCallback()
try { externallyPaused = false
captureThread?.join(1500) stopAndReleaseRecording()
} catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock() releaseWakeLock()
Log.i(TAG, "Lauschen gestoppt") Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true) promise.resolve(true)
@@ -272,18 +307,94 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
@ReactMethod @ReactMethod
fun dispose(promise: Promise) { fun dispose(promise: Promise) {
running.set(false) unregisterRecordingCallback()
try { captureThread?.join(1000) } catch (_: InterruptedException) {} externallyPaused = false
captureThread = null stopAndReleaseRecording()
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock() releaseWakeLock()
disposeSessions() disposeSessions()
promise.resolve(true) 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() { private fun releaseWakeLock() {
try { try {
wakeLock?.takeIf { it.isHeld }?.release() wakeLock?.takeIf { it.isHeld }?.release()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.9.1", "version": "0.1.9.3",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "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 ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio'; import audioService from '../services/audio';
import wakeWordService from '../services/wakeword'; import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
import phoneCallService from '../services/phoneCall'; import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound'; import { playWakeReadySound } from '../services/wakeReadySound';
import { import {
@@ -273,7 +273,7 @@ const ChatScreen: React.FC = () => {
const [gpsEnabled, setGpsEnabled] = useState(false); const [gpsEnabled, setGpsEnabled] = useState(false);
const [wakeWordActive, setWakeWordActive] = useState(false); const [wakeWordActive, setWakeWordActive] = useState(false);
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button // 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 [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null); const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
const [inboxVisible, setInboxVisible] = useState(false); const [inboxVisible, setInboxVisible] = useState(false);
@@ -487,9 +487,16 @@ const ChatScreen: React.FC = () => {
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im // Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber // Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach // Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
// 'armed' oder 'off' fallen, darf Spotify wieder. // 'armed' oder 'off' fallen, darf Spotify wieder. 'listening' soll
if (s === 'conversing') audioService.acquireConversationFocus(); // Spotify ebenfalls leise halten (User darf jederzeit weitersprechen).
if (s === 'conversing' || s === 'listening') audioService.acquireConversationFocus();
else audioService.releaseConversationFocus(); 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 // Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
// (armed oder conversing), soll der App-Prozess im Hintergrund am Leben // (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
// bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen. // bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
@@ -1346,12 +1353,18 @@ const ChatScreen: React.FC = () => {
// - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor. // - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
// aria-bridge bekommt das gleiche Event und triggert Brain // aria-bridge bekommt das gleiche Event und triggert Brain
// direkt. App muss nix mehr senden. // direkt. App muss nix mehr senden.
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error). // - text == '' → cancelStreamingRecording (no-speech / hardcap / error /
// Konversation beenden wie frueher der "kein Speech"-Fall. // speaker_mismatch). Konversation beenden, oder bei
// passive-listening: nochmal lauschen.
const unsubEndpoint = audioService.onSttEndpoint((ev) => { const unsubEndpoint = audioService.onSttEndpoint((ev) => {
if (ev.text && ev.text.trim()) { if (ev.text && ev.text.trim()) {
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)', console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS); ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
// 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) + // Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
// chat(sender=aria) wie im Legacy-Pfad. // chat(sender=aria) wie im Legacy-Pfad.
} else { } else {
@@ -1361,11 +1374,28 @@ const ChatScreen: React.FC = () => {
if (ev.audioRequestId) { if (ev.audioRequestId) {
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId)); setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
} }
wakeWordService.endConversation(); // Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
if (!wakeWordService.isActive()) setWakeWordActive(false); // 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. // Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen // Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert). // (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
@@ -1430,11 +1460,38 @@ const ChatScreen: React.FC = () => {
unsubWake(); unsubWake();
unsubEndpoint(); unsubEndpoint();
unsubBarge(); unsubBarge();
unsubPassive();
unsubTtsStart(); unsubTtsStart();
unsubTtsEnd(); unsubTtsEnd();
}; };
}, [wakeWordActive]); }, [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 // Wake Word Toggle Handler
const toggleWakeWord = useCallback(async () => { const toggleWakeWord = useCallback(async () => {
if (wakeWordActive) { if (wakeWordActive) {
+8
View File
@@ -91,6 +91,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
import TriggerBrowser from '../components/TriggerBrowser'; import TriggerBrowser from '../components/TriggerBrowser';
import SkillBrowser from '../components/SkillBrowser'; import SkillBrowser from '../components/SkillBrowser';
import OAuthBrowser from '../components/OAuthBrowser'; import OAuthBrowser from '../components/OAuthBrowser';
import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger'; import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
import { import {
isWakeReadySoundEnabled, isWakeReadySoundEnabled,
@@ -136,6 +137,7 @@ const SETTINGS_SECTIONS = [
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' }, { id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' }, { id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' }, { 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: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' }, { id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' }, { id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
@@ -1836,6 +1838,12 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</>)} </>)}
{/* === Voice-ID Enrollment (Sprecher-Erkennung) === */}
{currentSection === 'voice_id' && (<>
<Text style={styles.sectionTitle}>Stimme einrichten</Text>
<VoiceIdEnrollment />
</>)}
{/* === Sprachausgabe (geraetelokal) === */} {/* === Sprachausgabe (geraetelokal) === */}
{currentSection === 'voice_output' && (<> {currentSection === 'voice_output' && (<>
<Text style={styles.sectionTitle}>Sprachausgabe</Text> <Text style={styles.sectionTitle}>Sprachausgabe</Text>
+115 -1
View File
@@ -26,8 +26,30 @@ import { acquireBackgroundAudio } from './backgroundAudio';
type WakeWordCallback = () => void; type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => 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'; export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
@@ -103,6 +125,12 @@ class WakeWordService {
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger * Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */ * waehrend ARIA noch redet, NICHT vom Guard blockieren. */
private detectionInProgress: boolean = false; 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 keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false; private nativeReady: boolean = false;
@@ -225,6 +253,7 @@ class WakeWordService {
/** Komplett ausschalten (Ohr abschalten) */ /** Komplett ausschalten (Ohr abschalten) */
async stop(): Promise<void> { async stop(): Promise<void> {
console.log('[WakeWord] Ohr deaktiviert'); console.log('[WakeWord] Ohr deaktiviert');
this.cancelPassiveListenTimer();
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {} try { await OpenWakeWord.stop(); } catch {}
} }
@@ -407,6 +436,17 @@ class WakeWordService {
this.bargeListening = false; this.bargeListening = false;
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{}); `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) { if (this.nativeReady && OpenWakeWord) {
// Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS): // Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
// OpenWakeWord.start() ist idempotent (Kotlin checkt running.get() // OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
@@ -435,6 +475,80 @@ class WakeWordService {
this.setState('off'); 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 /** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed. * maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund * Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
+90 -111
View File
@@ -127,6 +127,25 @@ META_TOOLS = [
"items": {"type": "object"}, "items": {"type": "object"},
"description": "Argumente-Schema [{name, type, required, description}]", "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"], "required": ["name", "description", "entry_code"],
}, },
@@ -193,6 +212,16 @@ META_TOOLS = [
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV." "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"], "required": ["name"],
}, },
@@ -782,61 +811,26 @@ META_TOOLS = [
] ]
# ── Spotify Fast-Path ────────────────────────────────────────────────── # ── Fast-Path (Skill-deklariert) ───────────────────────────────────────
# #
# Einfache Media-Commands (nächster Track, Pause, lauter, ...) gehen # Skills koennen in ihrem Manifest `fast_patterns` deklarieren — eine Liste
# direkt aufs spotify-Skill statt durch die volle Claude-Reasoning-Pipeline. # von {match: regex, args: dict, reply: str}. Wenn ein User-Text gegen
# Latenz: ~1-1.5s statt 5-10s. Stefan-Bug 06/2026: "ARIA braucht ewig nur # ein Pattern matcht, ruft das Brain direkt run_skill(name, args) auf und
# fuer 'nächster Track'". Wenn ein Pattern nicht matcht, faellt der Call # returnt `reply` an den User — Claude wird komplett uebersprungen. Spart
# wie bisher in die normale chat()-Loop und Claude entscheidet — keine # 5-10s LLM-Latenz pro "reines Steuern"-Befehl.
# Funktionalitaet geht verloren.
# #
# Patterns sind anchored (^...$) gegen normalisierten Text (lowercase, # Patterns sollten anchored (^...$) gegen den normalisierten Text (lower-
# Endsatzzeichen weg, Whitespace gestrafft). Bewusst eng gefasst: lieber # case, Endsatzzeichen weg, Whitespace gestrafft) geschrieben sein. Lieber
# einmal in Claude fallen als ein Kontextsatz wie "ich war kurz zurueck" # eng matchen als breit — false-positives sind teurer als ein Cache-Miss.
# faelschlich als "previous track" interpretieren. #
_SPOTIFY_FAST_PATTERNS: list[tuple[str, str, str, Optional[int]]] = [ # Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
# (regex, action, http-method, volume-delta) # skill_create / skill_update, das Brain orchestriert nur.
# 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),
]
def _normalize_for_fast_match(text: str) -> str:
def _spotify_fast_match(text: str) -> Optional[tuple[str, str, Optional[int]]]:
"""Returns (action, method, volume_delta) wenn ein Pattern matcht — sonst None."""
norm = (text or "").strip().lower() norm = (text or "").strip().lower()
norm = re.sub(r"[.!?]+$", "", norm) norm = re.sub(r"[.!?]+$", "", norm)
norm = re.sub(r"\s+", " ", norm) norm = re.sub(r"\s+", " ", norm)
if not norm: return 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)
def _skill_to_tool(s: dict) -> dict: def _skill_to_tool(s: dict) -> dict:
@@ -906,71 +900,52 @@ class Agent:
self._pending_events = [] self._pending_events = []
return events return events
def _try_spotify_fast_path(self, user_message: str) -> Optional[str]: def _try_skill_fast_path(self, user_message: str) -> Optional[str]:
"""Wenn die Nachricht ein einfacher Media-Command ist, direkt aufs """Iteriert ueber alle aktiven Skills und probiert deren fast_patterns
spotify-Skill routen und ein kurzes Reply zurueckgeben — Claude wird gegen den normalisierten User-Text. Erster Treffer gewinnt — Skill
komplett uebersprungen. Returnt None wenn kein Pattern matcht oder das wird direkt aufgerufen, Reply geht ohne Claude zurueck.
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
# Skill muss installiert + aktiv sein. Sonst Fall-Through zu Claude. Returnt None wenn kein Pattern matcht. Bei Skill-Ausfuehrungs-Fehler
try: (ok=False) wird eine ehrliche Fehler-Reply gegeben statt durch Claude
manifest = skills_mod.read_manifest("spotify") zu fallen — sonst kostet ein gescheiterter Fast-Path doppelt (~1s
except Exception: Skill-Versuch + 5-10s Claude). Bei unerwarteter Exception fallen wir
manifest = None durch zu Claude (Claude kann ggf. besser diagnostizieren)."""
if not manifest or not manifest.get("active", True): norm = _normalize_for_fast_match(user_message)
logger.info("[spotify-fast] skill nicht verfuegbar — fall through zu Claude") if not norm:
return None return None
logger.info("[spotify-fast] match action=%s method=%s delta=%s msg=%r", active_skills = [s for s in skills_mod.list_skills(active_only=False)
action, method, delta, user_message[:60]) if s.get("active", True)]
for skill in active_skills:
def _err_reply(label: str, res: dict) -> str: patterns = skill.get("fast_patterns") or []
# ok=False kommt von 401 (nicht eingeloggt), 404 (kein aktives if not patterns:
# Gerät) etc. — Skill schreibt den Spotify-Error nach stderr. continue
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines() skill_name = skill.get("name") or ""
hint = (tail[-1] if tail else "")[:120] for pat in patterns:
return f"Spotify: {label} fehlgeschlagen — {hint or 'siehe Brain-Log'}" rx = pat.get("match") or ""
if not rx:
try: continue
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
try: try:
out = (state.get("stdout") or "").strip() if not re.match(rx, norm, re.IGNORECASE):
if out: continue
data = json.loads(out) except re.error:
dev = data.get("device") or {} # Sollte durch _normalize_fast_patterns rausgefiltert sein.
cur_vol = int(dev.get("volume_percent", 50)) 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: except Exception as exc:
logger.warning("[spotify-fast] volume-state parse: %s", exc) logger.warning("[fast-path] %s exception — fall through zu Claude: %s",
new_vol = max(0, min(100, cur_vol + delta)) skill_name, exc)
res = _run_spotify_call(f"/v1/me/player/volume?volume_percent={new_vol}", "PUT") return None
if not res.get("ok"): if not res.get("ok"):
return _err_reply("Lautstärke", res) tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
arrow = "🔊" if delta > 0 else "🔉" hint = (tail[-1] if tail else "")[:120]
return f"Spotify: Lautstärke {new_vol}% {arrow}" return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
except Exception as exc: return reply
logger.warning("[spotify-fast] action=%s exception — fall through zu Claude: %s",
action, exc)
return None
return None return None
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ── # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
@@ -985,9 +960,10 @@ class Agent:
# Events vom letzten Turn weglassen # Events vom letzten Turn weglassen
self._pending_events = [] self._pending_events = []
# Spotify Fast-Path: einfache Media-Commands ueberspringen Claude komplett. # Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Spart 4-9s Latenz fuer 'naechster Track', 'Pause', 'lauter' etc. # Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
fast_reply = self._try_spotify_fast_path(user_message) # 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: if fast_reply is not None:
self.conversation.add("user", user_message, source=source) self.conversation.add("user", user_message, source=source)
self.conversation.add("assistant", fast_reply) self.conversation.add("assistant", fast_reply)
@@ -1133,6 +1109,7 @@ class Agent:
args=arguments.get("args", []), args=arguments.get("args", []),
pip_packages=arguments.get("pip_packages", []), pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None, config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None,
author="aria", author="aria",
) )
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1196,6 +1173,8 @@ class Agent:
patch["pip_packages"] = arguments["pip_packages"] patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list): if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
patch["config_schema"] = arguments["config_schema"] 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: if not patch:
return "FEHLER: keine Felder zum Update angegeben." return "FEHLER: keine Felder zum Update angegeben."
try: try:
+57
View File
@@ -45,6 +45,54 @@ logger = logging.getLogger("aria-brain")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant") QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333")) 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben, """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) logger.info("Lifespan: seed_rules angewendet (%s)", result)
except Exception as exc: except Exception as exc:
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", 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)) task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet") logger.info("Lifespan: Trigger-Loop gestartet")
try: try:
+48
View File
@@ -131,6 +131,54 @@ SEED_RULES: List[dict] = [
"Skill-Friedhof und Stefan muss aufraeumen." "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", "migration_key": "seed/skill-rule/no-hardcoded-credentials",
"type": "rule", "type": "rule",
+36
View File
@@ -164,6 +164,7 @@ def create_skill(
pip_packages: Optional[list[str]] = None, pip_packages: Optional[list[str]] = None,
author: str = "aria", author: str = "aria",
config_schema: Optional[list] = None, config_schema: Optional[list] = None,
fast_patterns: Optional[list] = None,
) -> dict: ) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs. """Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -213,6 +214,7 @@ def create_skill(
"version": "1.0", "version": "1.0",
"author": author, "author": author,
"config_schema": _normalize_config_schema(config_schema), "config_schema": _normalize_config_schema(config_schema),
"fast_patterns": _normalize_fast_patterns(fast_patterns),
"version_history": [], "version_history": [],
} }
write_manifest(name, manifest) write_manifest(name, manifest)
@@ -261,6 +263,38 @@ def _normalize_config_schema(schema: Optional[list]) -> list:
return out 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: def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
venv = skill_dir / "venv" venv = skill_dir / "venv"
logger.info("venv erstellen: %s", venv) logger.info("venv erstellen: %s", venv)
@@ -307,6 +341,8 @@ def update_skill(name: str, patch: dict) -> dict:
manifest[k] = v manifest[k] = v
if "config_schema" in patch: if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"]) 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 # Code austauschen
if "entry_code" in patch and patch["entry_code"]: if "entry_code" in patch and patch["entry_code"]:
+91
View File
@@ -764,6 +764,42 @@
</div> </div>
</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 --> <!-- Runtime-Konfiguration -->
<div class="settings-section"> <div class="settings-section">
<h2>Runtime-Konfiguration</h2> <h2>Runtime-Konfiguration</h2>
@@ -1475,6 +1511,46 @@
setIfPresent('diag-flux-keyword-raw', msg.fluxKeywordRaw); setIfPresent('diag-flux-keyword-raw', msg.fluxKeywordRaw);
setIfPresent('diag-flux-keyword-switch', msg.fluxKeywordSwitch); setIfPresent('diag-flux-keyword-switch', msg.fluxKeywordSwitch);
setIfPresent('diag-flux-hf-token', msg.huggingfaceToken); 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; 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) { function deleteXttsVoice(name) {
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return; if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
send({ action: 'xtts_delete_voice', name }); send({ action: 'xtts_delete_voice', name });
@@ -2823,12 +2910,15 @@
const fluxKeywordRaw = document.getElementById('diag-flux-keyword-raw')?.value; const fluxKeywordRaw = document.getElementById('diag-flux-keyword-raw')?.value;
const fluxKeywordSwitch = document.getElementById('diag-flux-keyword-switch')?.value; const fluxKeywordSwitch = document.getElementById('diag-flux-keyword-switch')?.value;
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.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({ send({
action: 'send_voice_config', action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel, ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile, f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep, f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken, fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
voiceIdThreshold,
}); });
const statusEl = document.getElementById('voice-status'); const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) { if (statusEl && xttsVoice) {
@@ -3354,6 +3444,7 @@
loadRuntimeConfig(); loadRuntimeConfig();
loadOnboardingQR(); loadOnboardingQR();
loadOAuthServices(); loadOAuthServices();
refreshVoiceIdStatus();
} else if (tab === 'brain') { } else if (tab === 'brain') {
loadBrainStatus(); loadBrainStatus();
loadBrainMemoryList(); loadBrainMemoryList();
+15
View File
@@ -2367,6 +2367,12 @@ wss.on("connection", (ws) => {
if (msg.huggingfaceToken !== undefined) { if (msg.huggingfaceToken !== undefined) {
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim(); 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 { try {
fs.mkdirSync("/shared/config", { recursive: true }); fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2)); fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
@@ -2390,6 +2396,15 @@ wss.on("connection", (ws) => {
handleGetModel(ws); handleGetModel(ws);
} else if (msg.action === "set_model") { } else if (msg.action === "set_model") {
handleSetModel(ws, msg.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. // get_openclaw_config entfernt — aria-core ist raus.
} catch {} } catch {}
+6
View File
@@ -42,6 +42,12 @@ const ALLOWED_TYPES = new Set([
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip. // die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
"stt_stream_start", "stt_audio_chunk", "stt_stream_end", "stt_stream_start", "stt_audio_chunk", "stt_stream_end",
"stt_partial", "stt_endpoint", "stt_stream_done", "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, // File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
// alte Versionen herunterladen, Restore = non-destructive neuer Commit. // alte Versionen herunterladen, Restore = non-destructive neuer Commit.
"file_version_list_request", "file_version_list_response", "file_version_list_request", "file_version_list_response",
+3
View File
@@ -85,4 +85,7 @@ services:
# ein Modell muss nur einmal pro # ein Modell muss nur einmal pro
# Maschine geladen werden, kein # Maschine geladen werden, kein
# Re-Download bei Container-Restart. # Re-Download bei Container-Restart.
- ./voice-id:/voice-id # Speaker-ID-Fingerprint (Stefans
# Stimm-Embedding) persistent zwischen
# Container-Restarts.
restart: unless-stopped restart: unless-stopped
+10 -2
View File
@@ -1,14 +1,22 @@
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04 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 \ 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app 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 . COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py . COPY bridge.py speaker_id.py ./
CMD ["python3", "bridge.py"] CMD ["python3", "bridge.py"]
+146
View File
@@ -33,6 +33,8 @@ import sys
import tempfile import tempfile
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
import speaker_id
from typing import Optional from typing import Optional
import numpy as np 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) # Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
STREAM_TRANSCRIBE_INTERVAL_MS = 700 # alle 700ms transkribieren waehrend Stream laeuft 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_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was 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 STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
@@ -309,6 +312,12 @@ class StreamSession:
last_transcribe_at: float = 0.0 last_transcribe_at: float = 0.0
closed: bool = False # nach stream_end gesetzt closed: bool = False # nach stream_end gesetzt
endpoint_sent: bool = False # Endpoint nur einmal feuern 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: class SessionManager:
@@ -420,6 +429,77 @@ class SessionManager:
sid[:8], now - sess.last_chunk_at) sid[:8], now - sess.last_chunk_at)
self.drop(sid) 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: async def _tick_session(self, sess: StreamSession, now: float) -> None:
ws = self._ws ws = self._ws
if ws is None: if ws is None:
@@ -440,6 +520,15 @@ class SessionManager:
await self._finalize(sess, ws, reason="stream_end") await self._finalize(sess, ws, reason="stream_end")
return 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 # Noch zu wenig Audio fuer eine erste Transkription
if audio_ms < STREAM_MIN_AUDIO_MS: if audio_ms < STREAM_MIN_AUDIO_MS:
return return
@@ -729,10 +818,67 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
f"received id={req_id[:12]} reason={payload.get('reason', '')}") f"received id={req_id[:12]} reason={payload.get('reason', '')}")
sessions.end_session(req_id) 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": elif mtype == "config":
# Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog # Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog
# damit Stefan im laufenden Betrieb via Diagnostic-Settings # damit Stefan im laufenden Betrieb via Diagnostic-Settings
# die Logs an/aus schalten kann. # 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: if "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE old = _DEBUG_LOG_TO_BRIDGE
+3
View File
@@ -2,3 +2,6 @@ faster-whisper==1.0.3
websockets>=12.0 websockets>=12.0
numpy>=1.24 numpy>=1.24
requests>=2.31 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