diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0ec580f..182ac8a 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -205,19 +205,20 @@ Disk meters are intentionally absent (models live on the Ollama hosts). `TTS_PROVIDER` forces one; `TTS_VOICE` / `ELEVENLABS_VOICE` set defaults. Output is cached on disk (sha of provider|voice|text, 200 files cap), rate-limited to 30 jobs/hour/account (429), kill-switched via - `tools_tts_enabled` (Admin Center → Features). Test overrides: - `ELEVENLABS_BASE_URL` / `OPENAI_BASE_URL`. + `tools_tts_enabled` (Admin Center → Features). Preview lines are + pre-generated once at boot and right after each preview save, so serving + users always hits the cache (`X-TTS-Cache: HIT/MISS` proves it). Test + overrides: `ELEVENLABS_BASE_URL` / `OPENAI_BASE_URL`. - **Settings → Voice** (per browser, localStorage): engine, language (auto-detect per message or forced), browser voice/rate/pitch, neural voice id, and one-tap previews of the admin-set lines. - **Admin Center → Features → Voice previews**: the default playback lines (`voice_previews` setting, 1–6 lines `{text, lang, rate, pitch, timbre, voice}`, each with its OWN voice character — masculine/feminine/any - timbre hint, speed, pitch, optional server voice id — so previews sound - different from each other; timbre matching is best-effort over the - browser's voice names, rate/pitch always apply; empty list resets to the - built-ins), also served to users via `GET /api/tools`. Users can adopt - any preview's character as their own playback voice from Settings → Voice. + timbre hint, speed, pitch, optional server voice id picked from a select + menu fed by the provider catalog), also served to users via + `GET /api/tools`. Users can adopt any preview's character as their own + playback voice from Settings → Voice. ## Personal providers (BYOK) diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 8ebd345..a248f56 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -348,6 +348,7 @@ function VoicePreviewsCard() { const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const [serverVoices, setServerVoices] = useState<{ id: string; name: string }[]>([]); useEffect(() => { api.adminGetSettings() @@ -356,6 +357,7 @@ function VoicePreviewsCard() { setLoaded(true); }) .catch(() => setLoaded(true)); + api.ttsInfo().then((r) => setServerVoices(r.voices)).catch(() => {}); }, []); async function save(next: VoicePreview[]) { @@ -453,16 +455,30 @@ function VoicePreviewsCard() { - patch(i, { voice: e.target.value })} - onBlur={() => commit(i)} - onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} - /> + {serverVoices.length > 0 ? ( + { + patch(i, { voice: v }); + const next = lines.map((l, j) => (j === i ? { ...l, voice: v } : l)); + if (next[i]!.text.trim() !== "") void save(next); + }} + align="left" + options={[{ value: "", label: t("vfeat.voicePh") }, ...serverVoices.map((v) => ({ value: v.id, label: v.name }))]} + /> + ) : ( + patch(i, { voice: e.target.value })} + onBlur={() => commit(i)} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + /> + )}