Compare commits

..

7 Commits

Author SHA1 Message Date
duffyduck 1baa1a7a08 release: bump version to 0.1.9.4 2026-06-13 13:54:12 +02:00
duffyduck fc0f91d1e6 feat(projects): Threads im Hauptchat verankert (Stefan-Konzept)
Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.

Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
  (/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
  _summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
  Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
  den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
  last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
  {id}/end,{id}/archive, PATCH /{id}}.

Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
  damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.

App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
  Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
  zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
  oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
  Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.

Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
  pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
  project_changed-Event-Broadcast.

Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
  zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 13:51:26 +02:00
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
21 changed files with 2361 additions and 33 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 10902 versionCode 10904
versionName "0.1.9.2" versionName "0.1.9.4"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.9.2", "version": "0.1.9.4",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+359
View File
@@ -0,0 +1,359 @@
/**
* Projekt-Übersicht + Switcher.
*
* Modal-Komponente die:
* - Den aktuellen Projekt-Status zeigt (Hauptchat oder konkretes Projekt)
* - Die Projekt-Liste rendert (sortiert nach letzter Aktivität)
* - Per Tap zwischen Projekten wechseln lässt
* - Neue Projekte anlegen kann
* - Bestehende editieren/beenden/archivieren
*
* Eingesetzt von ChatScreen (über den Projekt-Indicator) und von
* SettingsScreen.tsx in der Section 'projects'.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Project } from '../services/brainApi';
import rvs from '../services/rvs';
interface Props {
/** Optional — wenn als Modal genutzt, sonst inline */
visible?: boolean;
onClose?: () => void;
/** Wird gerufen wenn sich das aktive Projekt aendert — ChatScreen
* refresht dann seinen Banner-State. */
onActiveChanged?: (project: Project | null) => void;
}
function _fmtRel(unixSec: number): string {
if (!unixSec) return '?';
const diff = (Date.now() / 1000) - unixSec;
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
if (diff < 86400 * 14) return `vor ${Math.floor(diff / 86400)} Tagen`;
return new Date(unixSec * 1000).toLocaleDateString('de-DE');
}
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged }) => {
const [projects, setProjects] = useState<Project[]>([]);
const [activeId, setActiveId] = useState<string>('');
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.getProjectStatus()
.then(status => {
setProjects(status.projects || []);
setActiveId(status.active_id || '');
onActiveChanged?.(status.active);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [onActiveChanged]);
useEffect(() => { if (visible) load(); }, [visible, load]);
// Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig
useEffect(() => {
if (!visible) return;
const unsub = rvs.onStateChange((state) => { if (state === 'connected') load(); });
return () => unsub();
}, [visible, load]);
const switchTo = useCallback((id: string) => {
brainApi.switchProject(id)
.then(status => {
setActiveId(status.active_id || '');
onActiveChanged?.(status.active);
})
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [onActiveChanged]);
const createProject = useCallback(() => {
const name = newName.trim();
if (!name) return;
brainApi.createProject({ name, description: newDesc.trim() })
.then(() => {
setNewName(''); setNewDesc(''); setNewOpen(false);
load();
})
.catch(e => Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e)));
}, [newName, newDesc, load]);
const openEdit = useCallback((p: Project) => {
setEditing(p);
setEditName(p.name);
setEditDesc(p.description || '');
}, []);
const saveEdit = useCallback(() => {
if (!editing) return;
const patch: Partial<Pick<Project, 'name' | 'description'>> = {};
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
if (Object.keys(patch).length === 0) { setEditing(null); return; }
brainApi.updateProject(editing.id, patch)
.then(() => { setEditing(null); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [editing, editName, editDesc, load]);
const endProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" beenden?`,
'Bleibt sichtbar, kann nicht mehr aktiv sein außer mit explizitem Wiedereintritt.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Beenden', onPress: () => {
brainApi.endProject(p.id).then(() => load()).catch(e => Alert.alert('Fehler', String(e?.message || e)));
}},
]);
}, [load]);
const archiveProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" archivieren?`,
'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Archivieren', style: 'destructive', onPress: () => {
brainApi.archiveProject(p.id)
.then(() => { setEditing(null); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}},
]);
}, [load]);
// ── Render ────────────────────────────────────────────────
const renderItem = ({ item }: { item: Project }) => {
const isActive = item.id === activeId;
return (
<TouchableOpacity
onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive]}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> AKTIV</Text>}
</View>
{item.description ? (
<Text style={s.rowDesc} numberOfLines={2}>{item.description}</Text>
) : null}
<Text style={s.rowMeta}>
{item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)}
</Text>
</View>
</TouchableOpacity>
);
};
const body = (
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
{/* Header */}
<View style={s.header}>
{onClose && (
<TouchableOpacity onPress={onClose} style={s.headerBtn}>
<Text style={s.headerBtnText}></Text>
</TouchableOpacity>
)}
<Text style={s.headerTitle}>Projekte</Text>
<TouchableOpacity onPress={() => setNewOpen(true)} style={s.headerBtn}>
<Text style={[s.headerBtnText, { color: '#34C759' }]}>+ Neu</Text>
</TouchableOpacity>
</View>
{/* Hauptchat-Eintrag (immer oben) */}
<TouchableOpacity
onPress={() => switchTo('')}
style={[s.row, !activeId && s.rowActive]}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
{!activeId && <Text style={s.activeBadge}> AKTIV</Text>}
</View>
<Text style={s.rowMeta}>Standard-Verlauf, keine Projekt-Zuordnung</Text>
</View>
</TouchableOpacity>
{loading ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" />
</View>
) : err ? (
<Text style={s.errorText}> {err}</Text>
) : (
<FlatList
data={projects}
keyExtractor={p => p.id}
renderItem={renderItem}
ListEmptyComponent={
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
Lass uns ein Projekt 'XY' anlegen".
</Text>
}
/>
)}
{/* Neu-Anlegen Modal */}
<Modal visible={newOpen} animationType="slide" transparent onRequestClose={() => setNewOpen(false)}>
<View style={s.modalOverlay}>
<View style={s.modalCard}>
<Text style={s.modalTitle}>Neues Projekt</Text>
<TextInput
value={newName}
onChangeText={setNewName}
placeholder="Name (z.B. 'Frankreich-Urlaub')"
placeholderTextColor="#555570"
style={s.input}
autoFocus
/>
<TextInput
value={newDesc}
onChangeText={setNewDesc}
placeholder="Beschreibung kurz, hilft beim Wiederfinden"
placeholderTextColor="#555570"
style={[s.input, { height: 70 }]}
multiline
/>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<TouchableOpacity onPress={() => setNewOpen(false)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
<Text style={s.modalBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity onPress={createProject} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
<Text style={s.modalBtnText}>Anlegen + aktivieren</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Edit Modal */}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={() => setEditing(null)}>
<View style={s.modalOverlay}>
<View style={s.modalCard}>
<Text style={s.modalTitle}>Projekt bearbeiten</Text>
<TextInput
value={editName}
onChangeText={setEditName}
placeholder="Name"
placeholderTextColor="#555570"
style={s.input}
/>
<TextInput
value={editDesc}
onChangeText={setEditDesc}
placeholder="Beschreibung"
placeholderTextColor="#555570"
style={[s.input, { height: 70 }]}
multiline
/>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
<Text style={s.modalBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity onPress={saveEdit} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
<Text style={s.modalBtnText}>Speichern</Text>
</TouchableOpacity>
</View>
{editing && editing.status !== 'ended' && (
<TouchableOpacity onPress={() => endProject(editing)} style={s.tertiaryBtn}>
<Text style={s.tertiaryBtnText}>⏹ Projekt beenden</Text>
</TouchableOpacity>
)}
{editing && (
<TouchableOpacity onPress={() => archiveProject(editing)} style={s.tertiaryBtn}>
<Text style={[s.tertiaryBtnText, { color: '#E55C5C' }]}>🗑 Archivieren</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
</View>
);
// Wenn als Modal genutzt
if (onClose) {
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
{body}
</Modal>
);
}
return body;
};
const s = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 14,
borderBottomWidth: 1,
borderColor: '#1E1E2E',
backgroundColor: '#080810',
},
headerBtn: { padding: 8, minWidth: 60 },
headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' },
headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' },
row: {
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderColor: '#1E1E2E',
},
rowActive: {
backgroundColor: 'rgba(52,199,89,0.08)',
borderLeftWidth: 3,
borderLeftColor: '#34C759',
},
rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' },
rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 },
rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 },
activeBadge: { color: '#34C759', fontSize: 10, fontWeight: '800' },
statusBadge: { color: '#FFD60A', fontSize: 10, fontWeight: '700',
backgroundColor: 'rgba(255,214,10,0.15)', paddingHorizontal: 6,
paddingVertical: 2, borderRadius: 4 },
errorText: { color: '#FF6E6E', padding: 16, textAlign: 'center', fontSize: 13 },
emptyText: { color: '#555570', padding: 24, textAlign: 'center', fontSize: 13, lineHeight: 19 },
modalOverlay: {
flex: 1, backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center', paddingHorizontal: 20,
},
modalCard: { backgroundColor: '#15151E', borderRadius: 12, padding: 18 },
modalTitle: { color: '#E0E0F0', fontSize: 18, fontWeight: '700', marginBottom: 14 },
input: {
backgroundColor: '#0A0A14', borderRadius: 6, color: '#E0E0F0',
paddingHorizontal: 12, paddingVertical: 10, fontSize: 14, marginBottom: 8,
borderWidth: 1, borderColor: '#2A2A3E',
},
modalBtn: { flex: 1, alignItems: 'center', paddingVertical: 11, borderRadius: 6 },
modalBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' },
tertiaryBtn: { alignItems: 'center', paddingVertical: 10, marginTop: 8 },
tertiaryBtnText: { color: '#FFD60A', fontSize: 13, fontWeight: '600' },
});
export default ProjectsBrowser;
@@ -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;
+115 -6
View File
@@ -35,7 +35,9 @@ 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 ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import phoneCallService from '../services/phoneCall'; import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound'; import { playWakeReadySound } from '../services/wakeReadySound';
import { import {
@@ -273,13 +275,15 @@ 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);
const [showJumpDown, setShowJumpDown] = useState(false); const [showJumpDown, setShowJumpDown] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false);
const [projectsVisible, setProjectsVisible] = useState(false);
const [activeProject, setActiveProject] = useState<BrainProject | null>(null);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
@@ -457,6 +461,19 @@ const ChatScreen: React.FC = () => {
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
// sofort greift, ohne Context- oder Event-System) // sofort greift, ohne Context- oder Event-System)
// Aktives Projekt initial laden + bei RVS-Reconnect refreshen.
// Wird zusaetzlich nach jedem chat-Response refreshed (siehe handleAriaMessage).
useEffect(() => {
const loadProject = () => {
brainApi.getProjectStatus()
.then(s => setActiveProject(s.active || null))
.catch(() => {});
};
loadProject();
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadProject(); });
return () => unsub();
}, []);
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
const enabled = await AsyncStorage.getItem('aria_tts_enabled'); const enabled = await AsyncStorage.getItem('aria_tts_enabled');
@@ -487,9 +504,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.
@@ -788,6 +812,15 @@ const ChatScreen: React.FC = () => {
return; return;
} }
// project_changed: ARIA hat in einem Tool-Call ein Projekt erstellt /
// betreten / verlassen / beendet. Banner refreshen.
if (message.type === 'project_changed') {
brainApi.getProjectStatus()
.then(s => setActiveProject(s.active || null))
.catch(() => {});
return;
}
if (message.type === 'skill_created') { if (message.type === 'skill_created') {
const p = (message.payload || {}) as any; const p = (message.payload || {}) as any;
const skillMsg: ChatMessage = { const skillMsg: ChatMessage = {
@@ -1346,12 +1379,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,9 +1400,26 @@ 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));
} }
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
// Sonst endConversation wie bisher.
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
startPassiveStreamingRecording();
} else {
wakeWordService.endConversation(); wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false); 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.
@@ -1430,11 +1486,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) {
@@ -2400,6 +2483,32 @@ const ChatScreen: React.FC = () => {
); );
})()} })()}
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */}
<TouchableOpacity
onPress={() => setProjectsVisible(true)}
style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 12, paddingVertical: 6,
backgroundColor: activeProject ? 'rgba(52,199,89,0.10)' : 'transparent',
borderBottomWidth: activeProject ? 2 : 1,
borderColor: activeProject ? '#34C759' : '#1E1E2E',
}}
>
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
</Text>
<Text style={{ fontSize: 11, color: '#555570' }}>
{activeProject ? 'wechseln ' : 'Projekte '}
</Text>
</TouchableOpacity>
{/* Projekt-Modal */}
<ProjectsBrowser
visible={projectsVisible}
onClose={() => setProjectsVisible(false)}
onActiveChanged={(p) => setActiveProject(p)}
/>
{/* Suchleiste mit Treffer-Navigation */} {/* Suchleiste mit Treffer-Navigation */}
{searchVisible && ( {searchVisible && (
<View style={styles.searchBar}> <View style={styles.searchBar}>
+23 -1
View File
@@ -91,6 +91,8 @@ 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 ProjectsBrowser from '../components/ProjectsBrowser';
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,10 +138,12 @@ 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' },
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' }, { id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
{ id: 'projects', icon: '📁', label: 'Projekte', desc: 'Thread-Bündel im Hauptchat — verwalten, wechseln, beenden' },
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' }, { id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' }, { id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' }, { id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
@@ -1278,7 +1282,7 @@ const SettingsScreen: React.FC = () => {
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat // Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested- // (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
// scrolling laesst sonst nur in eine Richtung scrollen. // scrolling laesst sonst nur in eine Richtung scrollen.
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'} scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth' && currentSection !== 'projects'}
> >
{currentSection === null && ( {currentSection === null && (
@@ -1836,6 +1840,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>
@@ -2181,6 +2191,18 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</>)} </>)}
{/* === Projekte === */}
{currentSection === 'projects' && (<>
<Text style={styles.sectionTitle}>Projekte</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Thread-Bündel im Hauptchat. Tap auf ein Projekt aktivieren, alle weiteren Nachrichten gehen
dort rein. Long-Press bearbeiten. + Neu" oder zu ARIA: „lass uns ein Projekt anlegen".
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<ProjectsBrowser />
</View>
</>)}
{/* === Gedaechtnis === */} {/* === Gedaechtnis === */}
{currentSection === 'memory' && (<> {currentSection === 'memory' && (<>
<Text style={styles.sectionTitle}>Gedächtnis</Text> <Text style={styles.sectionTitle}>Gedächtnis</Text>
+69
View File
@@ -151,6 +151,24 @@ export interface OAuthAppConfig {
token_url?: string | null; token_url?: string | null;
} }
/** Projekt — Stefans Threading-Konzept im Hauptchat. */
export interface Project {
id: string;
name: string;
description: string;
status: 'active' | 'ended' | 'archived';
created_at: number;
updated_at: number;
last_activity_at: number;
turn_count: number;
}
export interface ProjectStatus {
active_id: string;
active: Project | null;
projects: Project[];
}
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */ /** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
export interface Skill { export interface Skill {
name: string; name: string;
@@ -521,6 +539,57 @@ export const brainApi = {
timeoutMs: 15000, timeoutMs: 15000,
}); });
}, },
// ── Projekte ───────────────────────────────────────────────────
/** Kompletter Status: aktives Projekt + Liste. */
getProjectStatus(): Promise<ProjectStatus> {
return _send('/projects/status');
},
/** Nur die Liste — fuer Sidebar/Drawer. */
listProjects(includeArchived: boolean = false): Promise<Project[]> {
return _send(`/projects/list${includeArchived ? '?include_archived=true' : ''}`)
.then((r: any) => r?.projects || []);
},
/** Neues Projekt anlegen — wird automatisch aktiviert. */
createProject(body: { name: string; description?: string }): Promise<Project> {
return _send('/projects/create', {
method: 'POST',
body: { description: '', ...body },
});
},
/** Aktives Projekt wechseln. Leerer projectId = Hauptthread. */
switchProject(projectId: string): Promise<ProjectStatus> {
return _send('/projects/switch', {
method: 'POST',
body: { project_id: projectId },
});
},
/** Projekt als beendet markieren (bleibt sichtbar, aktiv ist dann der Hauptthread). */
endProject(projectId: string): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}/end`, {
method: 'POST',
});
},
/** Projekt archivieren (verschwindet aus der Default-Liste). */
archiveProject(projectId: string): Promise<{ id: string; status: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/archive`, {
method: 'POST',
});
},
/** Projekt-Metadaten patchen (name / description). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: patch,
});
},
}; };
export default brainApi; export default brainApi;
+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
+244 -8
View File
@@ -32,6 +32,7 @@ import skills as skills_mod
import triggers as triggers_mod import triggers as triggers_mod
import watcher as watcher_mod import watcher as watcher_mod
import oauth as oauth_mod import oauth as oauth_mod
import projects as projects_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start # FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -808,6 +809,104 @@ META_TOOLS = [
}, },
}, },
}, },
# ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
{
"type": "function",
"function": {
"name": "project_create",
"description": (
"Legt ein neues Projekt an und macht es ZUR AKTIVEN Bühne. "
"Nutze das wenn Stefan sagt 'lass uns ein Projekt für X anlegen' "
"oder ein Thema klar als zusammenhängend bezeichnet. NICHT für "
"Ad-hoc-Fragen — Projekte sind für wiederkehrende, mehrere Tage "
"spannende Themen (Spotify-Setup, Renovierung, Reise-Planung)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Kurzer Name, wie ein Buchtitel ('Aria-Wakeword', 'Frankreich-Urlaub')."},
"description": {"type": "string", "description": "1-Satz worum's geht. Hilft beim Wiedererkennen."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_enter",
"description": (
"Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — "
"'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt "
"tagged jeder neue Turn die project_id. Bei sehr alten Projekten: "
"vorher project_summary aufrufen damit Du Stefan abholst."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name oder Teil davon."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_exit",
"description": (
"Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze "
"wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "project_list",
"description": "Listet alle Projekte mit Status und letzter Aktivität. Bevor Du ein neues anlegst: hier prüfen ob's schon eins gibt.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "project_summary",
"description": (
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
"halluzinierst Du Inhalte die nicht da sind."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_end",
"description": (
"Markiert ein Projekt als beendet — bleibt in der Liste sichtbar "
"(z.B. archiviert/grau), kann aber nicht mehr neu betreten werden "
"außer mit explizitem project_enter. Nutze wenn Stefan sagt 'Projekt "
"abgeschlossen' o.ä."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name."},
},
"required": ["name"],
},
},
},
] ]
@@ -960,17 +1059,28 @@ class Agent:
# Events vom letzten Turn weglassen # Events vom letzten Turn weglassen
self._pending_events = [] self._pending_events = []
# Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der
# neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt.
active_project_id = projects_mod.get_active()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett. # Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain # Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz. # iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
fast_reply = self._try_skill_fast_path(user_message) 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) project_id=active_project_id)
self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
return fast_reply return fast_reply
# 1. User-Turn an die Konversation # 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source) self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
# 2. Hot Memory (alle pinned Punkte) # 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned() hot = self.store.list_pinned()
@@ -1017,13 +1127,38 @@ class Agent:
oauth_callback_host=oauth_host, oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port, oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls) oauth_callback_tls=oauth_tls)
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
if active_project:
system_prompt += (
f"\n\n## AKTUELLES PROJEKT\n"
f"Stefan befindet sich gerade IN dem Projekt '{active_project['name']}' "
f"(id={active_project['id']}). Beschreibung: "
f"{active_project.get('description', '(keine)')}. "
f"Alle Antworten in diesem Turn gelten fuer dieses Projekt. "
f"Wenn er rauswill, ruf project_exit auf."
)
else:
project_count = len(projects_mod.list_projects())
if project_count > 0:
system_prompt += (
f"\n\n## PROJEKTE\n"
f"Hauptthread aktiv. {project_count} Projekte verfuegbar — wenn "
f"Stefan sagt 'in Projekt X' oder 'lass uns das Spotify-Thema "
f"weiterfuehren': project_enter aufrufen."
)
messages = [ProxyMessage(role="system", content=system_prompt)] messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window(): # Conversation-Window auf das aktive Projekt filtern: in einem Projekt
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
# nur die nicht-getaggten Turns.
window = self.conversation.window(project_id=active_project_id)
for t in window:
messages.append(ProxyMessage(role=t.role, content=t.content)) messages.append(ProxyMessage(role=t.role, content=t.content))
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d prompt_chars=%d", logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
len(hot), len(cold), len(active_skills), len(all_skills), len(hot), len(cold), len(active_skills), len(all_skills),
len(self.conversation.window()), len(system_prompt)) len(window), active_project_id or "(main)", len(system_prompt))
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein # 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn # Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
@@ -1082,13 +1217,19 @@ class Agent:
err_text = f"[Fehler: {exc}]" err_text = f"[Fehler: {exc}]"
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc) logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
try: try:
self.conversation.add("assistant", err_text) # Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops
# geaendert haben (project_enter/exit als Tool-Call).
self.conversation.add("assistant", err_text,
project_id=projects_mod.get_active())
except Exception as add_exc: except Exception as add_exc:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc) logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise raise
# 7. Assistant-Turn (final reply) in die Conversation # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply) # NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der
# Final-Reply schon im neuen Projekt-Kontext.
self.conversation.add("assistant", final_reply,
project_id=projects_mod.get_active())
return final_reply return final_reply
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -1648,6 +1789,101 @@ class Agent:
except Exception as e: except Exception as e:
logger.exception("memory_save fehlgeschlagen") logger.exception("memory_save fehlgeschlagen")
return f"FEHLER beim Speichern: {e}" return f"FEHLER beim Speichern: {e}"
# ── Projekte ────────────────────────────────────────
if name == "project_create":
pname = (arguments.get("name") or "").strip()
desc = (arguments.get("description") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
try:
p = projects_mod.create_project(pname, desc)
except ValueError as e:
return f"FEHLER: {e}"
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "created",
})
return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst."
if name == "project_enter":
pname = (arguments.get("name") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
projects_mod.set_active(p["id"])
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "entered",
})
turn_count = p.get("turn_count", 0)
hint = ""
if turn_count > 0:
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}"
if name == "project_exit":
active_id = projects_mod.get_active()
if not active_id:
return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread."
p = projects_mod.get_project(active_id)
projects_mod.set_active("")
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "exited",
})
return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread."
if name == "project_list":
items = projects_mod.list_projects()
if not items:
return "(keine Projekte angelegt)"
active_id = projects_mod.get_active()
lines = []
for p in items:
marker = " ← AKTIV" if p["id"] == active_id else ""
status_lbl = p.get("status", "active")
lines.append(
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
f"status={status_lbl}){marker}"
)
return "Projekte:\n" + "\n".join(lines)
if name == "project_summary":
pname = (arguments.get("name") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden."
# Letzte ~10 Turns des Projekts aus dem Conversation-Log
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
if not turns:
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
f"aktuellen Conversation-Window stehen noch keine Turns. "
f"Beschreibung: {p.get('description', '(keine)')}")
tail = turns[-12:]
summary_lines = []
for t in tail:
prefix = "Stefan" if t.role == "user" else "Du"
summary_lines.append(f"{prefix}: {t.content[:280]}")
preamble = (f"Projekt '{p['name']}'{p.get('description', '(keine Beschreibung)')}.\n"
f"Letzte {len(tail)} Turns:\n")
return preamble + "\n".join(summary_lines)
if name == "project_end":
pname = (arguments.get("name") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden."
projects_mod.end_project(p["id"])
self._pending_events.append({
"type": "project_changed",
"project": projects_mod.get_project(p["id"]),
"action": "ended",
})
return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
return f"Unbekanntes Tool: {name}" return f"Unbekanntes Tool: {name}"
except Exception as exc: except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name) logger.exception("Tool '%s' fehlgeschlagen", name)
+37 -9
View File
@@ -32,6 +32,7 @@ class Turn:
content: str content: str
ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
source: str = "" # "app" / "diagnostic" / "stt" — optional source: str = "" # "app" / "diagnostic" / "stt" — optional
project_id: str = "" # leer = Hauptthread; sonst projects.py-ID
class Conversation: class Conversation:
@@ -73,7 +74,8 @@ class Conversation:
if role in ("user", "assistant") and isinstance(content, str): if role in ("user", "assistant") and isinstance(content, str):
loaded.append(Turn(role=role, content=content, loaded.append(Turn(role=role, content=content,
ts=obj.get("ts", ""), ts=obj.get("ts", ""),
source=obj.get("source", ""))) source=obj.get("source", ""),
project_id=obj.get("project_id", "")))
self.turns = loaded self.turns = loaded
logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE) logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE)
@@ -85,17 +87,40 @@ class Conversation:
except Exception as exc: except Exception as exc:
logger.warning("Konversation persist fehlgeschlagen: %s", exc) logger.warning("Konversation persist fehlgeschlagen: %s", exc)
def add(self, role: str, content: str, source: str = "") -> Turn: def add(self, role: str, content: str, source: str = "",
t = Turn(role=role, content=content, source=source) project_id: str = "") -> Turn:
t = Turn(role=role, content=content, source=source, project_id=project_id)
self.turns.append(t) self.turns.append(t)
self._append_to_file({ record = {
"ts": t.ts, "role": t.role, "content": t.content, "source": t.source, "ts": t.ts, "role": t.role, "content": t.content, "source": t.source,
}) }
if t.project_id:
record["project_id"] = t.project_id
self._append_to_file(record)
return t return t
def window(self) -> List[Turn]: def window(self, project_id: Optional[str] = None) -> List[Turn]:
"""Die letzten max_window Turns — gehen in den LLM-Prompt.""" """Die letzten max_window Turns — gehen in den LLM-Prompt.
Wenn project_id gesetzt: nur Turns aus diesem Projekt + die letzten
~5 Hauptthread-Turns als Kontext. Wenn project_id leer/None und
explizit uebergeben → nur Hauptthread."""
if project_id is None:
return self.turns[-self.max_window:] return self.turns[-self.max_window:]
if project_id == "":
# Hauptthread-Modus: alle Turns, aber project-getaggte rausfiltern
main_turns = [t for t in self.turns if not t.project_id]
return main_turns[-self.max_window:]
# In-Projekt: alle Turns des Projekts + Tail des Hauptthreads als Kontext
project_turns = [t for t in self.turns if t.project_id == project_id]
return project_turns[-self.max_window:]
def window_recent_per_project(self) -> dict:
"""Returns {project_id: [last N turns]} — fuer „hol mich ab"-Summary."""
groups: dict[str, List[Turn]] = {}
for t in self.turns:
pid = t.project_id or ""
groups.setdefault(pid, []).append(t)
return groups
def needs_distill(self) -> bool: def needs_distill(self) -> bool:
return len(self.turns) > self.distill_threshold return len(self.turns) > self.distill_threshold
@@ -131,10 +156,13 @@ class Conversation:
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp") tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
with tmp.open("w", encoding="utf-8") as f: with tmp.open("w", encoding="utf-8") as f:
for t in self.turns: for t in self.turns:
f.write(json.dumps({ rec = {
"ts": t.ts, "role": t.role, "ts": t.ts, "role": t.role,
"content": t.content, "source": t.source, "content": t.content, "source": t.source,
}, ensure_ascii=False) + "\n") }
if t.project_id:
rec["project_id"] = t.project_id
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
tmp.replace(CONVERSATION_FILE) tmp.replace(CONVERSATION_FILE)
except Exception as exc: except Exception as exc:
logger.warning("Konversation rewrite fehlgeschlagen: %s", exc) logger.warning("Konversation rewrite fehlgeschlagen: %s", exc)
+71
View File
@@ -38,6 +38,7 @@ import watcher as watcher_mod
import background as background_mod import background as background_mod
import oauth as oauth_mod import oauth as oauth_mod
import seed_rules as seed_rules_mod import seed_rules as seed_rules_mod
import projects as projects_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain") logger = logging.getLogger("aria-brain")
@@ -639,6 +640,76 @@ def chat(body: ChatIn, background: BackgroundTasks):
) )
# ── Projekte ────────────────────────────────────────────────────────
@app.get("/projects/status")
def projects_status():
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
return projects_mod.status()
@app.get("/projects/list")
def projects_list(include_archived: bool = False):
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
class ProjectCreateBody(BaseModel):
name: str
description: str = ""
@app.post("/projects/create")
def projects_create(body: ProjectCreateBody):
try:
p = projects_mod.create_project(body.name, body.description)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return p
class ProjectSwitchBody(BaseModel):
project_id: str = ""
@app.post("/projects/switch")
def projects_switch(body: ProjectSwitchBody):
"""Aktive Projekt-ID setzen. Leerer String → Hauptthread."""
if body.project_id:
p = projects_mod.get_project(body.project_id)
if not p:
raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden")
projects_mod.set_active(body.project_id)
return projects_mod.status()
@app.post("/projects/{project_id}/end")
def projects_end(project_id: str):
if not projects_mod.end_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"}
@app.post("/projects/{project_id}/archive")
def projects_archive(project_id: str):
if not projects_mod.archive_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return {"id": project_id, "status": "archived"}
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@app.patch("/projects/{project_id}")
def projects_update(project_id: str, body: ProjectUpdateBody):
patch = body.dict(exclude_unset=True)
p = projects_mod.update_project(project_id, patch)
if p is None:
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return p
@app.get("/conversation/stats") @app.get("/conversation/stats")
def conversation_stats(): def conversation_stats():
return conversation().stats() return conversation().stats()
+219
View File
@@ -0,0 +1,219 @@
"""
Projekt-Verwaltung — Stefans Idee fuer „Threads im Hauptchat verankert".
Ein Projekt ist ein benanntes Thema-Bündel. Zwei Modi:
- Hauptthread (kein aktives Projekt): klassischer rollender Chat.
- In-Projekt: alle neuen Turns werden mit project_id getaggt. Die App
zeigt sie als zusammenhängenden Block, einklappbar.
Voice-Pattern (vom LLM via Meta-Tools getriggert):
- „neues Projekt 'Aria-Wakeword'" → project_create
- „steig in Projekt Spotify-Setup ein" → project_enter (Fuzzy-Match)
- „Projekt Ende" → project_exit (zurueck zu Hauptthread)
- „welche Projekte gibt's?" → project_list
- „hol mich ab — was war zuletzt bei Projekt X?" → project_summary
Persistenz: JSON-Liste in /shared/config/projects.json + aktive ID
in /shared/config/active_project.txt. Single-User, single-active —
keine Concurrency-Probleme.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import uuid
from difflib import SequenceMatcher
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/shared/config"))
PROJECTS_FILE = PROJECTS_DIR / "projects.json"
ACTIVE_PROJECT_FILE = PROJECTS_DIR / "active_project.txt"
def _now() -> int:
return int(time.time())
def _load_all() -> list[dict]:
if not PROJECTS_FILE.exists():
return []
try:
data = json.loads(PROJECTS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception as exc:
logger.warning("[projects] load failed: %s", exc)
return []
def _save_all(projects: list[dict]) -> None:
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
PROJECTS_FILE.write_text(
json.dumps(projects, indent=2, ensure_ascii=False), encoding="utf-8")
def _slug(name: str) -> str:
"""Stabile ID aus Namen — fuer Voice-Matches. Lowercase, only a-z 0-9 _."""
s = name.strip().lower()
s = re.sub(r"[^a-z0-9]+", "_", s)
s = s.strip("_")
return s or f"project_{_now()}"
def list_projects(include_archived: bool = False) -> list[dict]:
projects = _load_all()
if not include_archived:
projects = [p for p in projects if p.get("status") != "archived"]
projects.sort(key=lambda p: p.get("last_activity_at", 0), reverse=True)
return projects
def get_project(project_id: str) -> Optional[dict]:
if not project_id:
return None
for p in _load_all():
if p.get("id") == project_id:
return p
return None
def find_project(query: str) -> Optional[dict]:
"""Fuzzy-Match auf Projekt-Namen — fuer Voice-Commands.
Trifft auf: exact slug, prefix, substring, oder hoechste similarity > 0.6."""
q = (query or "").strip().lower()
if not q:
return None
projects = _load_all()
# 1. Exact ID-Match
for p in projects:
if p.get("id") == q:
return p
# 2. Exact / Prefix / Substring auf Slug + Name
q_slug = _slug(q)
for p in projects:
if p.get("id") == q_slug:
return p
name_low = (p.get("name", "")).lower()
if name_low == q or name_low.startswith(q) or q in name_low:
return p
# 3. Fuzzy
best, best_score = None, 0.0
for p in projects:
s = SequenceMatcher(None, q, p.get("name", "").lower()).ratio()
if s > best_score:
best, best_score = p, s
if best and best_score >= 0.6:
return best
return None
def create_project(name: str, description: str = "") -> dict:
name = (name or "").strip()
if not name:
raise ValueError("Projektname darf nicht leer sein")
base_id = _slug(name)
projects = _load_all()
# Dedup by id with suffix
used_ids = {p["id"] for p in projects}
pid = base_id
counter = 2
while pid in used_ids:
pid = f"{base_id}_{counter}"
counter += 1
now = _now()
project = {
"id": pid,
"name": name,
"description": description.strip(),
"status": "active", # active | ended | archived
"created_at": now,
"updated_at": now,
"last_activity_at": now,
"turn_count": 0,
}
projects.append(project)
_save_all(projects)
set_active(pid)
logger.info("[projects] created %r (id=%s)", name, pid)
return project
def update_project(project_id: str, patch: dict) -> Optional[dict]:
projects = _load_all()
for p in projects:
if p["id"] == project_id:
for k in ("name", "description", "status"):
if k in patch and patch[k] is not None:
p[k] = patch[k]
p["updated_at"] = _now()
_save_all(projects)
return p
return None
def archive_project(project_id: str) -> bool:
if update_project(project_id, {"status": "archived"}) is not None:
if get_active() == project_id:
set_active("")
return True
return False
def end_project(project_id: str) -> bool:
"""Markiert als beendet, aktive-Projekt-Pointer raus."""
if update_project(project_id, {"status": "ended"}) is not None:
if get_active() == project_id:
set_active("")
return True
return False
def touch_project(project_id: str) -> None:
"""Bei jedem Turn im Projekt: last_activity + turn_count erhoehen."""
if not project_id:
return
projects = _load_all()
changed = False
for p in projects:
if p["id"] == project_id:
p["last_activity_at"] = _now()
p["turn_count"] = int(p.get("turn_count", 0)) + 1
changed = True
break
if changed:
_save_all(projects)
# ── Active-Project-Pointer ─────────────────────────────────────────
def get_active() -> str:
"""Returns die aktive Projekt-ID oder leer (= Hauptthread)."""
try:
if ACTIVE_PROJECT_FILE.exists():
return ACTIVE_PROJECT_FILE.read_text(encoding="utf-8").strip()
except Exception:
pass
return ""
def set_active(project_id: str) -> None:
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
ACTIVE_PROJECT_FILE.write_text(project_id or "", encoding="utf-8")
logger.info("[projects] active project: %r", project_id or "(main)")
def status() -> dict:
"""Status-Snapshot fuer App/Diagnostic."""
active_id = get_active()
active = get_project(active_id) if active_id else None
return {
"active_id": active_id,
"active": active,
"projects": list_projects(include_archived=False),
}
+14
View File
@@ -1564,6 +1564,20 @@ class ARIABridge:
logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)", logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)",
event.get("memory", {}).get("title"), event.get("memory", {}).get("title"),
event.get("memory", {}).get("type")) event.get("memory", {}).get("type"))
elif etype == "project_changed":
# ARIA hat ein Projekt erstellt / betreten / verlassen / beendet.
# App + Diagnostic refreshen ihren Projekt-Banner anhand des Events.
await self._send_to_rvs({
"type": "project_changed",
"payload": {
"action": event.get("action") or "",
**(event.get("project") or {}),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
proj = event.get("project") or {}
logger.info("[brain] Projekt %s: %s (id=%s)",
event.get("action") or "?", proj.get("name"), proj.get("id"))
# _process_core_response uebernimmt alles weitere: # _process_core_response uebernimmt alles weitere:
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat- # File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
+245
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>
@@ -956,6 +992,41 @@
<!-- Alte Sessions-Sicherung entfernt — aria-core ist raus. --> <!-- Alte Sessions-Sicherung entfernt — aria-core ist raus. -->
<!-- Projekte — Threads-im-Hauptchat-Konzept -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2>
<div>
<button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
<button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div>
</div>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Projekte bündeln zusammengehörige Turns als Block im Hauptchat. Stefan sagt zu ARIA
„lass uns ein Projekt anlegen" oder klickt hier auf „+ Neues Projekt". Aktives Projekt:
<span id="project-active-label" style="color:#34C759;font-weight:600;">(wird geladen...)</span>
</div>
<div id="project-list" class="card" style="padding:0;">
<div style="padding:14px;color:#8888AA;font-size:12px;">Lade Projekte...</div>
</div>
</div>
<!-- Neues-Projekt Modal -->
<div id="project-create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;align-items:center;justify-content:center;">
<div style="background:#15151E;padding:20px;border-radius:8px;min-width:340px;max-width:90vw;">
<h3 style="margin-top:0;color:#E0E0F0;">Neues Projekt</h3>
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Name</label>
<input type="text" id="project-create-name" placeholder="z.B. Frankreich-Urlaub"
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:14px;margin-bottom:10px;">
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Beschreibung (optional)</label>
<textarea id="project-create-desc" placeholder="1 Satz worum's geht. Hilft beim Wiederfinden."
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:13px;height:60px;resize:vertical;margin-bottom:14px;"></textarea>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn secondary" onclick="closeCreateProjectModal()" style="padding:6px 14px;font-size:12px;">Abbrechen</button>
<button class="btn primary" onclick="submitCreateProject()" style="padding:6px 14px;font-size:12px;">Anlegen + aktivieren</button>
</div>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
@@ -1475,6 +1546,53 @@
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 === 'project_changed') {
// ARIA hat in einem Tool-Call ein Projekt erstellt/betreten/verlassen/beendet.
// Liste neu laden falls sichtbar.
loadProjects();
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 +2725,128 @@
}); });
} }
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' });
}
// ── Projekte ────────────────────────────────────────────
async function loadProjects() {
const listEl = document.getElementById('project-list');
const activeLabel = document.getElementById('project-active-label');
try {
const r = await fetch('/api/brain/projects/status');
const status = await r.json();
const projects = status.projects || [];
const activeId = status.active_id || '';
activeLabel.textContent = status.active ? status.active.name : '💬 Hauptchat';
activeLabel.style.color = status.active ? '#34C759' : '#8888AA';
const rows = [];
// Hauptchat-Eintrag
rows.push(`
<div onclick="switchProject('')" style="cursor:pointer;padding:12px 14px;border-bottom:1px solid #1E1E2E;${!activeId ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div>
<div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`);
for (const p of projects) {
const isActive = p.id === activeId;
const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
const ended = p.status === 'ended';
rows.push(`
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
📁 ${escapeHtml(p.name)}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<div style="display:flex;gap:4px;">
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div>
</div>
</div>`);
}
if (projects.length === 0) {
rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>');
}
listEl.innerHTML = rows.join('');
} catch (e) {
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`;
}
}
async function switchProject(projectId) {
try {
await fetch('/api/brain/projects/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: projectId }),
});
loadProjects();
} catch (e) { alert('Wechsel fehlgeschlagen: ' + e.message); }
}
async function endProject(id, name) {
if (!confirm(`Projekt "${name}" beenden?\n\nBleibt sichtbar, aktiv ist dann der Hauptchat.`)) return;
try {
await fetch(`/api/brain/projects/${encodeURIComponent(id)}/end`, { method: 'POST' });
loadProjects();
} catch (e) { alert('Beenden fehlgeschlagen: ' + e.message); }
}
async function archiveProject(id, name) {
if (!confirm(`Projekt "${name}" archivieren?\n\nVerschwindet aus der Liste.`)) return;
try {
await fetch(`/api/brain/projects/${encodeURIComponent(id)}/archive`, { method: 'POST' });
loadProjects();
} catch (e) { alert('Archivieren fehlgeschlagen: ' + e.message); }
}
function openCreateProjectModal() {
document.getElementById('project-create-name').value = '';
document.getElementById('project-create-desc').value = '';
document.getElementById('project-create-modal').style.display = 'flex';
setTimeout(() => document.getElementById('project-create-name').focus(), 50);
}
function closeCreateProjectModal() {
document.getElementById('project-create-modal').style.display = 'none';
}
async function submitCreateProject() {
const name = document.getElementById('project-create-name').value.trim();
const description = document.getElementById('project-create-desc').value.trim();
if (!name) { alert('Name darf nicht leer sein.'); return; }
try {
await fetch('/api/brain/projects/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
closeCreateProjectModal();
loadProjects();
} catch (e) { alert('Anlegen fehlgeschlagen: ' + e.message); }
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function escapeHtmlAttr(str) {
return String(str).replace(/['"\\]/g, '\\$&');
}
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 +3063,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,11 +3597,13 @@
loadRuntimeConfig(); loadRuntimeConfig();
loadOnboardingQR(); loadOnboardingQR();
loadOAuthServices(); loadOAuthServices();
refreshVoiceIdStatus();
} else if (tab === 'brain') { } else if (tab === 'brain') {
loadBrainStatus(); loadBrainStatus();
loadBrainMemoryList(); loadBrainMemoryList();
refreshImportFiles(); refreshImportFiles();
loadMetrics(); loadMetrics();
loadProjects();
} else if (tab === 'files') { } else if (tab === 'files') {
loadFiles(); loadFiles();
} else if (tab === 'skills') { } else if (tab === 'skills') {
+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 {}
+10
View File
@@ -42,6 +42,16 @@ 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",
// Projekte (Stefan-Konzept: Threads im Hauptchat verankert) — Side-Channel-
// Event vom Brain → Bridge → App/Diagnostic, damit beide Clients ihren
// aktiven-Projekt-Banner refreshen wenn ARIA via Tool was aendert.
"project_changed",
// 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