Compare commits
No commits in common. "4ea16cfa8fffd3def2112f18c59b6f4d2b24b11a" and "578ade35443a362108fba1453baf5949e890053e" have entirely different histories.
4ea16cfa8f
...
578ade3544
|
|
@ -24,9 +24,7 @@
|
|||
"react-native-camera-kit": "^13.0.0",
|
||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-audio-recorder-player": "^3.6.7",
|
||||
"@picovoice/porcupine-react-native": "^3.0.6",
|
||||
"@picovoice/react-native-voice-processor": "^1.2.3"
|
||||
"react-native-audio-recorder-player": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import updateService from '../services/updater';
|
|||
import VoiceButton from '../components/VoiceButton';
|
||||
import FileUpload, { FileData } from '../components/FileUpload';
|
||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||
import { RecordingResult, loadConvWindowMs } from '../services/audio';
|
||||
import { RecordingResult } from '../services/audio';
|
||||
import Geolocation from '@react-native-community/geolocation';
|
||||
|
||||
// --- Typen ---
|
||||
|
|
@ -139,11 +139,6 @@ const ChatScreen: React.FC = () => {
|
|||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
|
||||
useEffect(() => {
|
||||
wakeWordService.loadFromStorage().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
setTtsMuted(prev => {
|
||||
const next = !prev;
|
||||
|
|
@ -390,11 +385,10 @@ const ChatScreen: React.FC = () => {
|
|||
useEffect(() => {
|
||||
const unsubWake = wakeWordService.onWakeWord(async () => {
|
||||
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
|
||||
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
|
||||
const windowMs = await loadConvWindowMs();
|
||||
const started = await audioService.startRecording(true, windowMs);
|
||||
// Aufnahme mit Auto-Stop (VAD) starten
|
||||
const started = await audioService.startRecording(true);
|
||||
if (!started) {
|
||||
// Mikrofon nicht verfuegbar, naechsten Versuch
|
||||
// Mikrofon nicht verfuegbar, Wake Word wieder aktivieren
|
||||
wakeWordService.resume();
|
||||
}
|
||||
});
|
||||
|
|
@ -403,7 +397,7 @@ const ChatScreen: React.FC = () => {
|
|||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 500) {
|
||||
// User hat im Fenster gesprochen → Sprachnachricht senden
|
||||
// Sprachnachricht senden (gleiche Logik wie handleVoiceRecording)
|
||||
const location = await getCurrentLocation();
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
|
|
@ -420,14 +414,9 @@ const ChatScreen: React.FC = () => {
|
|||
voice: localXttsVoiceRef.current,
|
||||
...(location && { location }),
|
||||
});
|
||||
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
|
||||
} else {
|
||||
// Kein Speech im Window → Konversation beenden (Ohr geht aus oder
|
||||
// bleibt armed wenn Wake Word verfuegbar)
|
||||
wakeWordService.endConversation();
|
||||
// UI-State synchron halten
|
||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||
}
|
||||
// Wake Word wieder aktivieren
|
||||
if (wakeWordActive) wakeWordService.resume();
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -31,17 +31,7 @@ import {
|
|||
VAD_SILENCE_MIN_SEC,
|
||||
VAD_SILENCE_MAX_SEC,
|
||||
VAD_SILENCE_STORAGE_KEY,
|
||||
CONV_WINDOW_DEFAULT_SEC,
|
||||
CONV_WINDOW_MIN_SEC,
|
||||
CONV_WINDOW_MAX_SEC,
|
||||
CONV_WINDOW_STORAGE_KEY,
|
||||
} from '../services/audio';
|
||||
import wakeWordService, {
|
||||
BUILTIN_KEYWORDS,
|
||||
DEFAULT_KEYWORD,
|
||||
WAKE_ACCESS_KEY_STORAGE,
|
||||
WAKE_KEYWORD_STORAGE,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
import VoiceCloneModal from '../components/VoiceCloneModal';
|
||||
|
|
@ -97,11 +87,6 @@ const SettingsScreen: React.FC = () => {
|
|||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
|
||||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
||||
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
||||
const [wakeAccessKey, setWakeAccessKey] = useState<string>('');
|
||||
const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false);
|
||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [xttsVoice, setXttsVoice] = useState('');
|
||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||
|
|
@ -145,20 +130,6 @@ const SettingsScreen: React.FC = () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
setConvWindowSec(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE).then(saved => {
|
||||
if (saved) setWakeAccessKey(saved);
|
||||
});
|
||||
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
|
||||
if (saved) setWakeKeyword(saved);
|
||||
});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
if (saved) setXttsVoice(saved);
|
||||
});
|
||||
|
|
@ -632,117 +603,6 @@ const SettingsScreen: React.FC = () => {
|
|||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
|
||||
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
|
||||
Sprichst du nichts → Mikrofon zu.
|
||||
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Wake-Word (geraetelokal) === */}
|
||||
<Text style={styles.sectionTitle}>Wake-Word</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wenn ein Picovoice-Access-Key eingetragen ist, hoert die App passiv
|
||||
auf das gewaehlte Wake-Word — du kannst dich mit anderen unterhalten,
|
||||
Musik laufen lassen und mit "{wakeKeyword}" eine Konversation mit
|
||||
ARIA starten. Ohne Key oder bei Fehlschlag startet das Ohr direkt
|
||||
eine Konversation (klassischer Modus).
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Picovoice Access Key</Text>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 6}}>
|
||||
<TextInput
|
||||
style={[styles.input, {flex: 1}]}
|
||||
value={wakeAccessKey}
|
||||
onChangeText={setWakeAccessKey}
|
||||
placeholder="kostenlos auf console.picovoice.ai"
|
||||
placeholderTextColor="#666680"
|
||||
secureTextEntry={!wakeAccessKeyVisible}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setWakeAccessKeyVisible(v => !v)}
|
||||
style={{padding: 8}}
|
||||
>
|
||||
<Text style={{fontSize: 18}}>{wakeAccessKeyVisible ? '🙈' : '👁'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter
|
||||
ueber Diagnostic-Upload.
|
||||
</Text>
|
||||
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
|
||||
{BUILTIN_KEYWORDS.map(kw => (
|
||||
<TouchableOpacity
|
||||
key={kw}
|
||||
style={[
|
||||
styles.keywordChip,
|
||||
wakeKeyword === kw && styles.keywordChipActive,
|
||||
]}
|
||||
onPress={() => setWakeKeyword(kw)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.keywordChipText,
|
||||
wakeKeyword === kw && styles.keywordChipTextActive,
|
||||
]}>
|
||||
{kw}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {flex: 1}]}
|
||||
onPress={async () => {
|
||||
setWakeStatus('Initialisiere...');
|
||||
try {
|
||||
const ok = await wakeWordService.configure(wakeAccessKey, wakeKeyword);
|
||||
setWakeStatus(ok ? `✅ "${wakeKeyword}" bereit` : '❌ Fehlgeschlagen — Access Key pruefen');
|
||||
} catch (err: any) {
|
||||
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
|
||||
}
|
||||
setTimeout(() => setWakeStatus(''), 5000);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>Speichern + Aktivieren</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{!!wakeStatus && (
|
||||
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* === Sprachausgabe (geraetelokal) === */}
|
||||
|
|
@ -807,13 +667,23 @@ const SettingsScreen: React.FC = () => {
|
|||
<View style={{marginTop: 20}}>
|
||||
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz —
|
||||
ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme.
|
||||
Eigene Wahl fuer dieses Geraet. Ohne Auswahl gilt der Diagnostic-Default.
|
||||
</Text>
|
||||
|
||||
{/* Default-Option */}
|
||||
<TouchableOpacity
|
||||
style={[styles.voiceRow, xttsVoice === '' && styles.voiceRowActive]}
|
||||
onPress={() => selectVoice('')}
|
||||
>
|
||||
<Text style={[styles.voiceRowName, xttsVoice === '' && styles.voiceRowNameActive]}>
|
||||
Standard (Diagnostic-Default)
|
||||
</Text>
|
||||
{xttsVoice === '' && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
{availableVoices.length === 0 ? (
|
||||
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
|
||||
Keine geklonten Stimmen vorhanden — unten "Eigene Stimme aufnehmen".
|
||||
Keine eigenen Stimmen auf dem XTTS-Server.
|
||||
</Text>
|
||||
) : (
|
||||
availableVoices.map(v => (
|
||||
|
|
@ -1415,28 +1285,6 @@ const styles = StyleSheet.create({
|
|||
minWidth: 80,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
keywordChip: {
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A2A3E',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
},
|
||||
keywordChipActive: {
|
||||
backgroundColor: '#0096FF',
|
||||
borderColor: '#0096FF',
|
||||
},
|
||||
keywordChipText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
keywordChipTextActive: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
|
|
|
|||
|
|
@ -84,27 +84,6 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
|
|||
export const VAD_SILENCE_MAX_SEC = 8.0;
|
||||
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
||||
|
||||
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
|
||||
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
|
||||
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
|
||||
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
|
||||
export const CONV_WINDOW_MIN_SEC = 3.0;
|
||||
export const CONV_WINDOW_MAX_SEC = 20.0;
|
||||
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
|
||||
|
||||
export async function loadConvWindowMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
return Math.round(n * 1000);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
|
||||
}
|
||||
|
||||
async function loadVadSilenceMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
|
||||
|
|
@ -178,7 +157,6 @@ class AudioService {
|
|||
private lastSpeechTime: number = 0;
|
||||
private vadTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.recorder = new AudioRecorderPlayer();
|
||||
|
|
@ -211,16 +189,8 @@ class AudioService {
|
|||
|
||||
// --- Aufnahme ---
|
||||
|
||||
/** Mikrofon-Aufnahme starten.
|
||||
*
|
||||
* @param autoStop VAD aktivieren — Auto-Stop bei Stille
|
||||
* @param noSpeechTimeoutMs Wenn der User innerhalb dieser Zeit nichts sagt,
|
||||
* wird Stille gemeldet (Recording wird verworfen).
|
||||
* Fuer Conversation-Window: nach ARIA's Antwort
|
||||
* hast du nur N Sekunden um anzufangen, sonst
|
||||
* Gespraech zu Ende.
|
||||
*/
|
||||
async startRecording(autoStop: boolean = false, noSpeechTimeoutMs: number = 0): Promise<boolean> {
|
||||
/** Mikrofon-Aufnahme starten */
|
||||
async startRecording(autoStop: boolean = false): Promise<boolean> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] Aufnahme laeuft bereits');
|
||||
return false;
|
||||
|
|
@ -306,18 +276,6 @@ class AudioService {
|
|||
}, MAX_RECORDING_MS);
|
||||
}
|
||||
|
||||
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
|
||||
// anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie),
|
||||
// ChatScreen erkennt das und beendet die Konversation.
|
||||
if (noSpeechTimeoutMs > 0) {
|
||||
this.noSpeechTimer = setTimeout(() => {
|
||||
if (!this.speechDetected && this.recordingState === 'recording') {
|
||||
console.log(`[Audio] Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache — Stop`);
|
||||
this.silenceListeners.forEach(cb => cb());
|
||||
}
|
||||
}, noSpeechTimeoutMs);
|
||||
}
|
||||
|
||||
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
|
@ -344,10 +302,6 @@ class AudioService {
|
|||
clearTimeout(this.maxDurationTimer);
|
||||
this.maxDurationTimer = null;
|
||||
}
|
||||
if (this.noSpeechTimer) {
|
||||
clearTimeout(this.noSpeechTimer);
|
||||
this.noSpeechTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.recorder.stopRecorder();
|
||||
|
|
|
|||
|
|
@ -1,218 +1,56 @@
|
|||
/**
|
||||
* Gespraechsmodus / Wake Word Service
|
||||
* Gespraechsmodus — "Ohr-Button"
|
||||
*
|
||||
* Drei Zustaende:
|
||||
* off — Ohr aus, nichts laeuft
|
||||
* armed — Ohr aktiv, Porcupine hoert passiv auf das Wake-Word.
|
||||
* Das Mikro ist von Porcupine belegt; AudioRecorder ist aus.
|
||||
* conversing — Wake-Word getriggert (oder Ohr-Tap ohne Wake-Word):
|
||||
* aktive Konversation. Porcupine pausiert (gibt Mikro frei),
|
||||
* AudioRecorder uebernimmt fuer die Aufnahme.
|
||||
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
|
||||
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
|
||||
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
|
||||
* Wie ein Walkie-Talkie / natuerliches Gespraech:
|
||||
* ARIA spricht → Aufnahme startet → User spricht → VAD stoppt → ARIA antwortet → ...
|
||||
*
|
||||
* Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start'
|
||||
* direkt in 'conversing' (klassischer Gespraechsmodus). 'endConversation'
|
||||
* geht dann nach 'off' statt 'armed'.
|
||||
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
type WakeWordCallback = () => void;
|
||||
type StateCallback = (state: WakeWordState) => void;
|
||||
|
||||
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
||||
|
||||
export const WAKE_ACCESS_KEY_STORAGE = 'aria_wake_access_key';
|
||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||
|
||||
/** Built-In Keywords von Picovoice — pre-trained, sofort einsetzbar.
|
||||
* Custom Keywords (z.B. "ARIA") brauchen ein .ppn File aus der Picovoice
|
||||
* Console — wird spaeter ueber Diagnostic uploadbar. */
|
||||
export const BUILTIN_KEYWORDS = [
|
||||
'jarvis',
|
||||
'computer',
|
||||
'picovoice',
|
||||
'porcupine',
|
||||
'bumblebee',
|
||||
'terminator',
|
||||
'alexa',
|
||||
'hey google',
|
||||
'ok google',
|
||||
'hey siri',
|
||||
] as const;
|
||||
export type BuiltinKeyword = typeof BUILTIN_KEYWORDS[number];
|
||||
export const DEFAULT_KEYWORD: BuiltinKeyword = 'jarvis';
|
||||
export type WakeWordState = 'off' | 'listening' | 'detected';
|
||||
|
||||
class WakeWordService {
|
||||
private state: WakeWordState = 'off';
|
||||
private wakeCallbacks: WakeWordCallback[] = [];
|
||||
private stateCallbacks: StateCallback[] = [];
|
||||
|
||||
// Picovoice Manager (lazy, da Native Module nicht in jedem Build verfuegbar ist)
|
||||
private porcupine: any = null;
|
||||
private accessKey: string = '';
|
||||
private keyword: string = DEFAULT_KEYWORD;
|
||||
private initInProgress: Promise<boolean> | null = null;
|
||||
|
||||
/** Beim App-Start aufrufen — laedt Settings, baut Porcupine wenn Key da ist. */
|
||||
async loadFromStorage(): Promise<void> {
|
||||
try {
|
||||
const k = await AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE);
|
||||
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
|
||||
this.accessKey = (k || '').trim();
|
||||
this.keyword = (w || DEFAULT_KEYWORD).trim();
|
||||
if (this.accessKey) {
|
||||
// Vorinitialisieren — wirft sich nicht durch wenn etwas fehlt
|
||||
await this.initPorcupine();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] loadFromStorage', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings-Wechsel — neuer Key oder Keyword. Re-Init Porcupine. */
|
||||
async configure(accessKey: string, keyword: string): Promise<boolean> {
|
||||
this.accessKey = (accessKey || '').trim();
|
||||
this.keyword = (keyword || DEFAULT_KEYWORD).trim();
|
||||
await AsyncStorage.setItem(WAKE_ACCESS_KEY_STORAGE, this.accessKey);
|
||||
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, this.keyword);
|
||||
|
||||
// Laufende Instanz stoppen
|
||||
await this.disposePorcupine();
|
||||
if (!this.accessKey) return false;
|
||||
|
||||
// Neu initialisieren
|
||||
return this.initPorcupine();
|
||||
}
|
||||
|
||||
private async initPorcupine(): Promise<boolean> {
|
||||
if (this.initInProgress) return this.initInProgress;
|
||||
this.initInProgress = (async () => {
|
||||
try {
|
||||
const { PorcupineManager } = require('@picovoice/porcupine-react-native');
|
||||
// Built-In Keyword-Identifier sind lower-case strings im SDK
|
||||
this.porcupine = await PorcupineManager.fromBuiltInKeywords(
|
||||
this.accessKey,
|
||||
[this.keyword],
|
||||
(_keywordIndex: number) => this.onWakeDetected(),
|
||||
);
|
||||
console.log('[WakeWord] Porcupine init OK (keyword=%s)', this.keyword);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err);
|
||||
this.porcupine = null;
|
||||
return false;
|
||||
} finally {
|
||||
this.initInProgress = null;
|
||||
}
|
||||
})();
|
||||
return this.initInProgress;
|
||||
}
|
||||
|
||||
private async disposePorcupine() {
|
||||
if (this.porcupine) {
|
||||
try { await this.porcupine.stop(); } catch {}
|
||||
try { await this.porcupine.delete(); } catch {}
|
||||
this.porcupine = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
||||
/** Gespraechsmodus starten */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.state !== 'off') return true;
|
||||
if (this.porcupine) {
|
||||
// Passives Lauschen via Porcupine
|
||||
try {
|
||||
await this.porcupine.start();
|
||||
console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword);
|
||||
this.setState('armed');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:', err);
|
||||
}
|
||||
}
|
||||
// Fallback: direkt in die Konversation
|
||||
console.log('[WakeWord] Konversation startet sofort (kein Wake-Word)');
|
||||
this.setState('conversing');
|
||||
if (this.state === 'listening') return true;
|
||||
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
|
||||
this.setState('listening');
|
||||
// Sofort erste Aufnahme starten
|
||||
setTimeout(() => {
|
||||
if (this.state === 'conversing') {
|
||||
if (this.state === 'listening') {
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}, 500);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Komplett ausschalten (Ohr abschalten) */
|
||||
async stop(): Promise<void> {
|
||||
console.log('[WakeWord] Ohr deaktiviert');
|
||||
if (this.porcupine) {
|
||||
try { await this.porcupine.stop(); } catch {}
|
||||
}
|
||||
/** Gespraechsmodus stoppen */
|
||||
stop(): void {
|
||||
console.log('[WakeWord] Gespraechsmodus deaktiviert');
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
|
||||
if (this.porcupine) {
|
||||
try { await this.porcupine.stop(); } catch {}
|
||||
}
|
||||
this.setState('conversing');
|
||||
// kurz warten damit Mikrofon frei ist
|
||||
setTimeout(() => {
|
||||
if (this.state === 'conversing') {
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** Konversation beenden — User hat im Window nichts gesagt.
|
||||
* Mit Wake-Word: zurueck zu 'armed' (Porcupine wieder an).
|
||||
* Ohne: zurueck zu 'off'.
|
||||
*/
|
||||
async endConversation(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
if (this.porcupine && this.accessKey) {
|
||||
try {
|
||||
await this.porcupine.start();
|
||||
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
|
||||
this.setState('armed');
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
if (this.state !== 'listening') return;
|
||||
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
if (this.state === 'conversing') {
|
||||
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
|
||||
if (this.state === 'listening') {
|
||||
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}
|
||||
|
||||
/** True solange das Ohr aktiv ist (armed ODER conversing). */
|
||||
isActive(): boolean {
|
||||
return this.state !== 'off';
|
||||
}
|
||||
|
||||
isConversing(): boolean {
|
||||
return this.state === 'conversing';
|
||||
}
|
||||
|
||||
hasWakeWord(): boolean {
|
||||
return !!this.porcupine;
|
||||
}
|
||||
|
||||
getKeyword(): string {
|
||||
return this.keyword;
|
||||
return this.state === 'listening';
|
||||
}
|
||||
|
||||
// --- Callbacks ---
|
||||
|
|
|
|||
|
|
@ -496,7 +496,6 @@ class ARIABridge:
|
|||
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
|
||||
self.tts_enabled = True
|
||||
self.xtts_voice = ""
|
||||
self._f5tts_config: dict = {}
|
||||
vc: dict = {}
|
||||
# Gespeicherte Voice-Config laden
|
||||
try:
|
||||
|
|
@ -506,16 +505,7 @@ class ARIABridge:
|
|||
vc = json.load(f)
|
||||
self.tts_enabled = vc.get("ttsEnabled", True)
|
||||
self.xtts_voice = vc.get("xttsVoice", "")
|
||||
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
||||
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
|
||||
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
|
||||
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
|
||||
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
||||
if k in vc:
|
||||
self._f5tts_config[k] = vc[k]
|
||||
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s",
|
||||
self.tts_enabled, self.xtts_voice or "default",
|
||||
self._f5tts_config or "defaults")
|
||||
logger.info("Voice-Config geladen: tts=%s voice=%s", self.tts_enabled, self.xtts_voice or "default")
|
||||
except Exception as e:
|
||||
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
||||
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
||||
|
|
@ -973,29 +963,6 @@ class ARIABridge:
|
|||
except Exception as e:
|
||||
logger.debug("[mode] Broadcast fehlgeschlagen: %s", e)
|
||||
|
||||
async def _broadcast_persisted_config(self) -> None:
|
||||
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
|
||||
|
||||
Damit bekommen frisch verbundene Bridges (insbesondere die f5tts-bridge
|
||||
auf der Gamebox nach Container-Restart) die zuletzt in Diagnostic
|
||||
gewaehlten Settings — ohne dass der User in Diagnostic was klicken muss.
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
"ttsEnabled": getattr(self, "tts_enabled", True),
|
||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||
"whisperModel": self.stt_engine.model_size,
|
||||
}
|
||||
payload.update(getattr(self, "_f5tts_config", {}) or {})
|
||||
await self._send_to_rvs({
|
||||
"type": "config",
|
||||
"payload": payload,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
logger.info("[rvs] Persistierte Config broadcastet: %s", payload)
|
||||
except Exception as e:
|
||||
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e)
|
||||
|
||||
def _fetch_active_session(self) -> None:
|
||||
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
||||
try:
|
||||
|
|
@ -1065,12 +1032,6 @@ class ARIABridge:
|
|||
# ihren UI-State sofort syncen koennen
|
||||
await self._broadcast_current_mode()
|
||||
|
||||
# Persistierte Voice-Config broadcasten — die f5tts-bridge auf
|
||||
# der Gamebox bekommt damit nach Restart die zuletzt in
|
||||
# Diagnostic gewaehlten Settings wieder (sonst stuende sie auf
|
||||
# ihren Hard-Defaults).
|
||||
asyncio.create_task(self._broadcast_persisted_config())
|
||||
|
||||
# Heartbeat senden (RVS erwartet Ping alle 30s)
|
||||
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
|
||||
|
||||
|
|
@ -1234,10 +1195,7 @@ class ARIABridge:
|
|||
return
|
||||
|
||||
elif msg_type == "config":
|
||||
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
|
||||
# Felder die nicht direkt zur aria-bridge gehoeren (f5tts*) werden
|
||||
# nur persistiert; die f5tts-bridge auf der Gamebox empfaengt den
|
||||
# gleichen RVS-Broadcast und reagiert selber.
|
||||
# Konfiguration von App/Diagnostic empfangen + persistent speichern
|
||||
changed = False
|
||||
if "ttsEnabled" in payload:
|
||||
self.tts_enabled = bool(payload["ttsEnabled"])
|
||||
|
|
@ -1251,19 +1209,14 @@ class ARIABridge:
|
|||
new_model = payload["whisperModel"]
|
||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||
if new_model in allowed and new_model != self.stt_engine.model_size:
|
||||
# Merken und mitschicken an whisper-bridge (Gamebox).
|
||||
# Lokales Modell wird NICHT geladen — nur das Fallback braucht's,
|
||||
# und das passiert erst on-demand wenn Remote nicht antwortet.
|
||||
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt Gamebox)",
|
||||
new_model)
|
||||
self.stt_engine.model_size = new_model
|
||||
self.stt_engine.model = None
|
||||
changed = True
|
||||
# F5-TTS-Felder: einfach persistieren, f5tts-bridge applied selber.
|
||||
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
|
||||
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
||||
if k in payload:
|
||||
if not hasattr(self, "_f5tts_config"):
|
||||
self._f5tts_config = {}
|
||||
self._f5tts_config[k] = payload[k]
|
||||
changed = True
|
||||
# Persistent speichern in Shared Volume
|
||||
if changed:
|
||||
try:
|
||||
|
|
@ -1273,7 +1226,6 @@ class ARIABridge:
|
|||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||
"whisperModel": self.stt_engine.model_size,
|
||||
}
|
||||
config_data.update(getattr(self, "_f5tts_config", {}))
|
||||
with open("/shared/config/voice_config.json", "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
# ════════════════════════════════════════════════════════════════
|
||||
# ARIA — Windows / WSL2 / Docker Desktop VHDX Cleanup
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Findet alle WSL2 + Docker Desktop ext4.vhdx Files unter
|
||||
# C:\Users\<USER>\AppData\Local\... und kompaktiert sie via diskpart.
|
||||
# Damit bekommst du Speicherplatz zurueck den du IN den Distros/
|
||||
# Containern geloescht hast (z.B. nach `docker system prune`),
|
||||
# der aber von der VHDX bisher nicht freigegeben wurde.
|
||||
#
|
||||
# Nutzung (PowerShell als ADMIN):
|
||||
# .\cleanup-windows.ps1 stefan
|
||||
# .\cleanup-windows.ps1 -User stefan
|
||||
# .\cleanup-windows.ps1 -User stefan -SkipPrune # nur compacten
|
||||
#
|
||||
# Was passiert:
|
||||
# 1. Erst (optional): docker system prune + builder prune in WSL2
|
||||
# 2. wsl --shutdown
|
||||
# 3. Alle gefundenen .vhdx Files mit diskpart compact vdisk shrinken
|
||||
#
|
||||
# Hinweis: diskpart braucht KEINE Hyper-V Tools (anders als Optimize-VHD).
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0,
|
||||
HelpMessage="Dein Windows-Benutzername (z.B. stefan)")]
|
||||
[string]$User,
|
||||
|
||||
[Parameter(HelpMessage="Docker prune ueberspringen — nur compacten")]
|
||||
[switch]$SkipPrune,
|
||||
|
||||
[Parameter(HelpMessage="Docker prune NUR machen, dann beenden")]
|
||||
[switch]$PruneOnly
|
||||
)
|
||||
|
||||
# Admin-Check
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] `
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
Write-Host "❌ Dieses Script muss als Administrator laufen." -ForegroundColor Red
|
||||
Write-Host " Rechtsklick auf PowerShell → 'Als Administrator ausfuehren'" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
$basePath = "C:\Users\$User\AppData\Local"
|
||||
if (-not (Test-Path $basePath)) {
|
||||
Write-Host "❌ Pfad existiert nicht: $basePath" -ForegroundColor Red
|
||||
Write-Host " Pruefe den Benutzernamen." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host " ARIA Cleanup fuer User: $User" -ForegroundColor Cyan
|
||||
Write-Host "════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ── 1. Docker prune (in WSL2) ──────────────────────────────────
|
||||
if (-not $SkipPrune) {
|
||||
Write-Host "[1/3] Docker Cleanup in WSL2..." -ForegroundColor Yellow
|
||||
Write-Host " docker system prune -a --volumes -f" -ForegroundColor Gray
|
||||
Write-Host " docker builder prune -a -f" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
try {
|
||||
wsl -e bash -c "docker system prune -a --volumes -f && docker builder prune -a -f"
|
||||
Write-Host " ✅ fertig" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " ⚠️ Docker prune fehlgeschlagen (vielleicht laeuft Docker Desktop nicht?)" -ForegroundColor Yellow
|
||||
Write-Host " $_" -ForegroundColor Gray
|
||||
}
|
||||
Write-Host ""
|
||||
if ($PruneOnly) {
|
||||
Write-Host "─PruneOnly gesetzt — fertig." -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# ── 2. WSL2 shutdown ──────────────────────────────────────────
|
||||
Write-Host "[2/3] WSL2 herunterfahren..." -ForegroundColor Yellow
|
||||
wsl --shutdown
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " ✅ fertig" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# ── 3. VHDX-Files finden + compacten ──────────────────────────
|
||||
Write-Host "[3/3] VHDX-Files suchen + compacten..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
$vhdxFiles = @()
|
||||
$vhdxFiles += Get-ChildItem -Path "$basePath\Docker" -Recurse -Filter "*.vhdx" -ErrorAction SilentlyContinue
|
||||
$vhdxFiles += Get-ChildItem -Path "$basePath\Packages" -Recurse -Filter "ext4.vhdx" -ErrorAction SilentlyContinue
|
||||
$vhdxFiles = $vhdxFiles | Sort-Object FullName -Unique
|
||||
|
||||
if ($vhdxFiles.Count -eq 0) {
|
||||
Write-Host " Keine .vhdx Files gefunden." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Gefundene Files (vorher):" -ForegroundColor Cyan
|
||||
foreach ($f in $vhdxFiles) {
|
||||
$sizeGB = [math]::Round($f.Length / 1GB, 2)
|
||||
Write-Host (" {0,8} GB {1}" -f $sizeGB, $f.FullName) -ForegroundColor Gray
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
$totalBefore = ($vhdxFiles | Measure-Object Length -Sum).Sum
|
||||
|
||||
foreach ($f in $vhdxFiles) {
|
||||
Write-Host "→ Compact: $($f.FullName)" -ForegroundColor White
|
||||
$sizeBefore = [math]::Round($f.Length / 1GB, 2)
|
||||
|
||||
# Temporaeres diskpart-Script schreiben
|
||||
$tmp = [System.IO.Path]::GetTempFileName()
|
||||
@"
|
||||
select vdisk file="$($f.FullName)"
|
||||
attach vdisk readonly
|
||||
compact vdisk
|
||||
detach vdisk
|
||||
exit
|
||||
"@ | Out-File -Encoding ASCII -FilePath $tmp
|
||||
|
||||
try {
|
||||
$output = & diskpart /s $tmp 2>&1
|
||||
# Datei neu lesen — Length ist gecacht
|
||||
$newFile = Get-Item $f.FullName
|
||||
$sizeAfter = [math]::Round($newFile.Length / 1GB, 2)
|
||||
$saved = [math]::Round($sizeBefore - $sizeAfter, 2)
|
||||
if ($saved -gt 0) {
|
||||
Write-Host (" ✅ {0} GB → {1} GB (gespart: {2} GB)" -f $sizeBefore, $sizeAfter, $saved) -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host (" ─ {0} GB → {1} GB (nichts zu holen — File war schon optimal)" -f $sizeBefore, $sizeAfter) -ForegroundColor DarkGray
|
||||
}
|
||||
} catch {
|
||||
Write-Host " ❌ Fehler: $_" -ForegroundColor Red
|
||||
Write-Host " diskpart-Output:" -ForegroundColor DarkGray
|
||||
$output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
} finally {
|
||||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ── Zusammenfassung ─────────────────────────────────────────
|
||||
$vhdxFilesAfter = @()
|
||||
$vhdxFilesAfter += Get-ChildItem -Path "$basePath\Docker" -Recurse -Filter "*.vhdx" -ErrorAction SilentlyContinue
|
||||
$vhdxFilesAfter += Get-ChildItem -Path "$basePath\Packages" -Recurse -Filter "ext4.vhdx" -ErrorAction SilentlyContinue
|
||||
$vhdxFilesAfter = $vhdxFilesAfter | Sort-Object FullName -Unique
|
||||
$totalAfter = ($vhdxFilesAfter | Measure-Object Length -Sum).Sum
|
||||
|
||||
$savedTotal = [math]::Round(($totalBefore - $totalAfter) / 1GB, 2)
|
||||
|
||||
Write-Host "════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host (" Gesamt: {0} GB → {1} GB (gespart: {2} GB)" -f `
|
||||
[math]::Round($totalBefore / 1GB, 2),
|
||||
[math]::Round($totalAfter / 1GB, 2),
|
||||
$savedTotal) -ForegroundColor Cyan
|
||||
Write-Host "════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Fertig. Docker Desktop / WSL2 starten ja von alleine wieder beim naechsten Aufruf." -ForegroundColor Green
|
||||
|
|
@ -437,11 +437,11 @@
|
|||
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
|
||||
</div>
|
||||
|
||||
<!-- F5-TTS Stimme (zwingend eine Voice waehlen — F5-TTS braucht eine Referenz) -->
|
||||
<!-- XTTS Stimme -->
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
|
||||
<label style="color:#8888AA;font-size:12px;">F5-TTS Stimme:</label>
|
||||
<label style="color:#8888AA;font-size:12px;">XTTS Stimme:</label>
|
||||
<select id="diag-xtts-voice" onchange="sendVoiceConfig()" style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||
<option value="" disabled>(keine Stimme gewaehlt)</option>
|
||||
<option value="">Standard (XTTS Default)</option>
|
||||
</select>
|
||||
<button class="btn secondary" onclick="loadXTTSVoices()" style="padding:4px 10px;font-size:11px;">Laden</button>
|
||||
</div>
|
||||
|
|
@ -450,58 +450,6 @@
|
|||
<!-- Gecloned Stimmen — Liste mit Loeschen -->
|
||||
<div id="xtts-voice-list" style="margin-bottom:12px;"></div>
|
||||
|
||||
<!-- F5-TTS Modell-Tuning -->
|
||||
<details style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;margin-bottom:12px;">
|
||||
<summary style="color:#8888AA;font-size:12px;cursor:pointer;">F5-TTS Modell-Tuning (advanced)</summary>
|
||||
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="color:#8888AA;font-size:11px;">
|
||||
Werden via RVS an die f5tts-bridge auf der Gamebox geschickt.
|
||||
Modell-/Checkpoint-Wechsel triggert einen Reload (~30s).
|
||||
Hardcoded Defaults: F5TTS_v1_Base, cfg_strength=2.5, nfe_step=32.
|
||||
</div>
|
||||
|
||||
<label style="color:#8888AA;font-size:12px;">Modell-ID:</label>
|
||||
<input type="text" id="diag-f5tts-model"
|
||||
placeholder="F5TTS_v1_Base"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||
|
||||
<label style="color:#8888AA;font-size:12px;">
|
||||
Custom Checkpoint (HF-Repo "user/repo" oder Container-Pfad, leer = Default):
|
||||
</label>
|
||||
<input type="text" id="diag-f5tts-ckpt"
|
||||
placeholder="z.B. aoxo/F5-TTS-German"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||
|
||||
<label style="color:#8888AA;font-size:12px;">
|
||||
Custom Vocab (passend zum Checkpoint, optional):
|
||||
</label>
|
||||
<input type="text" id="diag-f5tts-vocab"
|
||||
placeholder="leer = Default"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
|
||||
|
||||
<div style="display:flex;gap:12px;">
|
||||
<div style="flex:1;">
|
||||
<label style="color:#8888AA;font-size:12px;">cfg_strength (1.0 - 5.0):</label>
|
||||
<input type="number" id="diag-f5tts-cfg" step="0.1" min="1" max="5"
|
||||
placeholder="2.5"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;width:100%;box-sizing:border-box;">
|
||||
<div style="color:#666680;font-size:10px;">Hoeher = klebt staerker an Referenz</div>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label style="color:#8888AA;font-size:12px;">nfe_step (8 - 64):</label>
|
||||
<input type="number" id="diag-f5tts-nfe" step="1" min="8" max="64"
|
||||
placeholder="32"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;width:100%;box-sizing:border-box;">
|
||||
<div style="color:#666680;font-size:10px;">Hoeher = bessere Qualitaet, langsamer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;align-self:flex-start;margin-top:6px;">
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Voice Cloning -->
|
||||
<div style="background:#1E1E2E;border-radius:8px;padding:12px;margin-top:8px;">
|
||||
<div style="color:#0096FF;font-size:13px;font-weight:600;margin-bottom:8px;">Stimme klonen</div>
|
||||
|
|
@ -893,16 +841,6 @@
|
|||
const wSel = document.getElementById('diag-whisper-model');
|
||||
if (wSel) wSel.value = msg.whisperModel;
|
||||
}
|
||||
// F5-TTS Tuning-Felder wiederherstellen (falls gesetzt)
|
||||
const setIfPresent = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && val !== undefined && val !== null && val !== '') el.value = val;
|
||||
};
|
||||
setIfPresent('diag-f5tts-model', msg.f5ttsModel);
|
||||
setIfPresent('diag-f5tts-ckpt', msg.f5ttsCkptFile);
|
||||
setIfPresent('diag-f5tts-vocab', msg.f5ttsVocabFile);
|
||||
setIfPresent('diag-f5tts-cfg', msg.f5ttsCfgStrength);
|
||||
setIfPresent('diag-f5tts-nfe', msg.f5ttsNfeStep);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1632,19 +1570,7 @@
|
|||
const ttsEnabled = document.getElementById('diag-tts-enabled').checked;
|
||||
const xttsVoice = document.getElementById('diag-xtts-voice').value;
|
||||
const whisperModel = document.getElementById('diag-whisper-model').value;
|
||||
const f5ttsModel = document.getElementById('diag-f5tts-model')?.value || '';
|
||||
const f5ttsCkptFile = document.getElementById('diag-f5tts-ckpt')?.value || '';
|
||||
const f5ttsVocabFile = document.getElementById('diag-f5tts-vocab')?.value || '';
|
||||
const f5ttsCfgRaw = document.getElementById('diag-f5tts-cfg')?.value || '';
|
||||
const f5ttsNfeRaw = document.getElementById('diag-f5tts-nfe')?.value || '';
|
||||
const f5ttsCfgStrength = f5ttsCfgRaw ? parseFloat(f5ttsCfgRaw) : undefined;
|
||||
const f5ttsNfeStep = f5ttsNfeRaw ? parseInt(f5ttsNfeRaw, 10) : undefined;
|
||||
send({
|
||||
action: 'send_voice_config',
|
||||
ttsEnabled, xttsVoice, whisperModel,
|
||||
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
||||
f5ttsCfgStrength, f5ttsNfeStep,
|
||||
});
|
||||
send({ action: 'send_voice_config', ttsEnabled, xttsVoice, whisperModel });
|
||||
const statusEl = document.getElementById('voice-status');
|
||||
if (statusEl && xttsVoice) {
|
||||
statusEl.textContent = `⏳ Stimme "${xttsVoice}" wird geladen...`;
|
||||
|
|
|
|||
|
|
@ -1423,25 +1423,6 @@ wss.on("connection", (ws) => {
|
|||
xttsVoice: msg.xttsVoice || "",
|
||||
};
|
||||
if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel;
|
||||
// F5-TTS Tuning-Felder — leere Strings entfernen damit der Default greift
|
||||
if (msg.f5ttsModel !== undefined) {
|
||||
if (msg.f5ttsModel) voiceConfig.f5ttsModel = msg.f5ttsModel;
|
||||
else delete voiceConfig.f5ttsModel;
|
||||
}
|
||||
if (msg.f5ttsCkptFile !== undefined) {
|
||||
if (msg.f5ttsCkptFile) voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile;
|
||||
else delete voiceConfig.f5ttsCkptFile;
|
||||
}
|
||||
if (msg.f5ttsVocabFile !== undefined) {
|
||||
if (msg.f5ttsVocabFile) voiceConfig.f5ttsVocabFile = msg.f5ttsVocabFile;
|
||||
else delete voiceConfig.f5ttsVocabFile;
|
||||
}
|
||||
if (msg.f5ttsCfgStrength !== undefined && !isNaN(msg.f5ttsCfgStrength)) {
|
||||
voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength;
|
||||
}
|
||||
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
|
||||
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# HuggingFace Model-Cache (geteilt zwischen f5tts + whisper bridge,
|
||||
# wird via Bind-Mount in die Container reingehaengt)
|
||||
hf-cache/
|
||||
|
||||
# Voice-Samples (lokal, gehoert nicht ins Repo)
|
||||
voices/
|
||||
|
||||
# Docker .env
|
||||
.env
|
||||
|
|
@ -31,19 +31,14 @@ services:
|
|||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./voices:/voices # WAV + TXT Referenz
|
||||
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
|
||||
# Direkt sichtbar im xtts/hf-cache/,
|
||||
# einfach zu loeschen, kein Docker-
|
||||
# Desktop .vhdx Bloat.
|
||||
# Wird mit whisper-bridge geteilt.
|
||||
- f5tts-models:/root/.cache/huggingface # Model-Cache persistieren
|
||||
environment:
|
||||
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
|
||||
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
- RVS_PORT=${RVS_PORT:-443}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||
- RVS_TOKEN=${RVS_TOKEN}
|
||||
- F5TTS_MODEL=${F5TTS_MODEL:-F5TTS_v1_Base}
|
||||
- F5TTS_DEVICE=${F5TTS_DEVICE:-cuda}
|
||||
- VOICES_DIR=/voices
|
||||
restart: unless-stopped
|
||||
|
|
@ -78,5 +73,9 @@ services:
|
|||
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
|
||||
- WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de}
|
||||
volumes:
|
||||
- ./hf-cache:/root/.cache/huggingface # gleicher Cache wie f5tts-bridge
|
||||
- whisper-models:/root/.cache/huggingface
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
f5tts-models:
|
||||
whisper-models:
|
||||
|
|
|
|||
|
|
@ -52,33 +52,13 @@ RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
|||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
|
||||
# F5-TTS Konfiguration
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Defaults sind hard-coded — bewusst KEINE ENV-Vars (ausser F5TTS_DEVICE,
|
||||
# weil Hardware-Bootstrap). Alle Settings werden zur Laufzeit via RVS
|
||||
# config-Broadcast aus Diagnostic uebersteuert (Felder f5ttsModel,
|
||||
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
|
||||
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
|
||||
|
||||
DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base"
|
||||
DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF
|
||||
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
|
||||
# cfg_strength: wie stark der Generator am Referenz-Voice klebt.
|
||||
# Default F5-TTS = 2.0. Bei nicht-EN/CN Sprachen (Deutsch!) hilft 2.5+,
|
||||
# damit das Modell nicht in eine andere Sprache abrutscht.
|
||||
DEFAULT_F5TTS_CFG_STRENGTH = 2.5
|
||||
DEFAULT_F5TTS_NFE_STEP = 32
|
||||
|
||||
F5TTS_MODEL = os.getenv("F5TTS_MODEL", "F5TTS_v1_Base")
|
||||
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda")
|
||||
VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
|
||||
|
||||
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
|
||||
TARGET_SR = 24000 # F5-TTS native
|
||||
|
||||
# Wird in einer Uebergangsphase als "ungueltige Referenz" erkannt (alte voices,
|
||||
# die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung
|
||||
# loeschen wir die .txt und ziehen den echten Text nach.
|
||||
_LEGACY_PLACEHOLDER_REF = "Das ist ein Referenz Audio."
|
||||
|
||||
# ── Lazy F5-TTS Loader ──────────────────────────────────────
|
||||
|
||||
_F5TTS_cls = None
|
||||
|
|
@ -94,36 +74,18 @@ def _get_f5tts_cls():
|
|||
|
||||
|
||||
class F5Runner:
|
||||
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking).
|
||||
|
||||
Live-Settings (Modell, cfg_strength, nfe_step) werden ueber update_config()
|
||||
aus dem Diagnostic-Config-Broadcast gesetzt; bei Modell-Wechsel wird
|
||||
automatisch neu geladen.
|
||||
"""
|
||||
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.model = None
|
||||
self._lock = asyncio.Lock()
|
||||
# Aktuelle Werte — gestartet mit Hard-Defaults, ueberschrieben von Diagnostic
|
||||
self.model_id: str = DEFAULT_F5TTS_MODEL
|
||||
self.ckpt_file: str = DEFAULT_F5TTS_CKPT_FILE
|
||||
self.vocab_file: str = DEFAULT_F5TTS_VOCAB_FILE
|
||||
self.cfg_strength: float = DEFAULT_F5TTS_CFG_STRENGTH
|
||||
self.nfe_step: int = DEFAULT_F5TTS_NFE_STEP
|
||||
|
||||
def _load_blocking(self) -> None:
|
||||
cls = _get_f5tts_cls()
|
||||
logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...",
|
||||
self.model_id, F5TTS_DEVICE, self.ckpt_file or "default")
|
||||
logger.info("Lade F5-TTS '%s' (device=%s)...", F5TTS_MODEL, F5TTS_DEVICE)
|
||||
t0 = time.time()
|
||||
kwargs = {"model": self.model_id, "device": F5TTS_DEVICE}
|
||||
if self.ckpt_file:
|
||||
kwargs["ckpt_file"] = self.ckpt_file
|
||||
if self.vocab_file:
|
||||
kwargs["vocab_file"] = self.vocab_file
|
||||
self.model = cls(**kwargs)
|
||||
logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)",
|
||||
time.time() - t0, self.cfg_strength, self.nfe_step)
|
||||
self.model = cls(model=F5TTS_MODEL, device=F5TTS_DEVICE)
|
||||
logger.info("F5-TTS geladen in %.1fs", time.time() - t0)
|
||||
|
||||
async def ensure_loaded(self) -> None:
|
||||
async with self._lock:
|
||||
|
|
@ -132,49 +94,6 @@ class F5Runner:
|
|||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._load_blocking)
|
||||
|
||||
async def update_config(self, payload: dict) -> None:
|
||||
"""Liest f5tts*-Felder aus einem config-Broadcast.
|
||||
Bei Modell-relevantem Wechsel wird neu geladen."""
|
||||
new_model = (payload.get("f5ttsModel") or "").strip() or self.model_id
|
||||
new_ckpt = payload.get("f5ttsCkptFile", self.ckpt_file) or ""
|
||||
new_vocab = payload.get("f5ttsVocabFile", self.vocab_file) or ""
|
||||
try:
|
||||
new_cfg = float(payload.get("f5ttsCfgStrength", self.cfg_strength))
|
||||
except (TypeError, ValueError):
|
||||
new_cfg = self.cfg_strength
|
||||
try:
|
||||
new_nfe = int(payload.get("f5ttsNfeStep", self.nfe_step))
|
||||
except (TypeError, ValueError):
|
||||
new_nfe = self.nfe_step
|
||||
|
||||
# Settings die KEINEN Modell-Reload brauchen (zur naechsten Synthese aktiv)
|
||||
self.cfg_strength = new_cfg
|
||||
self.nfe_step = new_nfe
|
||||
|
||||
# Settings die einen Reload triggern
|
||||
model_changed = (new_model != self.model_id
|
||||
or new_ckpt != self.ckpt_file
|
||||
or new_vocab != self.vocab_file)
|
||||
if model_changed:
|
||||
logger.info("F5-TTS Config-Wechsel: model=%s ckpt=%s vocab=%s — Reload",
|
||||
new_model, new_ckpt or "default", new_vocab or "default")
|
||||
self.model_id = new_model
|
||||
self.ckpt_file = new_ckpt
|
||||
self.vocab_file = new_vocab
|
||||
async with self._lock:
|
||||
old = self.model
|
||||
self.model = None
|
||||
# Alte Instanz freigeben
|
||||
try:
|
||||
if old is not None:
|
||||
del old
|
||||
except Exception:
|
||||
pass
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._load_blocking)
|
||||
else:
|
||||
logger.info("F5-TTS Live-Config: cfg_strength=%.2f nfe=%d", new_cfg, new_nfe)
|
||||
|
||||
def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str) -> tuple[np.ndarray, int]:
|
||||
wav, sr, _ = self.model.infer(
|
||||
ref_file=ref_wav,
|
||||
|
|
@ -182,8 +101,6 @@ class F5Runner:
|
|||
gen_text=gen_text,
|
||||
remove_silence=True,
|
||||
seed=-1,
|
||||
cfg_strength=self.cfg_strength,
|
||||
nfe_step=self.nfe_step,
|
||||
)
|
||||
# F5-TTS gibt float32 1D-Array — auf 24kHz sample-rate standard
|
||||
if not isinstance(wav, np.ndarray):
|
||||
|
|
@ -342,24 +259,13 @@ async def _do_tts(ws, runner: F5Runner, text: str, voice: str,
|
|||
request_id: str, message_id: str, language: str) -> None:
|
||||
t0 = time.time()
|
||||
ref_wav_path, ref_txt_path = voice_paths(voice) if voice else (None, None)
|
||||
|
||||
# Legacy-Platzhalter erkennen → behandeln als "kein txt" und neu transkribieren
|
||||
if voice and ref_txt_path and ref_txt_path.exists():
|
||||
try:
|
||||
existing = ref_txt_path.read_text(encoding="utf-8").strip()
|
||||
if existing == _LEGACY_PLACEHOLDER_REF or not existing:
|
||||
logger.info("Voice '%s' hat Legacy-Platzhalter → loesche, transkribiere neu", voice)
|
||||
ref_txt_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
has_custom = bool(voice and ref_wav_path and ref_wav_path.exists() and ref_txt_path.exists())
|
||||
if voice and not has_custom:
|
||||
# Wenn nur WAV da ist aber kein txt → on-the-fly transkribieren
|
||||
if ref_wav_path and ref_wav_path.exists() and (not ref_txt_path or not ref_txt_path.exists()):
|
||||
logger.info("Voice '%s' hat kein txt — transkribiere on-the-fly", voice)
|
||||
text_ref = await request_transcription(ws, ref_wav_path, language)
|
||||
if text_ref and text_ref.strip():
|
||||
if text_ref:
|
||||
try:
|
||||
ref_txt_path.write_text(text_ref.strip(), encoding="utf-8")
|
||||
has_custom = True
|
||||
|
|
@ -491,20 +397,14 @@ async def handle_voice_upload(ws, payload: dict) -> None:
|
|||
# Transkription ueber whisper-bridge anfragen
|
||||
logger.info("Transkribiere '%s' via whisper-bridge...", name)
|
||||
text = await request_transcription(ws, wav_path, language="de")
|
||||
if text and text.strip():
|
||||
if not text:
|
||||
logger.warning("Transkription fehlgeschlagen — speichere Platzhalter-Text")
|
||||
text = "Das ist ein Referenz Audio."
|
||||
txt_path.write_text(text.strip(), encoding="utf-8")
|
||||
logger.info("Voice '%s' komplett (txt: %s)", name, text[:80])
|
||||
ref_text_for_response = text.strip()
|
||||
else:
|
||||
# KEIN Platzhalter mehr schreiben! Beim ersten echten TTS-Use wird
|
||||
# on-the-fly nachtranskribiert. Wenn die whisper-bridge dann online
|
||||
# ist, klappt's — sonst koennte der User die .txt manuell anlegen.
|
||||
logger.warning("Voice '%s': Transkription fehlgeschlagen — .txt bleibt leer, "
|
||||
"wird on-the-fly bei erstem Render nachgezogen", name)
|
||||
ref_text_for_response = ""
|
||||
|
||||
await _send(ws, "xtts_voice_saved", {
|
||||
"name": name, "size": int(size_kb * 1024), "refText": ref_text_for_response,
|
||||
"name": name, "size": int(size_kb * 1024), "refText": text.strip(),
|
||||
})
|
||||
# Liste aktualisieren
|
||||
await handle_list_voices(ws)
|
||||
|
|
@ -639,9 +539,6 @@ async def run_loop(runner: F5Runner) -> None:
|
|||
else:
|
||||
fut.set_result(payload.get("text") or "")
|
||||
elif mtype == "config":
|
||||
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
|
||||
asyncio.create_task(runner.update_config(payload))
|
||||
# Voice-Preload bei Wechsel
|
||||
v = (payload.get("xttsVoice") or "").strip()
|
||||
if v and v != _last_diag_voice:
|
||||
_last_diag_voice = v
|
||||
|
|
|
|||
Loading…
Reference in New Issue