/** * 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({ state: 'loading' }); const [samples, setSamples] = useState([]); const [recording, setRecording] = useState(false); const [recordCountdown, setRecordCountdown] = useState(0); const [enrollPending, setEnrollPending] = useState(false); const [pendingReqId, setPendingReqId] = useState(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 ( 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. {/* Status-Karte */} Status {status.state === 'loading' && ( Wird abgefragt... )} {status.state === 'unenrolled' && ( ○ Nicht enrolled — Stimme einrichten ↓ )} {status.state === 'enrolled' && ( <> ✓ Enrolled — {status.sampleCount} Samples ({status.durations.reduce((a, b) => a + b, 0).toFixed(1)}s gesamt) Aktualisiert {new Date(status.updatedAt * 1000).toLocaleString('de-DE')} · dim={status.dim} )} {status.state === 'error' && ( ⚠ {status.message} )} {/* Aufnahme-Bereich */} Samples ({samples.length}/{SAMPLES_REQUIRED}) {samples.length === 0 && !recording && ( Tipp: sprich klare normale Sätze, je 3-4 Sekunden Audio. )} {samples.map((sample, idx) => ( Sample {idx + 1} · {(sample.durationMs / 1000).toFixed(1)}s removeSample(idx)} disabled={enrollPending}> ))} {recording ? ( <> Aufnahme läuft… {recordCountdown}s ) : ( ⏺ Sample {samples.length + 1} aufnehmen )} {samples.length > 0 && !recording && ( setSamples([])} disabled={enrollPending} style={s.resetBtn} > Alle verwerfen )} {/* Aktionen */} {enrollPending ? ( <> Wird verarbeitet… ) : ( ✓ Speichern ({samples.length}/{SAMPLES_REQUIRED}) )} {/* Verwaltung */} {status.state === 'enrolled' && ( Verwaltung 🔄 Status aktualisieren 🗑 Fingerprint löschen (Re-Enrollment nötig) )} ); }; 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;