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(); }}
+ />
+ )}