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-camera-kit": "^13.0.0",
|
||||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-audio-recorder-player": "^3.6.7",
|
"react-native-audio-recorder-player": "^3.6.7"
|
||||||
"@picovoice/porcupine-react-native": "^3.0.6",
|
|
||||||
"@picovoice/react-native-voice-processor": "^1.2.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import updateService from '../services/updater';
|
||||||
import VoiceButton from '../components/VoiceButton';
|
import VoiceButton from '../components/VoiceButton';
|
||||||
import FileUpload, { FileData } from '../components/FileUpload';
|
import FileUpload, { FileData } from '../components/FileUpload';
|
||||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||||
import { RecordingResult, loadConvWindowMs } from '../services/audio';
|
import { RecordingResult } from '../services/audio';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
|
|
@ -139,11 +139,6 @@ const ChatScreen: React.FC = () => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
|
|
||||||
useEffect(() => {
|
|
||||||
wakeWordService.loadFromStorage().catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
setTtsMuted(prev => {
|
setTtsMuted(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
|
|
@ -390,11 +385,10 @@ const ChatScreen: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubWake = wakeWordService.onWakeWord(async () => {
|
const unsubWake = wakeWordService.onWakeWord(async () => {
|
||||||
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
|
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
|
||||||
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
|
// Aufnahme mit Auto-Stop (VAD) starten
|
||||||
const windowMs = await loadConvWindowMs();
|
const started = await audioService.startRecording(true);
|
||||||
const started = await audioService.startRecording(true, windowMs);
|
|
||||||
if (!started) {
|
if (!started) {
|
||||||
// Mikrofon nicht verfuegbar, naechsten Versuch
|
// Mikrofon nicht verfuegbar, Wake Word wieder aktivieren
|
||||||
wakeWordService.resume();
|
wakeWordService.resume();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -403,7 +397,7 @@ const ChatScreen: React.FC = () => {
|
||||||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
const unsubSilence = audioService.onSilenceDetected(async () => {
|
||||||
const result = await audioService.stopRecording();
|
const result = await audioService.stopRecording();
|
||||||
if (result && result.durationMs > 500) {
|
if (result && result.durationMs > 500) {
|
||||||
// User hat im Fenster gesprochen → Sprachnachricht senden
|
// Sprachnachricht senden (gleiche Logik wie handleVoiceRecording)
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
|
|
@ -420,14 +414,9 @@ const ChatScreen: React.FC = () => {
|
||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
...(location && { location }),
|
...(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 () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,7 @@ import {
|
||||||
VAD_SILENCE_MIN_SEC,
|
VAD_SILENCE_MIN_SEC,
|
||||||
VAD_SILENCE_MAX_SEC,
|
VAD_SILENCE_MAX_SEC,
|
||||||
VAD_SILENCE_STORAGE_KEY,
|
VAD_SILENCE_STORAGE_KEY,
|
||||||
CONV_WINDOW_DEFAULT_SEC,
|
|
||||||
CONV_WINDOW_MIN_SEC,
|
|
||||||
CONV_WINDOW_MAX_SEC,
|
|
||||||
CONV_WINDOW_STORAGE_KEY,
|
|
||||||
} from '../services/audio';
|
} 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 ModeSelector from '../components/ModeSelector';
|
||||||
import QRScanner from '../components/QRScanner';
|
import QRScanner from '../components/QRScanner';
|
||||||
import VoiceCloneModal from '../components/VoiceCloneModal';
|
import VoiceCloneModal from '../components/VoiceCloneModal';
|
||||||
|
|
@ -97,11 +87,6 @@ const SettingsScreen: React.FC = () => {
|
||||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||||
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
|
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
|
||||||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_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 [editingPath, setEditingPath] = useState(false);
|
||||||
const [xttsVoice, setXttsVoice] = useState('');
|
const [xttsVoice, setXttsVoice] = useState('');
|
||||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
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 => {
|
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||||
if (saved) setXttsVoice(saved);
|
if (saved) setXttsVoice(saved);
|
||||||
});
|
});
|
||||||
|
|
@ -632,117 +603,6 @@ const SettingsScreen: React.FC = () => {
|
||||||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
{/* === Sprachausgabe (geraetelokal) === */}
|
{/* === Sprachausgabe (geraetelokal) === */}
|
||||||
|
|
@ -807,13 +667,23 @@ const SettingsScreen: React.FC = () => {
|
||||||
<View style={{marginTop: 20}}>
|
<View style={{marginTop: 20}}>
|
||||||
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz —
|
Eigene Wahl fuer dieses Geraet. Ohne Auswahl gilt der Diagnostic-Default.
|
||||||
ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme.
|
|
||||||
</Text>
|
</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 ? (
|
{availableVoices.length === 0 ? (
|
||||||
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
|
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
|
||||||
Keine geklonten Stimmen vorhanden — unten "Eigene Stimme aufnehmen".
|
Keine eigenen Stimmen auf dem XTTS-Server.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
availableVoices.map(v => (
|
availableVoices.map(v => (
|
||||||
|
|
@ -1415,28 +1285,6 @@ const styles = StyleSheet.create({
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
textAlign: 'center',
|
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;
|
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_MAX_SEC = 8.0;
|
||||||
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
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> {
|
async function loadVadSilenceMs(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
|
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
|
||||||
|
|
@ -178,7 +157,6 @@ class AudioService {
|
||||||
private lastSpeechTime: number = 0;
|
private lastSpeechTime: number = 0;
|
||||||
private vadTimer: ReturnType<typeof setInterval> | null = null;
|
private vadTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.recorder = new AudioRecorderPlayer();
|
this.recorder = new AudioRecorderPlayer();
|
||||||
|
|
@ -211,16 +189,8 @@ class AudioService {
|
||||||
|
|
||||||
// --- Aufnahme ---
|
// --- Aufnahme ---
|
||||||
|
|
||||||
/** Mikrofon-Aufnahme starten.
|
/** Mikrofon-Aufnahme starten */
|
||||||
*
|
async startRecording(autoStop: boolean = false): Promise<boolean> {
|
||||||
* @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> {
|
|
||||||
if (this.recordingState !== 'idle') {
|
if (this.recordingState !== 'idle') {
|
||||||
console.warn('[Audio] Aufnahme laeuft bereits');
|
console.warn('[Audio] Aufnahme laeuft bereits');
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -306,18 +276,6 @@ class AudioService {
|
||||||
}, MAX_RECORDING_MS);
|
}, 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);
|
console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -344,10 +302,6 @@ class AudioService {
|
||||||
clearTimeout(this.maxDurationTimer);
|
clearTimeout(this.maxDurationTimer);
|
||||||
this.maxDurationTimer = null;
|
this.maxDurationTimer = null;
|
||||||
}
|
}
|
||||||
if (this.noSpeechTimer) {
|
|
||||||
clearTimeout(this.noSpeechTimer);
|
|
||||||
this.noSpeechTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.recorder.stopRecorder();
|
await this.recorder.stopRecorder();
|
||||||
|
|
|
||||||
|
|
@ -1,218 +1,56 @@
|
||||||
/**
|
/**
|
||||||
* Gespraechsmodus / Wake Word Service
|
* Gespraechsmodus — "Ohr-Button"
|
||||||
*
|
*
|
||||||
* Drei Zustaende:
|
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
|
||||||
* off — Ohr aus, nichts laeuft
|
* Wie ein Walkie-Talkie / natuerliches Gespraech:
|
||||||
* armed — Ohr aktiv, Porcupine hoert passiv auf das Wake-Word.
|
* ARIA spricht → Aufnahme startet → User spricht → VAD stoppt → ARIA antwortet → ...
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start'
|
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
|
||||||
* direkt in 'conversing' (klassischer Gespraechsmodus). 'endConversation'
|
|
||||||
* geht dann nach 'off' statt 'armed'.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
|
|
||||||
type WakeWordCallback = () => void;
|
type WakeWordCallback = () => void;
|
||||||
type StateCallback = (state: WakeWordState) => void;
|
type StateCallback = (state: WakeWordState) => void;
|
||||||
|
|
||||||
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
export type WakeWordState = 'off' | 'listening' | 'detected';
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
class WakeWordService {
|
class WakeWordService {
|
||||||
private state: WakeWordState = 'off';
|
private state: WakeWordState = 'off';
|
||||||
private wakeCallbacks: WakeWordCallback[] = [];
|
private wakeCallbacks: WakeWordCallback[] = [];
|
||||||
private stateCallbacks: StateCallback[] = [];
|
private stateCallbacks: StateCallback[] = [];
|
||||||
|
|
||||||
// Picovoice Manager (lazy, da Native Module nicht in jedem Build verfuegbar ist)
|
/** Gespraechsmodus starten */
|
||||||
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. */
|
|
||||||
async start(): Promise<boolean> {
|
async start(): Promise<boolean> {
|
||||||
if (this.state !== 'off') return true;
|
if (this.state === 'listening') return true;
|
||||||
if (this.porcupine) {
|
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
|
||||||
// Passives Lauschen via Porcupine
|
this.setState('listening');
|
||||||
try {
|
// Sofort erste Aufnahme starten
|
||||||
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');
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.state === 'conversing') {
|
if (this.state === 'listening') {
|
||||||
this.wakeCallbacks.forEach(cb => cb());
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Komplett ausschalten (Ohr abschalten) */
|
/** Gespraechsmodus stoppen */
|
||||||
async stop(): Promise<void> {
|
stop(): void {
|
||||||
console.log('[WakeWord] Ohr deaktiviert');
|
console.log('[WakeWord] Gespraechsmodus deaktiviert');
|
||||||
if (this.porcupine) {
|
|
||||||
try { await this.porcupine.stop(); } catch {}
|
|
||||||
}
|
|
||||||
this.setState('off');
|
this.setState('off');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */
|
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch 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 */
|
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
if (this.state !== 'conversing') return;
|
if (this.state !== 'listening') return;
|
||||||
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
if (this.state === 'conversing') {
|
if (this.state === 'listening') {
|
||||||
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
|
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
|
||||||
this.wakeCallbacks.forEach(cb => cb());
|
this.wakeCallbacks.forEach(cb => cb());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True solange das Ohr aktiv ist (armed ODER conversing). */
|
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.state !== 'off';
|
return this.state === 'listening';
|
||||||
}
|
|
||||||
|
|
||||||
isConversing(): boolean {
|
|
||||||
return this.state === 'conversing';
|
|
||||||
}
|
|
||||||
|
|
||||||
hasWakeWord(): boolean {
|
|
||||||
return !!this.porcupine;
|
|
||||||
}
|
|
||||||
|
|
||||||
getKeyword(): string {
|
|
||||||
return this.keyword;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Callbacks ---
|
// --- Callbacks ---
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,6 @@ class ARIABridge:
|
||||||
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
|
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
|
||||||
self.tts_enabled = True
|
self.tts_enabled = True
|
||||||
self.xtts_voice = ""
|
self.xtts_voice = ""
|
||||||
self._f5tts_config: dict = {}
|
|
||||||
vc: dict = {}
|
vc: dict = {}
|
||||||
# Gespeicherte Voice-Config laden
|
# Gespeicherte Voice-Config laden
|
||||||
try:
|
try:
|
||||||
|
|
@ -506,16 +505,7 @@ class ARIABridge:
|
||||||
vc = json.load(f)
|
vc = json.load(f)
|
||||||
self.tts_enabled = vc.get("ttsEnabled", True)
|
self.tts_enabled = vc.get("ttsEnabled", True)
|
||||||
self.xtts_voice = vc.get("xttsVoice", "")
|
self.xtts_voice = vc.get("xttsVoice", "")
|
||||||
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
logger.info("Voice-Config geladen: tts=%s voice=%s", self.tts_enabled, self.xtts_voice or "default")
|
||||||
# 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")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
||||||
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
||||||
|
|
@ -973,29 +963,6 @@ class ARIABridge:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("[mode] Broadcast fehlgeschlagen: %s", 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:
|
def _fetch_active_session(self) -> None:
|
||||||
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1065,12 +1032,6 @@ class ARIABridge:
|
||||||
# ihren UI-State sofort syncen koennen
|
# ihren UI-State sofort syncen koennen
|
||||||
await self._broadcast_current_mode()
|
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 senden (RVS erwartet Ping alle 30s)
|
||||||
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
|
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
|
||||||
|
|
||||||
|
|
@ -1234,10 +1195,7 @@ class ARIABridge:
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "config":
|
elif msg_type == "config":
|
||||||
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
|
# 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.
|
|
||||||
changed = False
|
changed = False
|
||||||
if "ttsEnabled" in payload:
|
if "ttsEnabled" in payload:
|
||||||
self.tts_enabled = bool(payload["ttsEnabled"])
|
self.tts_enabled = bool(payload["ttsEnabled"])
|
||||||
|
|
@ -1251,19 +1209,14 @@ class ARIABridge:
|
||||||
new_model = payload["whisperModel"]
|
new_model = payload["whisperModel"]
|
||||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||||
if new_model in allowed and new_model != self.stt_engine.model_size:
|
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)",
|
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt Gamebox)",
|
||||||
new_model)
|
new_model)
|
||||||
self.stt_engine.model_size = new_model
|
self.stt_engine.model_size = new_model
|
||||||
self.stt_engine.model = None
|
self.stt_engine.model = None
|
||||||
changed = True
|
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
|
# Persistent speichern in Shared Volume
|
||||||
if changed:
|
if changed:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1273,7 +1226,6 @@ class ARIABridge:
|
||||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||||
"whisperModel": self.stt_engine.model_size,
|
"whisperModel": self.stt_engine.model_size,
|
||||||
}
|
}
|
||||||
config_data.update(getattr(self, "_f5tts_config", {}))
|
|
||||||
with open("/shared/config/voice_config.json", "w") as f:
|
with open("/shared/config/voice_config.json", "w") as f:
|
||||||
json.dump(config_data, f, indent=2)
|
json.dump(config_data, f, indent=2)
|
||||||
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
|
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>
|
<label class="toggle"><input type="checkbox" id="diag-tts-enabled" checked onchange="sendVoiceConfig()"><span class="slider"></span></label>
|
||||||
</div>
|
</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;">
|
<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;">
|
<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>
|
</select>
|
||||||
<button class="btn secondary" onclick="loadXTTSVoices()" style="padding:4px 10px;font-size:11px;">Laden</button>
|
<button class="btn secondary" onclick="loadXTTSVoices()" style="padding:4px 10px;font-size:11px;">Laden</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -450,58 +450,6 @@
|
||||||
<!-- Gecloned Stimmen — Liste mit Loeschen -->
|
<!-- Gecloned Stimmen — Liste mit Loeschen -->
|
||||||
<div id="xtts-voice-list" style="margin-bottom:12px;"></div>
|
<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 -->
|
<!-- Voice Cloning -->
|
||||||
<div style="background:#1E1E2E;border-radius:8px;padding:12px;margin-top:8px;">
|
<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>
|
<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');
|
const wSel = document.getElementById('diag-whisper-model');
|
||||||
if (wSel) wSel.value = msg.whisperModel;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1632,19 +1570,7 @@
|
||||||
const ttsEnabled = document.getElementById('diag-tts-enabled').checked;
|
const ttsEnabled = document.getElementById('diag-tts-enabled').checked;
|
||||||
const xttsVoice = document.getElementById('diag-xtts-voice').value;
|
const xttsVoice = document.getElementById('diag-xtts-voice').value;
|
||||||
const whisperModel = document.getElementById('diag-whisper-model').value;
|
const whisperModel = document.getElementById('diag-whisper-model').value;
|
||||||
const f5ttsModel = document.getElementById('diag-f5tts-model')?.value || '';
|
send({ action: 'send_voice_config', ttsEnabled, xttsVoice, whisperModel });
|
||||||
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,
|
|
||||||
});
|
|
||||||
const statusEl = document.getElementById('voice-status');
|
const statusEl = document.getElementById('voice-status');
|
||||||
if (statusEl && xttsVoice) {
|
if (statusEl && xttsVoice) {
|
||||||
statusEl.textContent = `⏳ Stimme "${xttsVoice}" wird geladen...`;
|
statusEl.textContent = `⏳ Stimme "${xttsVoice}" wird geladen...`;
|
||||||
|
|
|
||||||
|
|
@ -1423,25 +1423,6 @@ wss.on("connection", (ws) => {
|
||||||
xttsVoice: msg.xttsVoice || "",
|
xttsVoice: msg.xttsVoice || "",
|
||||||
};
|
};
|
||||||
if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel;
|
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 {
|
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));
|
||||||
|
|
|
||||||
|
|
@ -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]
|
capabilities: [gpu]
|
||||||
volumes:
|
volumes:
|
||||||
- ./voices:/voices # WAV + TXT Referenz
|
- ./voices:/voices # WAV + TXT Referenz
|
||||||
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
|
- f5tts-models:/root/.cache/huggingface # Model-Cache persistieren
|
||||||
# Direkt sichtbar im xtts/hf-cache/,
|
|
||||||
# einfach zu loeschen, kein Docker-
|
|
||||||
# Desktop .vhdx Bloat.
|
|
||||||
# Wird mit whisper-bridge geteilt.
|
|
||||||
environment:
|
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_HOST=${RVS_HOST}
|
||||||
- RVS_PORT=${RVS_PORT:-443}
|
- RVS_PORT=${RVS_PORT:-443}
|
||||||
- RVS_TLS=${RVS_TLS:-true}
|
- RVS_TLS=${RVS_TLS:-true}
|
||||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||||
- RVS_TOKEN=${RVS_TOKEN}
|
- RVS_TOKEN=${RVS_TOKEN}
|
||||||
|
- F5TTS_MODEL=${F5TTS_MODEL:-F5TTS_v1_Base}
|
||||||
- F5TTS_DEVICE=${F5TTS_DEVICE:-cuda}
|
- F5TTS_DEVICE=${F5TTS_DEVICE:-cuda}
|
||||||
- VOICES_DIR=/voices
|
- VOICES_DIR=/voices
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -78,5 +73,9 @@ services:
|
||||||
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
|
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
|
||||||
- WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de}
|
- WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de}
|
||||||
volumes:
|
volumes:
|
||||||
- ./hf-cache:/root/.cache/huggingface # gleicher Cache wie f5tts-bridge
|
- whisper-models:/root/.cache/huggingface
|
||||||
restart: unless-stopped
|
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_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||||
|
|
||||||
# F5-TTS Konfiguration
|
F5TTS_MODEL = os.getenv("F5TTS_MODEL", "F5TTS_v1_Base")
|
||||||
# ─────────────────────────────────────────────────────────────────
|
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda")
|
||||||
# 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
|
|
||||||
|
|
||||||
VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
|
VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
|
||||||
|
|
||||||
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
|
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
|
||||||
TARGET_SR = 24000 # F5-TTS native
|
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 ──────────────────────────────────────
|
# ── Lazy F5-TTS Loader ──────────────────────────────────────
|
||||||
|
|
||||||
_F5TTS_cls = None
|
_F5TTS_cls = None
|
||||||
|
|
@ -94,36 +74,18 @@ def _get_f5tts_cls():
|
||||||
|
|
||||||
|
|
||||||
class F5Runner:
|
class F5Runner:
|
||||||
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking).
|
"""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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.model = None
|
self.model = None
|
||||||
self._lock = asyncio.Lock()
|
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:
|
def _load_blocking(self) -> None:
|
||||||
cls = _get_f5tts_cls()
|
cls = _get_f5tts_cls()
|
||||||
logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...",
|
logger.info("Lade F5-TTS '%s' (device=%s)...", F5TTS_MODEL, F5TTS_DEVICE)
|
||||||
self.model_id, F5TTS_DEVICE, self.ckpt_file or "default")
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
kwargs = {"model": self.model_id, "device": F5TTS_DEVICE}
|
self.model = cls(model=F5TTS_MODEL, device=F5TTS_DEVICE)
|
||||||
if self.ckpt_file:
|
logger.info("F5-TTS geladen in %.1fs", time.time() - t0)
|
||||||
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)
|
|
||||||
|
|
||||||
async def ensure_loaded(self) -> None:
|
async def ensure_loaded(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
|
@ -132,49 +94,6 @@ class F5Runner:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, self._load_blocking)
|
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]:
|
def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str) -> tuple[np.ndarray, int]:
|
||||||
wav, sr, _ = self.model.infer(
|
wav, sr, _ = self.model.infer(
|
||||||
ref_file=ref_wav,
|
ref_file=ref_wav,
|
||||||
|
|
@ -182,8 +101,6 @@ class F5Runner:
|
||||||
gen_text=gen_text,
|
gen_text=gen_text,
|
||||||
remove_silence=True,
|
remove_silence=True,
|
||||||
seed=-1,
|
seed=-1,
|
||||||
cfg_strength=self.cfg_strength,
|
|
||||||
nfe_step=self.nfe_step,
|
|
||||||
)
|
)
|
||||||
# F5-TTS gibt float32 1D-Array — auf 24kHz sample-rate standard
|
# F5-TTS gibt float32 1D-Array — auf 24kHz sample-rate standard
|
||||||
if not isinstance(wav, np.ndarray):
|
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:
|
request_id: str, message_id: str, language: str) -> None:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
ref_wav_path, ref_txt_path = voice_paths(voice) if voice else (None, None)
|
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())
|
has_custom = bool(voice and ref_wav_path and ref_wav_path.exists() and ref_txt_path.exists())
|
||||||
if voice and not has_custom:
|
if voice and not has_custom:
|
||||||
# Wenn nur WAV da ist aber kein txt → on-the-fly transkribieren
|
# 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()):
|
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)
|
logger.info("Voice '%s' hat kein txt — transkribiere on-the-fly", voice)
|
||||||
text_ref = await request_transcription(ws, ref_wav_path, language)
|
text_ref = await request_transcription(ws, ref_wav_path, language)
|
||||||
if text_ref and text_ref.strip():
|
if text_ref:
|
||||||
try:
|
try:
|
||||||
ref_txt_path.write_text(text_ref.strip(), encoding="utf-8")
|
ref_txt_path.write_text(text_ref.strip(), encoding="utf-8")
|
||||||
has_custom = True
|
has_custom = True
|
||||||
|
|
@ -491,20 +397,14 @@ async def handle_voice_upload(ws, payload: dict) -> None:
|
||||||
# Transkription ueber whisper-bridge anfragen
|
# Transkription ueber whisper-bridge anfragen
|
||||||
logger.info("Transkribiere '%s' via whisper-bridge...", name)
|
logger.info("Transkribiere '%s' via whisper-bridge...", name)
|
||||||
text = await request_transcription(ws, wav_path, language="de")
|
text = await request_transcription(ws, wav_path, language="de")
|
||||||
if text and text.strip():
|
if not text:
|
||||||
txt_path.write_text(text.strip(), encoding="utf-8")
|
logger.warning("Transkription fehlgeschlagen — speichere Platzhalter-Text")
|
||||||
logger.info("Voice '%s' komplett (txt: %s)", name, text[:80])
|
text = "Das ist ein Referenz Audio."
|
||||||
ref_text_for_response = text.strip()
|
txt_path.write_text(text.strip(), encoding="utf-8")
|
||||||
else:
|
logger.info("Voice '%s' komplett (txt: %s)", name, text[:80])
|
||||||
# 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", {
|
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
|
# Liste aktualisieren
|
||||||
await handle_list_voices(ws)
|
await handle_list_voices(ws)
|
||||||
|
|
@ -639,9 +539,6 @@ async def run_loop(runner: F5Runner) -> None:
|
||||||
else:
|
else:
|
||||||
fut.set_result(payload.get("text") or "")
|
fut.set_result(payload.get("text") or "")
|
||||||
elif mtype == "config":
|
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()
|
v = (payload.get("xttsVoice") or "").strip()
|
||||||
if v and v != _last_diag_voice:
|
if v and v != _last_diag_voice:
|
||||||
_last_diag_voice = v
|
_last_diag_voice = v
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue