feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung

Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
  (Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
  mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
  (isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
  geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
  serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
  pro Seitenladen).

GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
  gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.

Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-27 04:16:21 +02:00
commit 5e128a74ce
7 changed files with 333 additions and 114 deletions

View file

@ -84,8 +84,8 @@ function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() :
if (TTS_SUPPORTED) {
loadVoices();
// Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen).
speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); refreshDevicePreset(); });
setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt
speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); applyTtsVisibility(); });
setTimeout(() => applyTtsVisibility(), 1500); // Backstop, falls kein voiceschanged kommt
}
// Die Web Speech API kennt KEIN Standard-Geschlechtsfeld an einer Stimme -> Heuristik
@ -237,75 +237,61 @@ function applyPlaybackChoice(serverProvider) {
} else if (!ttsSel.value) {
ttsSel.value = "piper"; // Fallback: Server
}
refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI
applyTtsVisibility(); // blendet Provider passend ein/aus + syncPresetUI
}
// Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch,
// sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die
// gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben.
function refreshDevicePreset() {
const btn = document.querySelector('.tts-preset[data-tts="device"]');
if (!btn || !ttsSel) return;
const ready = deviceVoicesReady();
btn.disabled = !ready;
btn.classList.toggle("opacity-50", !ready);
btn.classList.toggle("cursor-not-allowed", !ready);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = ready
// ── TTS-Menü: Sichtbarkeit der Provider (eine Quelle: Backend /api/config) ──
// Regeln liegen im Backend (tts_menu); hier nur rendern + die client-only-Regel
// „Im Gerät nur auf Mobilgeräten" (isMobile). Ausgeblendet, nicht ausgegraut.
let _ttsMenu = null; // zuletzt geladenes tts_menu von /api/config
const TTS_DEFAULT = "piper"; // garantiert sichtbarer Fallback -> Menü nie leer
function _ttsVisible(provider) {
if (provider === "device") return TTS_SUPPORTED && isMobile(); // nur mobil
if (provider === TTS_DEFAULT) return true; // garantierter Default
if (!_ttsMenu) return true; // vor dem Laden nichts verstecken
const e = _ttsMenu.find((m) => m.provider === provider);
return e ? e.visible !== false : true;
}
// Blendet Provider-Buttons (und die zugehörigen <option>) ein/aus und klemmt die
// aktive Auswahl auf einen sichtbaren Provider (sonst spielt nichts / Server-422).
function applyTtsVisibility() {
if (!ttsSel) return;
document.querySelectorAll(".tts-preset").forEach((btn) => {
const p = btn.dataset.tts;
const vis = _ttsVisible(p);
btn.classList.toggle("hidden", !vis);
const opt = ttsSel.querySelector(`option[value="${p}"]`);
if (opt) { opt.hidden = !vis; opt.disabled = !vis; }
});
const dsub = document.querySelector('.tts-preset[data-tts="device"] .tts-sub');
if (dsub) dsub.textContent = deviceVoicesReady()
? "Das Gerät liest vor · spart Daten"
: "keine Sprachstimmen im Browser";
if (ready) {
if (localStorage.getItem(PLAYBACK_KEY) === "device" && ttsSel.value !== "device") {
ttsSel.value = "device"; // gespeicherte Präferenz wieder aktivieren
: "Liest über den Server vor";
if (!_ttsVisible(ttsSel.value)) {
const wasServer = ttsSel.value && ttsSel.value !== "device";
ttsSel.value = TTS_DEFAULT;
if (wasServer) {
savePrefs({ tts_provider: TTS_DEFAULT }); // verhindert Server-422 beim nächsten Turn
if (statusEl) statusEl.textContent = "Stimme nicht verfügbar auf „Schnell“ umgestellt";
}
} else if (ttsSel.value === "device") {
ttsSel.value = "piper"; // Server statt deaktivierter Option (Präferenz bleibt)
} else if (localStorage.getItem(PLAYBACK_KEY) === "device"
&& _ttsVisible("device") && ttsSel.value !== "device") {
ttsSel.value = "device"; // gespeicherte Geräte-Präferenz wieder aktivieren
}
syncPresetUI();
updateCartesiaLangHint();
}
// Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist.
async function refreshChatterboxPreset() {
const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]');
if (!btn || !ttsSel) return;
// Holt die Sichtbarkeiten einmal vom (gecachten) Backend und wendet sie an.
async function refreshTtsMenu() {
try {
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
const available = cfg.available?.chatterbox_reachable === true;
btn.disabled = !available;
btn.classList.toggle("opacity-50", !available);
btn.classList.toggle("cursor-not-allowed", !available);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = available
? "Chatterbox · natürliche Stimme"
: "Chatterbox · kein GPU-Backend erreichbar";
if (!available && ttsSel.value === "chatterbox") {
ttsSel.value = "piper";
syncPresetUI();
}
} catch (e) { /* ignorieren */ }
}
// Graut "Cartesia" aus, wenn API-Key oder Voice-ID fehlt.
async function refreshCartesiaPreset() {
const btn = document.querySelector('.tts-preset[data-tts="cartesia"]');
if (!btn || !ttsSel) return;
try {
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
const available = cfg.available?.cartesia_available === true;
const cfg = await fetch("/api/config", { cache: "no-store" }).then((r) => r.json());
_ttsMenu = cfg.tts_menu || null;
cartesiaUnsupportedLangs = cfg.available?.cartesia_unsupported_languages || [];
btn.disabled = !available;
btn.classList.toggle("opacity-50", !available);
btn.classList.toggle("cursor-not-allowed", !available);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = available
? "Cartesia Sonic · natürlich & schnell"
: "Cartesia · kein API-Key konfiguriert";
if (!available && ttsSel.value === "cartesia") {
ttsSel.value = "piper";
syncPresetUI();
}
updateCartesiaLangHint();
} catch (e) { /* ignorieren */ }
} catch (e) { /* offline -> nichts verstecken */ }
applyTtsVisibility();
}
// Zeigt einen Hinweis, wenn Cartesia gewählt ist, die feste Sprache aber nicht
@ -326,27 +312,6 @@ function updateCartesiaLangHint() {
}
}
// Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist.
async function refreshOpenrouterTtsPreset() {
const btn = document.querySelector('.tts-preset[data-tts="openrouter"]');
if (!btn || !ttsSel) return;
try {
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
const available = cfg.available?.openrouter_tts_available === true;
btn.disabled = !available;
btn.classList.toggle("opacity-50", !available);
btn.classList.toggle("cursor-not-allowed", !available);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = available
? "OpenRouter · benötigt Internet"
: "OpenRouter · kein TTS-Modell verfügbar";
if (!available && ttsSel.value === "openrouter") {
ttsSel.value = "piper";
syncPresetUI();
}
} catch (e) { /* ignorieren */ }
}
// Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts.
function syncPresetUI() {
const val = ttsSel ? ttsSel.value : "";
@ -364,6 +329,7 @@ function syncPresetUI() {
function openMenu() {
$("#settings-sheet").classList.remove("hidden");
$("#settings-backdrop").classList.remove("hidden");
refreshTtsMenu(); // beim Öffnen neu bewerten (Backend kann zur Laufzeit gewechselt haben)
}
function closeMenu() {
$("#settings-sheet").classList.add("hidden");
@ -963,9 +929,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
loadMe();
refreshChatterboxPreset();
refreshCartesiaPreset();
refreshOpenrouterTtsPreset();
refreshTtsMenu();
// ═══════════════════════════════════════════════════════════════
// ADMIN PANEL