natural voice

This commit is contained in:
k
2026-09-24 11:31:43 +02:00
parent 562ab5191f
commit 8d72defb82
16 changed files with 778 additions and 73 deletions
+50 -7
View File
@@ -143,6 +143,7 @@ import {
writeCdnFile,
} from "./cdn.ts";
import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
import { TTS_MAX_CHARS, recordTtsJob, ttsAllowed } from "./tools/tts.ts";
import { tools } from "./tools/registry.ts";
import { initOllamaNodes } from "./ollamaNodes.ts";
@@ -236,13 +237,15 @@ export interface VoicePreview {
rate: number;
pitch: number;
timbre: "masculine" | "feminine" | "any";
/** Server neural voice id (ElevenLabs voice_id / OpenAI voice name). Empty = provider default. */
voice: string;
}
const DEFAULT_VOICE_PREVIEWS: VoicePreview[] = [
{ text: "Salut, euhm, comment je peux t'aider aujourd'hui ?", lang: "fr", rate: 0.85, pitch: 0.75, timbre: "masculine" },
{ text: "5 sur 5 je recois ! Que veux-tu ?", lang: "fr", rate: 1, pitch: 0.8, timbre: "masculine" },
{ text: "Owww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi ?", lang: "fr", rate: 1.05, pitch: 1.35, timbre: "feminine" },
{ text: "Hey, que puis-je faire pour toi ?", lang: "fr", rate: 1, pitch: 1.1, timbre: "feminine" },
{ text: "Salut, euhm, comment je peux t'aider aujourd'hui ?", lang: "fr", rate: 0.85, pitch: 0.75, timbre: "masculine", voice: "" },
{ text: "5 sur 5 je recois ! Que veux-tu ?", lang: "fr", rate: 1, pitch: 0.8, timbre: "masculine", voice: "" },
{ text: "Owww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi ?", lang: "fr", rate: 1.05, pitch: 1.35, timbre: "feminine", voice: "" },
{ text: "Hey, que puis-je faire pour toi ?", lang: "fr", rate: 1, pitch: 1.1, timbre: "feminine", voice: "" },
];
function sanitizePreviewLine(v: unknown): VoicePreview | null {
@@ -250,7 +253,7 @@ function sanitizePreviewLine(v: unknown): VoicePreview | null {
if (typeof v === "string") {
const text = v.trim();
if (text.length === 0 || text.length > 500) return null;
return { text, lang: "auto", rate: 1, pitch: 1, timbre: "any" };
return { text, lang: "auto", rate: 1, pitch: 1, timbre: "any", voice: "" };
}
if (!v || typeof v !== "object") return null;
const o = v as Record<string, unknown>;
@@ -261,7 +264,8 @@ function sanitizePreviewLine(v: unknown): VoicePreview | null {
const rate = typeof o.rate === "number" && o.rate >= 0.5 && o.rate <= 2 ? o.rate : 1;
const pitch = typeof o.pitch === "number" && o.pitch >= 0.5 && o.pitch <= 2 ? o.pitch : 1;
const timbre = o.timbre === "masculine" || o.timbre === "feminine" ? o.timbre : "any";
return { text, lang, rate, pitch, timbre };
const voice = typeof o.voice === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(o.voice.trim()) ? o.voice.trim() : "";
return { text, lang, rate, pitch, timbre, voice };
}
async function getVoicePreviews(): Promise<VoicePreview[]> {
@@ -1031,6 +1035,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recoveryLimitMax: await recoveryLimitMax(),
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
voicePreviews: await getVoicePreviews(),
ttsEnabled: (await getSetting("tools_tts_enabled", "1")) === "1",
},
});
}
@@ -1096,10 +1101,14 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
await setSetting("voice_previews", "");
} else {
const clean = sanitizeVoicePreviews(body.voicePreviews);
if (!clean) return json({ error: "voicePreviews must be 1-6 lines {text, lang?, rate?, pitch?, timbre?}, 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));
}
}
if (body.ttsEnabled !== undefined) {
if (typeof body.ttsEnabled !== "boolean") return json({ error: "ttsEnabled must be boolean" }, 400);
await setSetting("tools_tts_enabled", body.ttsEnabled ? "1" : "0");
}
return json({
settings: {
registrationEnabled: await isRegistrationEnabled(),
@@ -1112,6 +1121,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recoveryLimitMax: await recoveryLimitMax(),
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
voicePreviews: await getVoicePreviews(),
ttsEnabled: (await getSetting("tools_tts_enabled", "1")) === "1",
},
});
}
@@ -1815,6 +1825,39 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
}
// --- platform tools: neural TTS (natural voices, cached) ---
if (path === "/api/tools/tts/info" && req.method === "GET") {
const tts = tools.tts;
return json({ ...(await tts.providerInfo()), available: await tts.isAvailable(), reason: await tts.unavailableReason() });
}
if (path === "/api/tools/tts" && req.method === "POST") {
const tts = tools.tts;
if (!await tts.isAvailable()) return json({ error: (await tts.unavailableReason()) ?? "tts unavailable" }, 501);
const { ok, body } = await readJson(req);
if (!ok || typeof body.text !== "string") return json({ error: "expected { text, voice? }" }, 400);
const text = body.text.trim();
if (text.length === 0 || text.length > TTS_MAX_CHARS) {
return json({ error: `text: 1-${TTS_MAX_CHARS} chars` }, 400);
}
const rl = ttsAllowed(user.id);
if (!rl.ok) {
return json({ error: "tts rate limited: 30 jobs per hour", retryAfterSec: rl.retryAfterSec }, 429);
}
recordTtsJob(user.id);
try {
const { audio, contentType } = await tts.synthesize(text, typeof body.voice === "string" ? body.voice : undefined);
const bytes = audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength) as ArrayBuffer;
return new Response(bytes, {
headers: { "Content-Type": contentType, "Cache-Control": "private, max-age=86400" },
});
} catch (e) {
const msg = (e as Error).message;
if (/not available|disabled|failed to start/i.test(msg)) return json({ error: msg }, 501);
return json({ error: msg }, 502);
}
}
if (path === "/api/chat" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);