This commit is contained in:
k
2026-09-24 11:37:02 +02:00
parent 8d72defb82
commit 30918f33e7
7 changed files with 129 additions and 34 deletions
+8 -7
View File
@@ -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. `TTS_PROVIDER` forces one; `TTS_VOICE` / `ELEVENLABS_VOICE` set defaults.
Output is cached on disk (sha of provider|voice|text, 200 files cap), Output is cached on disk (sha of provider|voice|text, 200 files cap),
rate-limited to 30 jobs/hour/account (429), kill-switched via rate-limited to 30 jobs/hour/account (429), kill-switched via
`tools_tts_enabled` (Admin Center → Features). Test overrides: `tools_tts_enabled` (Admin Center → Features). Preview lines are
`ELEVENLABS_BASE_URL` / `OPENAI_BASE_URL`. 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 - **Settings → Voice** (per browser, localStorage): engine, language
(auto-detect per message or forced), browser voice/rate/pitch, neural (auto-detect per message or forced), browser voice/rate/pitch, neural
voice id, and one-tap previews of the admin-set lines. voice id, and one-tap previews of the admin-set lines.
- **Admin Center → Features → Voice previews**: the default playback lines - **Admin Center → Features → Voice previews**: the default playback lines
(`voice_previews` setting, 1–6 lines `{text, lang, rate, pitch, timbre, (`voice_previews` setting, 1–6 lines `{text, lang, rate, pitch, timbre,
voice}`, each with its OWN voice character — masculine/feminine/any voice}`, each with its OWN voice character — masculine/feminine/any
timbre hint, speed, pitch, optional server voice id — so previews sound timbre hint, speed, pitch, optional server voice id picked from a select
different from each other; timbre matching is best-effort over the menu fed by the provider catalog), also served to users via
browser's voice names, rate/pitch always apply; empty list resets to the `GET /api/tools`. Users can adopt any preview's character as their own
built-ins), also served to users via `GET /api/tools`. Users can adopt playback voice from Settings → Voice.
any preview's character as their own playback voice from Settings → Voice.
## Personal providers (BYOK) ## Personal providers (BYOK)
+26 -10
View File
@@ -348,6 +348,7 @@ function VoicePreviewsCard() {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [serverVoices, setServerVoices] = useState<{ id: string; name: string }[]>([]);
useEffect(() => { useEffect(() => {
api.adminGetSettings() api.adminGetSettings()
@@ -356,6 +357,7 @@ function VoicePreviewsCard() {
setLoaded(true); setLoaded(true);
}) })
.catch(() => setLoaded(true)); .catch(() => setLoaded(true));
api.ttsInfo().then((r) => setServerVoices(r.voices)).catch(() => {});
}, []); }, []);
async function save(next: VoicePreview[]) { async function save(next: VoicePreview[]) {
@@ -453,16 +455,30 @@ function VoicePreviewsCard() {
</span> </span>
<span className="w-40"> <span className="w-40">
<Field label={t("vfeat.voice")} hint={t("vfeat.voiceHint")}> <Field label={t("vfeat.voice")} hint={t("vfeat.voiceHint")}>
<Input {serverVoices.length > 0 ? (
value={line.voice} <Picker
disabled={busy} ariaLabel={t("vfeat.voice")}
maxLength={64} value={line.voice}
placeholder={t("vfeat.voicePh")} onChange={(v) => {
aria-label={t("vfeat.voice")} patch(i, { voice: v });
onChange={(e) => patch(i, { voice: e.target.value })} const next = lines.map((l, j) => (j === i ? { ...l, voice: v } : l));
onBlur={() => commit(i)} if (next[i]!.text.trim() !== "") void save(next);
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} }}
/> align="left"
options={[{ value: "", label: t("vfeat.voicePh") }, ...serverVoices.map((v) => ({ value: v.id, label: v.name }))]}
/>
) : (
<Input
value={line.voice}
disabled={busy}
maxLength={64}
placeholder={t("vfeat.voicePh")}
aria-label={t("vfeat.voice")}
onChange={(e) => patch(i, { voice: e.target.value })}
onBlur={() => commit(i)}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
/>
)}
</Field> </Field>
</span> </span>
<label className="min-w-32 flex-1 text-xs opacity-80"> <label className="min-w-32 flex-1 text-xs opacity-80">
+3 -3
View File
@@ -877,7 +877,7 @@ function VoiceSection() {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]); const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [previews, setPreviews] = useState<VoicePreview[]>([]); const [previews, setPreviews] = useState<VoicePreview[]>([]);
const [playing, setPlaying] = useState<number | null>(null); const [playing, setPlaying] = useState<number | null>(null);
const [ttsServer, setTtsServer] = useState<{ available: boolean; provider: string | null; voices: string[] } | null>(null); const [ttsServer, setTtsServer] = useState<{ available: boolean; provider: string | null; voices: { id: string; name: string }[] } | null>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -979,7 +979,7 @@ function VoiceSection() {
</Field> </Field>
{(prefs.engine !== "browser") && ( {(prefs.engine !== "browser") && (
ttsServer?.available ? ( ttsServer?.available ? (
ttsServer.provider === "openai" ? ( ttsServer.voices.length > 0 ? (
<Field label={t("voice.serverVoice")}> <Field label={t("voice.serverVoice")}>
<Picker <Picker
ariaLabel={t("voice.serverVoice")} ariaLabel={t("voice.serverVoice")}
@@ -987,7 +987,7 @@ function VoiceSection() {
value={prefs.serverVoice ?? ""} value={prefs.serverVoice ?? ""}
onChange={(v) => update({ serverVoice: v || null })} onChange={(v) => update({ serverVoice: v || null })}
align="left" align="left"
options={[{ value: "", label: t("voice.defaultVoice") }, ...ttsServer.voices.map((v) => ({ value: v, label: v }))]} options={[{ value: "", label: t("voice.defaultVoice") }, ...ttsServer.voices.map((v) => ({ value: v.id, label: v.name }))]}
/> />
</Field> </Field>
) : ttsServer.provider === "elevenlabs" ? ( ) : ttsServer.provider === "elevenlabs" ? (
+26 -6
View File
@@ -123,21 +123,23 @@ function mixHex(hex: string, target: number, amount: number): string {
} }
/** /**
* Favicon follows the accent: a mini flower (petals in the accent ramp, * Favicon follows the accent AND the theme: the tile background is
* amber core like the logo) swapped in as an SVG data URL, so the tab icon * theme-owned (white by day, near-black by night) while the flower itself
* always matches the platform dress. * wears the accent ramp — never an accent tile.
*/ */
export function paintFavicon(baseHex: string): void { export function paintFavicon(baseHex: string, dark?: boolean): void {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const base = /^#?[0-9a-f]{6}$/i.test(baseHex.trim()) ? baseHex.trim().replace(/^#?/, "#") : "#10b981"; const base = /^#?[0-9a-f]{6}$/i.test(baseHex.trim()) ? baseHex.trim().replace(/^#?/, "#") : "#10b981";
const dark = mixHex(base, 0, 0.35); const isDark = dark ?? document.documentElement.classList.contains("dark");
const tile = isDark ? "#09090b" : "#ffffff";
const ring = isDark ? "#27272a" : "#e7e5e4";
const light = mixHex(base, 255, 0.55); const light = mixHex(base, 255, 0.55);
const petals = [8, 80, 151, 223, 295].map( const petals = [8, 80, 151, 223, 295].map(
(a) => `<ellipse cx="50" cy="28" rx="13" ry="20" fill="${base}" transform="rotate(${a} 50 50)"/>`, (a) => `<ellipse cx="50" cy="28" rx="13" ry="20" fill="${base}" transform="rotate(${a} 50 50)"/>`,
).join(""); ).join("");
const svg = const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` +
`<rect width="100" height="100" rx="22" fill="${dark}"/>${petals}` + `<rect width="100" height="100" rx="22" fill="${tile}" stroke="${ring}" stroke-width="3"/>${petals}` +
`<ellipse cx="50" cy="28" rx="13" ry="20" fill="${light}" opacity="0.45" transform="rotate(8 50 50)"/>` + `<ellipse cx="50" cy="28" rx="13" ry="20" fill="${light}" opacity="0.45" transform="rotate(8 50 50)"/>` +
`<circle cx="50" cy="50" r="10" fill="#fbbf24"/></svg>`; `<circle cx="50" cy="50" r="10" fill="#fbbf24"/></svg>`;
const url = `data:image/svg+xml,${encodeURIComponent(svg)}`; const url = `data:image/svg+xml,${encodeURIComponent(svg)}`;
@@ -153,6 +155,24 @@ export function paintFavicon(baseHex: string): void {
} }
} }
/** Last accent base, so theme flips can repaint without knowing the color. */
let lastBase = "#10b981";
function repaint(): void {
paintFavicon(lastBase);
}
if (typeof document !== "undefined" && typeof MutationObserver !== "undefined") {
new MutationObserver((muts) => {
for (const m of muts) {
if (m.attributeName === "class") {
repaint();
break;
}
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
}
// Restore the saved accent on load. // Restore the saved accent on load.
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
applyAccent(currentAccent()); applyAccent(currentAccent());
+1 -1
View File
@@ -446,7 +446,7 @@ export const api = {
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"), tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"),
/** Neural TTS provider status (null = browser speech fallback). */ /** Neural TTS provider status (null = browser speech fallback). */
ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: string[]; available: boolean; reason: string | null }>("/api/tools/tts/info"), ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: { id: string; name: string }[]; available: boolean; reason: string | null }>("/api/tools/tts/info"),
/** Synthesize text server-side (neural voice). Returns audio bytes. */ /** Synthesize text server-side (neural voice). Returns audio bytes. */
ttsSpeak: async (text: string, voice?: string): Promise<Blob> => { ttsSpeak: async (text: string, voice?: string): Promise<Blob> => {
+30 -2
View File
@@ -287,6 +287,28 @@ function sanitizeVoicePreviews(v: unknown): VoicePreview[] | null {
return clean.length > 0 ? clean.slice(0, 6) : null; return clean.length > 0 ? clean.slice(0, 6) : null;
} }
/**
* Generate every preview line's audio ONCE (background, best-effort) so
* serving users always hits the disk cache — no synthesis latency on
* playback, no repeated provider cost. Runs at boot and after each
* preview save; failures are swallowed (first playback synthesizes).
*/
async function pregenerateVoicePreviews(): Promise<void> {
try {
const tts = tools.tts;
if (!await tts.isAvailable()) return;
for (const line of await getVoicePreviews()) {
try {
await tts.synthesize(line.text, line.voice || undefined);
} catch {
// one line must not block the others
}
}
} catch {
// pregen must never break boot or saves
}
}
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string { function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
try { try {
const info = server.requestIP?.(req); const info = server.requestIP?.(req);
@@ -1104,6 +1126,8 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!clean) return json({ error: "voicePreviews must be 1-6 lines {text, lang?, rate?, pitch?, timbre?, voice?}, or [] to reset" }, 400); if (!clean) return json({ error: "voicePreviews must be 1-6 lines {text, lang?, rate?, pitch?, timbre?, voice?}, or [] to reset" }, 400);
await setSetting("voice_previews", JSON.stringify(clean)); await setSetting("voice_previews", JSON.stringify(clean));
} }
// New/changed lines get their audio generated once, right away.
void pregenerateVoicePreviews();
} }
if (body.ttsEnabled !== undefined) { if (body.ttsEnabled !== undefined) {
if (typeof body.ttsEnabled !== "boolean") return json({ error: "ttsEnabled must be boolean" }, 400); if (typeof body.ttsEnabled !== "boolean") return json({ error: "ttsEnabled must be boolean" }, 400);
@@ -1846,10 +1870,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
} }
recordTtsJob(user.id); recordTtsJob(user.id);
try { try {
const { audio, contentType } = await tts.synthesize(text, typeof body.voice === "string" ? body.voice : undefined); const { audio, contentType, fromCache } = await tts.synthesize(text, typeof body.voice === "string" ? body.voice : undefined);
const bytes = audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength) as ArrayBuffer; const bytes = audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength) as ArrayBuffer;
return new Response(bytes, { return new Response(bytes, {
headers: { "Content-Type": contentType, "Cache-Control": "private, max-age=86400" }, headers: { "Content-Type": contentType, "Cache-Control": "private, max-age=86400", "X-TTS-Cache": fromCache ? "HIT" : "MISS" },
}); });
} catch (e) { } catch (e) {
const msg = (e as Error).message; const msg = (e as Error).message;
@@ -2010,3 +2034,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
console.log(`KisAssistant listening on http://${config.host}:${config.port}`); console.log(`KisAssistant listening on http://${config.host}:${config.port}`);
console.log(`Drivers (priority order): ${registry.ordered().map((d) => `${d.name}${d.enabled ? "" : " [disabled]"}`).join(" -> ")}`); console.log(`Drivers (priority order): ${registry.ordered().map((d) => `${d.name}${d.enabled ? "" : " [disabled]"}`).join(" -> ")}`);
void server; void server;
// Preview voice lines get their audio generated once at boot so playback
// always serves the disk cache (background, best-effort).
void pregenerateVoicePreviews();
+35 -5
View File
@@ -65,6 +65,11 @@ function piperBin(): string {
return process.env["PIPER_BIN"] || "piper"; return process.env["PIPER_BIN"] || "piper";
} }
export interface TtsVoice {
id: string;
name: string;
}
async function piperAvailable(): Promise<boolean> { async function piperAvailable(): Promise<boolean> {
if (!config.piperModel) return false; if (!config.piperModel) return false;
try { try {
@@ -140,8 +145,33 @@ function pruneCache(): void {
} }
} }
async function synthElevenlabs(text: string, voiceId: string): Promise<{ audio: Uint8Array; contentType: string }> { // ElevenLabs voice catalog (cached 5 min, best-effort — drives the admin
const res = await fetch(`${elevenlabsBase()}/v1/text-to-speech/${encodeURIComponent(voiceId)}`, { // voice select menus; never blocks synthesis when unreachable).
let elVoicesCache: { at: number; voices: TtsVoice[] } | null = null;
export async function elevenlabsVoices(): Promise<TtsVoice[]> {
const now = Date.now();
if (elVoicesCache && now - elVoicesCache.at < 300_000) return elVoicesCache.voices;
const fallback = config.elevenlabsVoice ? [{ id: config.elevenlabsVoice, name: config.elevenlabsVoice }] : [];
try {
const res = await fetch(`${elevenlabsBase()}/v1/voices`, {
headers: { "xi-api-key": config.elevenlabsApiKey },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`status ${res.status}`);
const data = (await res.json()) as { voices?: { voice_id?: unknown; name?: unknown }[] };
const voices = (data.voices ?? [])
.filter((v) => typeof v.voice_id === "string" && typeof v.name === "string")
.map((v) => ({ id: v.voice_id as string, name: v.name as string }));
if (voices.length === 0) return fallback;
elVoicesCache = { at: now, voices };
return [...voices, ...fallback.filter((f) => !voices.some((v) => v.id === f.id))];
} catch {
return fallback;
}
}
async function synthElevenlabs(text: string, voiceId: string): Promise<{ audio: Uint8Array; contentType: string }> { const res = await fetch(`${elevenlabsBase()}/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -228,11 +258,11 @@ export class TtsTool implements PlatformTool {
return null; return null;
} }
async providerInfo(): Promise<{ provider: TtsProvider | null; voices: string[] }> { async providerInfo(): Promise<{ provider: TtsProvider | null; voices: TtsVoice[] }> {
const p = await activeTtsProvider(); const p = await activeTtsProvider();
if (!p) return { provider: null, voices: [] }; if (!p) return { provider: null, voices: [] };
if (p === "openai") return { provider: p, voices: [...OPENAI_VOICES] }; if (p === "openai") return { provider: p, voices: OPENAI_VOICES.map((v) => ({ id: v, name: v })) };
if (p === "elevenlabs") return { provider: p, voices: config.elevenlabsVoice ? [config.elevenlabsVoice] : [] }; if (p === "elevenlabs") return { provider: p, voices: await elevenlabsVoices() };
return { provider: p, voices: [] }; return { provider: p, voices: [] };
} }