From f821e321aa85fda4272de4b0c9873e931523d6ff Mon Sep 17 00:00:00 2001 From: Eddy Date: Mon, 27 Apr 2026 17:13:15 +0200 Subject: [PATCH] feat: TTS-Stimmen-Auswahl + Schrift kleiner einstellbar [appimage] - Neue Settings-Sektion "Stimme" mit Radio-Buttons fuer alle Piper-Voices - Anhoer-Button pro Stimme mit Beispielsatz - TtsVoice in chatAppearance-Store persistiert, ConversationEngine nutzt Auswahl - Schriftgroesse jetzt 6-20 px (vorher 8-18), Code-Bloecke 5-18 px (vorher 7-16) - Empfohlen: thorsten-high (maennlich, beste Qualitaet) oder ramona / eva_k / kerstin (weiblich, low-quality) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/components/SettingsPanel.svelte | 134 +++++++++++++++++++++++- src/lib/stores/chatAppearance.ts | 2 + src/lib/voice/conversationEngine.ts | 4 +- 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index 4838b83..f23727b 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -41,10 +41,43 @@ // Alle Settings aus DB let allSettings: Record = $state({}); + // Voice/TTS + interface VoiceMeta { id: string; name: string; description: string; available: boolean; default: boolean; } + let availableVoices: VoiceMeta[] = $state([]); + let voicePreviewBusy = $state(false); + let voicePreviewError = $state(''); + + async function loadVoices() { + try { + availableVoices = await invoke('get_tts_voices'); + } catch (err) { + console.error('TTS-Stimmen laden fehlgeschlagen:', err); + } + } + + async function previewVoice(voiceId: string) { + voicePreviewBusy = true; + voicePreviewError = ''; + try { + const audioBase64 = await invoke('text_to_speech', { + text: 'Hallo Eddy, so klinge ich. Passt das?', + voice: voiceId + }); + const audio = new Audio(`data:audio/wav;base64,${audioBase64}`); + audio.onended = () => { voicePreviewBusy = false; }; + audio.onerror = () => { voicePreviewBusy = false; voicePreviewError = 'Wiedergabe fehlgeschlagen'; }; + await audio.play(); + } catch (err) { + voicePreviewBusy = false; + voicePreviewError = String(err); + } + } + // === Kategorien === const categories = [ { id: 'general', label: 'Allgemein', icon: 'βš™οΈ' }, { id: 'appearance', label: 'Chat-Darstellung', icon: '🎨' }, + { id: 'voice', label: 'Stimme', icon: 'πŸ”Š' }, { id: 'model', label: 'Modell', icon: 'πŸ€–' }, { id: 'commands', label: 'Commands', icon: '⌨️' }, { id: 'hooks', label: 'Hooks', icon: 'πŸͺ' }, @@ -97,6 +130,7 @@ // === Laden === onMount(async () => { await loadAll(); + loadVoices(); }); async function loadAll() { @@ -293,7 +327,7 @@ {$chatAppearance.fontSize} px updateChatAppearance({ fontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })} /> @@ -315,7 +349,7 @@ Schriftgroesse fuer Code: {$chatAppearance.codeFontSize} px updateChatAppearance({ codeFontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })} /> @@ -337,6 +371,49 @@ {/if} {/if} + + {#if activeCategory === 'voice' || (searchQuery && 'stimme voice tts piper sprache vorlesen'.includes(searchQuery.toLowerCase()))} +
+

πŸ”Š Sprachausgabe (TTS)

+

+ Welche Stimme spricht im Konversationsmodus (Mikro lange halten). + Lokale Piper-Stimmen β€” fehlende Modelle bitte als .onnx nach + ~/.local/share/claude-desktop/models/ legen. +

+ {#each availableVoices as v} +
+ + +
+ {/each} + {#if voicePreviewError} +

{voicePreviewError}

+ {/if} +
+ {/if} + {#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
@@ -806,6 +883,59 @@ overflow-x: auto; } + /* Voice/TTS */ + .voice-row { + gap: 12px; + align-items: center; + } + .voice-label { + flex: 1; + display: flex; + gap: 10px; + align-items: flex-start; + cursor: pointer; + } + .voice-label input[type="radio"] { + margin-top: 4px; + flex-shrink: 0; + } + .voice-info { + display: flex; + flex-direction: column; + gap: 2px; + } + .voice-name { + font-weight: 500; + color: var(--text-primary); + } + .voice-desc { + font-size: 0.75rem; + color: var(--text-secondary); + } + .voice-missing { + font-size: 0.7rem; + color: var(--accent-warning, #d4a657); + margin-top: 2px; + } + .voice-error { + color: var(--accent-error, #c44); + font-size: 0.8rem; + margin: 8px 0 0 0; + } + .section-desc { + color: var(--text-secondary); + font-size: 0.8rem; + margin: 0 0 12px 0; + line-height: 1.45; + } + .section-desc code { + background: var(--bg-secondary); + padding: 1px 5px; + border-radius: 3px; + font-family: ui-monospace, monospace; + font-size: 0.85em; + } + /* Commands */ .command-list { display: flex; diff --git a/src/lib/stores/chatAppearance.ts b/src/lib/stores/chatAppearance.ts index f651af2..4320962 100644 --- a/src/lib/stores/chatAppearance.ts +++ b/src/lib/stores/chatAppearance.ts @@ -13,6 +13,7 @@ export interface ChatAppearance { lineHeight: number; // unitless codeFontSize: number; // px β€” Code-Bloecke separat toolCardScale: number; // 0.85..1.15 β€” Multiplikator fuer Tool-/Hint-Pillen + ttsVoice: string; // Piper-Voice-ID, z.B. 'kerstin', 'thorsten-high' } export const DEFAULT_APPEARANCE: ChatAppearance = { @@ -21,6 +22,7 @@ export const DEFAULT_APPEARANCE: ChatAppearance = { lineHeight: 1.5, codeFontSize: 12, toolCardScale: 1.0, + ttsVoice: 'kerstin', }; // Vordefinierte Schriftarten β€” "system" trifft die Plattform-Default-Sans diff --git a/src/lib/voice/conversationEngine.ts b/src/lib/voice/conversationEngine.ts index 9ecd775..12b88d3 100644 --- a/src/lib/voice/conversationEngine.ts +++ b/src/lib/voice/conversationEngine.ts @@ -20,6 +20,7 @@ import { writable, get } from 'svelte/store'; import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { messages, addMessage } from '$lib/stores/app'; +import { chatAppearance } from '$lib/stores/chatAppearance'; export type ConversationState = | 'idle' @@ -296,7 +297,8 @@ function prepareTtsText(text: string): string { async function speakAndWait(text: string): Promise { if (!text || !isActive()) return; try { - const audioBase64: string = await invoke('text_to_speech', { text, voice: null }); + const voice = get(chatAppearance).ttsVoice || null; + const audioBase64: string = await invoke('text_to_speech', { text, voice }); return new Promise((resolve) => { if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; } ttsAudio = new Audio(`data:audio/wav;base64,${audioBase64}`);