From 40e48b046b535a3c969b0d470109d7b5ca10bd39 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 19 Apr 2026 22:33:36 +0200 Subject: [PATCH] feat: App TTS-Einstellungen vereinfacht + Mund-Button fuer lokales Muten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsScreen: - Piper-Reste entfernt (defaultVoice, highlightVoice, Speed-Slider, Highlight-Trigger-Info) - Nur noch EIN Toggle 'Sprachausgabe auf diesem Geraet' — geraetelokal, persistent in aria_tts_enabled (AsyncStorage) - Keine Config-Propagation mehr via RVS (das waere ja global gewesen) - Hinweis dass Stimme + Voice-Cloning zentral in der Diagnose sind ChatScreen: Mund-Button (👄 / 🤐) - Neben Ohr-Button im Eingabebereich, NUR sichtbar wenn TTS im Setting grundsaetzlich aktiv ist - Tap toggelt Mute: 👄 an / 🤐 rot gemutet - Persistent in aria_tts_muted (AsyncStorage) - Stoppt bei Muten sofort laufende Wiedergabe (stopPlayback) - Settings-Toggle wird alle 2s gepollt damit Aenderungen greifen (einfache Loesung ohne globalen State-Context) Audio-Handling respektiert lokalen Zustand - Incoming audio/audio_pcm: nur abspielen wenn ttsDeviceEnabled && !ttsMuted - Cache wird TROTZDEM immer geschrieben — Play-Button funktioniert spaeter aus Cache, auch waehrend Mute - audioService.handlePcmChunk akzeptiert silent-Flag: skipt AudioTrack aber baut weiterhin den WAV-Cache pro messageId Jedes Android-Geraet mit der App hat seinen eigenen Mute-Zustand. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/screens/ChatScreen.tsx | 53 ++++++++-- android/src/screens/SettingsScreen.tsx | 133 ++----------------------- android/src/services/audio.ts | 46 +++++---- 3 files changed, 78 insertions(+), 154 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 732ec48..ed68f87 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -107,6 +107,9 @@ const ChatScreen: React.FC = () => { const [searchVisible, setSearchVisible] = useState(false); const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); + // Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button) + const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true); + const [ttsMuted, setTtsMuted] = useState(false); const flatListRef = useRef(null); const messageIdCounter = useRef(0); @@ -117,6 +120,30 @@ const ChatScreen: React.FC = () => { return `msg_${Date.now()}_${messageIdCounter.current}`; }; + // TTS-Settings beim Mount + bei Screen-Fokus neu laden (damit Settings-Toggle sofort greift) + useEffect(() => { + const loadTtsSettings = async () => { + const enabled = await AsyncStorage.getItem('aria_tts_enabled'); + setTtsDeviceEnabled(enabled !== 'false'); // default true + const muted = await AsyncStorage.getItem('aria_tts_muted'); + setTtsMuted(muted === 'true'); // default false + }; + loadTtsSettings(); + // Poll alle 2s um Settings-Aenderung mitzubekommen (einfache Loesung ohne Context) + const interval = setInterval(loadTtsSettings, 2000); + return () => clearInterval(interval); + }, []); + + const toggleMute = useCallback(() => { + setTtsMuted(prev => { + const next = !prev; + AsyncStorage.setItem('aria_tts_muted', String(next)); + // Bei Muten sofort laufende Wiedergabe stoppen + if (next) audioService.stopPlayback(); + return next; + }); + }, []); + // Chat-Verlauf aus AsyncStorage laden const isInitialLoad = useRef(true); useEffect(() => { @@ -258,12 +285,13 @@ const ChatScreen: React.FC = () => { }); } - // TTS-Audio abspielen wenn vorhanden + // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable + const canPlay = ttsDeviceEnabled && !ttsMuted; if (message.type === 'audio' && message.payload.base64) { const b64 = message.payload.base64 as string; const refId = (message.payload.messageId as string) || ''; - audioService.playAudio(b64); - // Wenn messageId mitgeliefert wurde: Audio in Cache speichern + Pfad in Message eintragen + if (canPlay) audioService.playAudio(b64); + // Cache IMMER schreiben — Play-Button soll auch bei Mute spaeter funktionieren if (refId) { audioService.cacheAudio(b64, refId).then(audioPath => { if (!audioPath) return; @@ -274,12 +302,11 @@ const ChatScreen: React.FC = () => { } } - // XTTS PCM-Stream: direkt an AudioTrack, bei final WAV-Cache schreiben + // XTTS PCM-Stream: Cache IMMER bauen, Playback nur wenn nicht gemutet if (message.type === ('audio_pcm' as any)) { - const p = message.payload as any; + const p = { ...(message.payload as any), silent: !canPlay }; const refId = (p.messageId as string) || ''; audioService.handlePcmChunk(p).then((audioPath: any) => { - // Wenn final + Cache-Pfad zurueckkam, Message aktualisieren if (p.final && audioPath && refId) { setMessages(prev => prev.map(m => m.messageId === refId ? { ...m, audioPath } : m @@ -825,6 +852,17 @@ const ChatScreen: React.FC = () => { disabled={connectionState !== 'connected'} wakeWordActive={wakeWordActive} /> + {/* Mund-Button: TTS auf diesem Geraet muten/aufheben. + Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */} + {ttsDeviceEnabled && ( + + {ttsMuted ? '🤐' : '👄'} + + )} { const [autoDownload, setAutoDownload] = useState(true); const [storageSize, setStorageSize] = useState('...'); const [ttsEnabled, setTtsEnabled] = useState(true); - const [defaultVoice, setDefaultVoice] = useState('ramona'); - const [highlightVoice, setHighlightVoice] = useState('thorsten'); - const [speedRamona, setSpeedRamona] = useState(1.0); - const [speedThorsten, setSpeedThorsten] = useState(1.0); const [editingPath, setEditingPath] = useState(false); const [tempPath, setTempPath] = useState(''); @@ -99,18 +95,6 @@ const SettingsScreen: React.FC = () => { AsyncStorage.getItem('aria_tts_enabled').then(saved => { if (saved !== null) setTtsEnabled(saved === 'true'); }); - AsyncStorage.getItem('aria_default_voice').then(saved => { - if (saved) setDefaultVoice(saved); - }); - AsyncStorage.getItem('aria_highlight_voice').then(saved => { - if (saved) setHighlightVoice(saved); - }); - AsyncStorage.getItem('aria_speed_ramona').then(saved => { - if (saved) setSpeedRamona(parseFloat(saved)); - }); - AsyncStorage.getItem('aria_speed_thorsten').then(saved => { - if (saved) setSpeedThorsten(parseFloat(saved)); - }); }, []); // Speichergroesse berechnen @@ -462,131 +446,28 @@ const SettingsScreen: React.FC = () => { - {/* === Sprachausgabe === */} + {/* === Sprachausgabe (geraetelokal) === */} Sprachausgabe - {/* TTS An/Aus */} - Sprachausgabe - ARIA antwortet per Sprache (TTS) + Sprachausgabe auf diesem Geraet + + Nur lokal — andere Geraete sind unabhaengig. + Wenn aus, erscheint im Chat auch kein Mund-Button. + Stimme und Voice-Cloning werden zentral in der Diagnose eingestellt. + { setTtsEnabled(val); AsyncStorage.setItem('aria_tts_enabled', String(val)); - rvs.send('config' as any, { ttsEnabled: val }); }} trackColor={{ false: '#2A2A3E', true: '#0096FF' }} thumbColor={ttsEnabled ? '#FFFFFF' : '#666680'} /> - - {/* Standard-Stimme */} - - Standard-Stimme - Fuer normale Antworten und Gespraeche - - { setDefaultVoice('ramona'); AsyncStorage.setItem('aria_default_voice', 'ramona'); rvs.send('config' as any, { defaultVoice: 'ramona' }); }} - > - {'\uD83D\uDE4E\u200D\u2640\uFE0F'} - Ramona - Weiblich, warm - - { setDefaultVoice('thorsten'); AsyncStorage.setItem('aria_default_voice', 'thorsten'); rvs.send('config' as any, { defaultVoice: 'thorsten' }); }} - > - {'\uD83E\uDDD4'} - Thorsten - Maennlich, tief - - - - - {/* Highlight-Stimme */} - - Highlight-Stimme - Fuer besondere Ereignisse (Deploy, Alarm, Erfolg) - - { setHighlightVoice('thorsten'); AsyncStorage.setItem('aria_highlight_voice', 'thorsten'); rvs.send('config' as any, { highlightVoice: 'thorsten' }); }} - > - {'\uD83E\uDDD4'} - Thorsten - - { setHighlightVoice('ramona'); AsyncStorage.setItem('aria_highlight_voice', 'ramona'); rvs.send('config' as any, { highlightVoice: 'ramona' }); }} - > - {'\uD83D\uDE4E\u200D\u2640\uFE0F'} - Ramona - - - - - {/* Sprechgeschwindigkeit Ramona */} - - Ramona Speed: {speedRamona.toFixed(1)}x - - {[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(speed => ( - { - setSpeedRamona(speed); - AsyncStorage.setItem('aria_speed_ramona', String(speed)); - rvs.send('config' as any, { speedRamona: speed }); - }} - style={{ - paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, - backgroundColor: speedRamona === speed ? '#0096FF' : '#1E1E2E', - }} - > - - {speed}x - - - ))} - - - - {/* Sprechgeschwindigkeit Thorsten */} - - Thorsten Speed: {speedThorsten.toFixed(1)}x - - {[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(speed => ( - { - setSpeedThorsten(speed); - AsyncStorage.setItem('aria_speed_thorsten', String(speed)); - rvs.send('config' as any, { speedThorsten: speed }); - }} - style={{ - paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, - backgroundColor: speedThorsten === speed ? '#0096FF' : '#1E1E2E', - }} - > - - {speed}x - - - ))} - - - - {/* Highlight-Trigger Info */} - - {'\u26A1'} Highlight-Trigger - - Die Highlight-Stimme wird automatisch bei diesen Woertern verwendet:{'\n'} - deploy, erfolgreich, alarm, so soll es sein, kritisch, server down, sicherheitswarnung, ticket geloest, aufgabe abgeschlossen - - {/* === Speicher === */} diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 2a2814f..839989b 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -335,7 +335,8 @@ class AudioService { } } - /** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen und spielen/cachen. + /** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen. + * silent=true → nur cachen, nicht abspielen (z.B. wenn TTS geraetelokal gemutet). * Gibt bei final=true den Cache-Pfad zurueck (file://) oder '' wenn nicht gecached. */ async handlePcmChunk(payload: { base64: string; @@ -344,8 +345,10 @@ class AudioService { messageId?: string; chunk?: number; final?: boolean; + silent?: boolean; }): Promise { - if (!PcmStreamPlayer) { + const silent = !!payload.silent; + if (!silent && !PcmStreamPlayer) { console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); return ''; } @@ -358,10 +361,8 @@ class AudioService { // Neuer Stream? (messageId Wechsel oder nicht aktiv) if (!this.pcmStreamActive || this.pcmMessageId !== messageId) { - // Vorherigen Stream clean beenden (falls da) - if (this.pcmStreamActive) { - try { await PcmStreamPlayer.stop(); } catch {} - // Altes Buffer verwerfen (wurde nicht final — neue Message kam dazwischen) + if (this.pcmStreamActive && !silent) { + try { await PcmStreamPlayer!.stop(); } catch {} this.pcmBuffer = []; this.pcmBytesCollected = 0; } @@ -371,35 +372,36 @@ class AudioService { this.pcmChannels = channels; this.pcmBuffer = []; this.pcmBytesCollected = 0; - try { - await PcmStreamPlayer.start(sampleRate, channels); - } catch (err) { - console.error('[Audio] PcmStreamPlayer.start fehlgeschlagen:', err); - this.pcmStreamActive = false; - return ''; + if (!silent) { + try { + await PcmStreamPlayer!.start(sampleRate, channels); + } catch (err) { + console.error('[Audio] PcmStreamPlayer.start fehlgeschlagen:', err); + this.pcmStreamActive = false; + return ''; + } + AudioFocus?.requestDuck().catch(() => {}); } - // Audio-Focus: andere Apps ducken - AudioFocus?.requestDuck().catch(() => {}); } - // Chunk abspielen + cachen + // Chunk — immer cachen, nur bei !silent auch abspielen if (base64) { - try { await PcmStreamPlayer.writeChunk(base64); } catch (err) { console.warn('[Audio] writeChunk', err); } - // Buffer fuer Cache sammeln (wenn noch nicht zu gross) + if (!silent) { + try { await PcmStreamPlayer!.writeChunk(base64); } catch (err) { console.warn('[Audio] writeChunk', err); } + } if (messageId && this.pcmBytesCollected < this.PCM_MAX_CACHE_BYTES) { this.pcmBuffer.push(base64); - // 4 base64-chars ≈ 3 bytes — grobe Schaetzung this.pcmBytesCollected += Math.floor(base64.length * 0.75); } } if (isFinal) { - // Stream sauber beenden (spielt noch bis Puffer leer ist) - try { await PcmStreamPlayer.end(); } catch {} + if (!silent) { + try { await PcmStreamPlayer!.end(); } catch {} + AudioFocus?.release().catch(() => {}); + } this.pcmStreamActive = false; - AudioFocus?.release().catch(() => {}); - // Aus gesammelten PCM-Chunks eine WAV-Datei fuer Replay bauen if (messageId && this.pcmBuffer.length > 0) { const audioPath = await this._savePcmBufferAsWav(messageId); this.pcmBuffer = [];