Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f714cfc336 | |||
| a0dc0cf20e | |||
| ac53af5c24 | |||
| e3fe27f736 | |||
| 6e19adab87 |
@@ -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 10902
|
versionCode 10903
|
||||||
versionName "0.1.9.2"
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.9.2",
|
"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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user