feat: TTS-Stimmen-Auswahl + Schrift kleiner einstellbar [appimage]
All checks were successful
Build AppImage / build (push) Successful in 6m49s
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:
parent
fec8aea22c
commit
f821e321aa
3 changed files with 137 additions and 3 deletions
|
|
@ -41,10 +41,43 @@
|
||||||
// Alle Settings aus DB
|
// Alle Settings aus DB
|
||||||
let allSettings: Record<string, string> = $state({});
|
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 ===
|
// === Kategorien ===
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'general', label: 'Allgemein', icon: '⚙️' },
|
{ id: 'general', label: 'Allgemein', icon: '⚙️' },
|
||||||
{ id: 'appearance', label: 'Chat-Darstellung', icon: '🎨' },
|
{ id: 'appearance', label: 'Chat-Darstellung', icon: '🎨' },
|
||||||
|
{ id: 'voice', label: 'Stimme', icon: '🔊' },
|
||||||
{ id: 'model', label: 'Modell', icon: '🤖' },
|
{ id: 'model', label: 'Modell', icon: '🤖' },
|
||||||
{ id: 'commands', label: 'Commands', icon: '⌨️' },
|
{ id: 'commands', label: 'Commands', icon: '⌨️' },
|
||||||
{ id: 'hooks', label: 'Hooks', icon: '🪝' },
|
{ id: 'hooks', label: 'Hooks', icon: '🪝' },
|
||||||
|
|
@ -97,6 +130,7 @@
|
||||||
// === Laden ===
|
// === Laden ===
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
loadVoices();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
|
|
@ -293,7 +327,7 @@
|
||||||
<span class="setting-desc">{$chatAppearance.fontSize} px</span>
|
<span class="setting-desc">{$chatAppearance.fontSize} px</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range" min="11" max="18" step="0.5"
|
type="range" min="6" max="20" step="0.5"
|
||||||
value={$chatAppearance.fontSize}
|
value={$chatAppearance.fontSize}
|
||||||
oninput={(e) => updateChatAppearance({ fontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })}
|
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>
|
<span class="setting-desc">Schriftgroesse fuer Code: {$chatAppearance.codeFontSize} px</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range" min="10" max="16" step="0.5"
|
type="range" min="5" max="18" step="0.5"
|
||||||
value={$chatAppearance.codeFontSize}
|
value={$chatAppearance.codeFontSize}
|
||||||
oninput={(e) => updateChatAppearance({ codeFontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })}
|
oninput={(e) => updateChatAppearance({ codeFontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })}
|
||||||
/>
|
/>
|
||||||
|
|
@ -337,6 +371,49 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/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 -->
|
<!-- MODELL -->
|
||||||
{#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
|
{#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
|
@ -806,6 +883,59 @@
|
||||||
overflow-x: auto;
|
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 */
|
/* Commands */
|
||||||
.command-list {
|
.command-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface ChatAppearance {
|
||||||
lineHeight: number; // unitless
|
lineHeight: number; // unitless
|
||||||
codeFontSize: number; // px — Code-Bloecke separat
|
codeFontSize: number; // px — Code-Bloecke separat
|
||||||
toolCardScale: number; // 0.85..1.15 — Multiplikator fuer Tool-/Hint-Pillen
|
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 = {
|
export const DEFAULT_APPEARANCE: ChatAppearance = {
|
||||||
|
|
@ -21,6 +22,7 @@ export const DEFAULT_APPEARANCE: ChatAppearance = {
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
codeFontSize: 12,
|
codeFontSize: 12,
|
||||||
toolCardScale: 1.0,
|
toolCardScale: 1.0,
|
||||||
|
ttsVoice: 'kerstin',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Vordefinierte Schriftarten — "system" trifft die Plattform-Default-Sans
|
// Vordefinierte Schriftarten — "system" trifft die Plattform-Default-Sans
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { writable, get } from 'svelte/store';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { messages, addMessage } from '$lib/stores/app';
|
import { messages, addMessage } from '$lib/stores/app';
|
||||||
|
import { chatAppearance } from '$lib/stores/chatAppearance';
|
||||||
|
|
||||||
export type ConversationState =
|
export type ConversationState =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
|
|
@ -296,7 +297,8 @@ function prepareTtsText(text: string): string {
|
||||||
async function speakAndWait(text: string): Promise<void> {
|
async function speakAndWait(text: string): Promise<void> {
|
||||||
if (!text || !isActive()) return;
|
if (!text || !isActive()) return;
|
||||||
try {
|
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) => {
|
return new Promise((resolve) => {
|
||||||
if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; }
|
if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; }
|
||||||
ttsAudio = new Audio(`data:audio/wav;base64,${audioBase64}`);
|
ttsAudio = new Audio(`data:audio/wav;base64,${audioBase64}`);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue