feat: TTS-Stimmen-Auswahl + Schrift kleiner einstellbar [appimage]
All checks were successful
Build AppImage / build (push) Successful in 6m49s

- 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) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-27 17:13:15 +02:00
parent fec8aea22c
commit f821e321aa
3 changed files with 137 additions and 3 deletions

View file

@ -41,10 +41,43 @@
// Alle Settings aus DB
let allSettings: Record<string, string> = $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<VoiceMeta[]>('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<string>('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 @@
<span class="setting-desc">{$chatAppearance.fontSize} px</span>
</div>
<input
type="range" min="11" max="18" step="0.5"
type="range" min="6" max="20" step="0.5"
value={$chatAppearance.fontSize}
oninput={(e) => updateChatAppearance({ fontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })}
/>
@ -315,7 +349,7 @@
<span class="setting-desc">Schriftgroesse fuer Code: {$chatAppearance.codeFontSize} px</span>
</div>
<input
type="range" min="10" max="16" step="0.5"
type="range" min="5" max="18" step="0.5"
value={$chatAppearance.codeFontSize}
oninput={(e) => updateChatAppearance({ codeFontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })}
/>
@ -337,6 +371,49 @@
{/if}
{/if}
<!-- STIMME / TTS -->
{#if activeCategory === 'voice' || (searchQuery && 'stimme voice tts piper sprache vorlesen'.includes(searchQuery.toLowerCase()))}
<section class="section">
<h3>🔊 Sprachausgabe (TTS)</h3>
<p class="section-desc">
Welche Stimme spricht im Konversationsmodus (Mikro lange halten).
Lokale Piper-Stimmen — fehlende Modelle bitte als <code>.onnx</code> nach
<code>~/.local/share/claude-desktop/models/</code> legen.
</p>
{#each availableVoices as v}
<div class="setting-row voice-row">
<label class="voice-label">
<input
type="radio"
name="tts-voice"
value={v.id}
checked={$chatAppearance.ttsVoice === v.id}
disabled={!v.available}
onchange={() => updateChatAppearance({ ttsVoice: v.id })}
/>
<div class="voice-info">
<span class="voice-name">{v.name} {v.default ? '(Standard)' : ''}</span>
<span class="voice-desc">{v.description}</span>
{#if !v.available}
<span class="voice-missing">⚠ Modell nicht installiert</span>
{/if}
</div>
</label>
<button
class="btn-secondary"
onclick={() => previewVoice(v.id)}
disabled={!v.available || voicePreviewBusy}
>
▶ Anhören
</button>
</div>
{/each}
{#if voicePreviewError}
<p class="voice-error">{voicePreviewError}</p>
{/if}
</section>
{/if}
<!-- MODELL -->
{#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
<section class="section">
@ -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;

View file

@ -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

View file

@ -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<void> {
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}`);