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
+24
View File
@@ -74,6 +74,7 @@ export interface VoicePreview {
rate: number;
pitch: number;
timbre: "masculine" | "feminine" | "any";
voice: string;
}
export interface PlatformInfo {
@@ -102,6 +103,7 @@ export interface AdminSettings {
recoveryLimitMax: number;
recoveryLimitWindowMin: number;
voicePreviews: VoicePreview[];
ttsEnabled: boolean;
}
export interface AdminSettingsPatch {
@@ -115,6 +117,7 @@ export interface AdminSettingsPatch {
recoveryLimitMax?: number;
recoveryLimitWindowMin?: number;
voicePreviews?: VoicePreview[];
ttsEnabled?: boolean;
}
export interface OllamaNode {
@@ -442,6 +445,27 @@ export const api = {
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). */
ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: string[]; available: boolean; reason: string | null }>("/api/tools/tts/info"),
/** Synthesize text server-side (neural voice). Returns audio bytes. */
ttsSpeak: async (text: string, voice?: string): Promise<Blob> => {
const res = await fetch("/api/tools/tts", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ text, ...(voice ? { voice } : {}) }),
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, data.error || `TTS failed (${res.status})`);
}
return await res.blob();
},
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
const res = await fetch("/api/tools/ocr", {
+96
View File
@@ -605,6 +605,22 @@ const en = {
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"vfeat.voice": "Server voice",
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
"vfeat.voicePh": "auto",
"center.ttsTitle": "Neural voice playback",
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
"voice.engine": "Playback engine",
"voice.engineAuto": "Auto (neural if available)",
"voice.engineBrowser": "Browser speech",
"voice.engineServer": "Neural server voice",
"voice.engineHintOn": "Neural TTS via {provider}",
"voice.engineHintOff": "No neural provider configured — browser speech only",
"voice.serverVoice": "Neural voice",
"voice.serverVoiceHint": "ElevenLabs voice id",
"voice.piperNote": "Local piper voice (single configured model).",
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
"msg.copy": "Copy message",
@@ -1297,6 +1313,22 @@ const es: Dict = {
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"vfeat.voice": "Server voice",
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
"vfeat.voicePh": "auto",
"center.ttsTitle": "Neural voice playback",
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
"voice.engine": "Playback engine",
"voice.engineAuto": "Auto (neural if available)",
"voice.engineBrowser": "Browser speech",
"voice.engineServer": "Neural server voice",
"voice.engineHintOn": "Neural TTS via {provider}",
"voice.engineHintOff": "No neural provider configured — browser speech only",
"voice.serverVoice": "Neural voice",
"voice.serverVoiceHint": "ElevenLabs voice id",
"voice.piperNote": "Local piper voice (single configured model).",
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
"msg.copy": "Copiar mensaje",
@@ -1984,6 +2016,22 @@ const fr: Dict = {
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"vfeat.voice": "Server voice",
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
"vfeat.voicePh": "auto",
"center.ttsTitle": "Neural voice playback",
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
"voice.engine": "Playback engine",
"voice.engineAuto": "Auto (neural if available)",
"voice.engineBrowser": "Browser speech",
"voice.engineServer": "Neural server voice",
"voice.engineHintOn": "Neural TTS via {provider}",
"voice.engineHintOff": "No neural provider configured — browser speech only",
"voice.serverVoice": "Neural voice",
"voice.serverVoiceHint": "ElevenLabs voice id",
"voice.piperNote": "Local piper voice (single configured model).",
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
"msg.copy": "Copier le message",
@@ -2659,6 +2707,22 @@ const ru: Dict = {
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"vfeat.voice": "Server voice",
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
"vfeat.voicePh": "auto",
"center.ttsTitle": "Neural voice playback",
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
"voice.engine": "Playback engine",
"voice.engineAuto": "Auto (neural if available)",
"voice.engineBrowser": "Browser speech",
"voice.engineServer": "Neural server voice",
"voice.engineHintOn": "Neural TTS via {provider}",
"voice.engineHintOff": "No neural provider configured — browser speech only",
"voice.serverVoice": "Neural voice",
"voice.serverVoiceHint": "ElevenLabs voice id",
"voice.piperNote": "Local piper voice (single configured model).",
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
"msg.copy": "Скопировать сообщение",
@@ -3339,6 +3403,22 @@ const it: Dict = {
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"vfeat.voice": "Server voice",
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
"vfeat.voicePh": "auto",
"center.ttsTitle": "Neural voice playback",
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
"voice.engine": "Playback engine",
"voice.engineAuto": "Auto (neural if available)",
"voice.engineBrowser": "Browser speech",
"voice.engineServer": "Neural server voice",
"voice.engineHintOn": "Neural TTS via {provider}",
"voice.engineHintOff": "No neural provider configured — browser speech only",
"voice.serverVoice": "Neural voice",
"voice.serverVoiceHint": "ElevenLabs voice id",
"voice.piperNote": "Local piper voice (single configured model).",
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
"msg.copy": "Copia messaggio",
@@ -4010,6 +4090,22 @@ const pt: Dict = {
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"vfeat.voice": "Server voice",
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
"vfeat.voicePh": "auto",
"center.ttsTitle": "Neural voice playback",
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
"voice.engine": "Playback engine",
"voice.engineAuto": "Auto (neural if available)",
"voice.engineBrowser": "Browser speech",
"voice.engineServer": "Neural server voice",
"voice.engineHintOn": "Neural TTS via {provider}",
"voice.engineHintOff": "No neural provider configured — browser speech only",
"voice.serverVoice": "Neural voice",
"voice.serverVoiceHint": "ElevenLabs voice id",
"voice.piperNote": "Local piper voice (single configured model).",
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
"msg.copy": "Copiar mensagem",
+159 -39
View File
@@ -15,21 +15,28 @@ export interface VoicePrefs {
pitch: number;
/** "auto" detects per message, otherwise forces a BCP-47 prefix. */
lang: string;
/** Playback engine: neural server TTS when available, else browser. */
engine: "auto" | "browser" | "server";
/** Server neural voice id (ElevenLabs voice_id / OpenAI voice name). */
serverVoice: string | null;
}
const PREFS_KEY = "ka:voice";
export const DEFAULT_VOICE_PREFS: VoicePrefs = { voiceURI: null, rate: 1, pitch: 1, lang: "auto" };
export const DEFAULT_VOICE_PREFS: VoicePrefs = { voiceURI: null, rate: 1, pitch: 1, lang: "auto", engine: "auto", serverVoice: null };
export function getVoicePrefs(): VoicePrefs {
try {
const raw = JSON.parse(localStorage.getItem(PREFS_KEY) ?? "null") as Partial<VoicePrefs> | null;
if (!raw || typeof raw !== "object") return { ...DEFAULT_VOICE_PREFS };
const engine = raw.engine === "browser" || raw.engine === "server" ? raw.engine : "auto";
return {
voiceURI: typeof raw.voiceURI === "string" ? raw.voiceURI : null,
rate: typeof raw.rate === "number" && raw.rate >= 0.5 && raw.rate <= 2 ? raw.rate : 1,
pitch: typeof raw.pitch === "number" && raw.pitch >= 0.5 && raw.pitch <= 2 ? raw.pitch : 1,
lang: typeof raw.lang === "string" && raw.lang.length <= 12 ? raw.lang : "auto",
engine,
serverVoice: typeof raw.serverVoice === "string" && raw.serverVoice.length > 0 ? raw.serverVoice : null,
};
} catch {
return { ...DEFAULT_VOICE_PREFS };
@@ -83,13 +90,6 @@ export interface SpeakOpts {
export type VoiceTimbre = "masculine" | "feminine" | "any";
export interface PreviewVoice {
lang: string;
rate: number;
pitch: number;
timbre: VoiceTimbre;
}
// Name fragments hinting a masculine / feminine voice (Chrome, Edge,
// Safari and Android voices carry such markers; absence falls back to
// any same-language voice — never an error).
@@ -158,43 +158,163 @@ export function stopSpeak(): void {
}
}
/**
* Play a preview line with ITS OWN voice character (timbre + rate + pitch),
* never the user's global playback prefs — previews must sound different
* from each other. Returns the resolved voice (null when unsupported) so
* callers can offer "use this voice" adoption.
*/
export function speakPreview(
text: string,
preview: PreviewVoice,
voices: SpeechSynthesisVoice[],
opts: SpeakOpts = {},
): SpeechSynthesisVoice | null {
if (!ttsSupported()) return null;
const synth = window.speechSynthesis;
// --- unified playback (neural server audio with browser fallback) ---
export interface PlayHandle {
/** Engine that ended up playing (may differ from the request in auto). */
engine: "server" | "browser";
stop: () => void;
done: Promise<void>;
}
let currentAudio: HTMLAudioElement | null = null;
function stopCurrentAudio(): void {
if (currentAudio) {
try {
currentAudio.pause();
const url = currentAudio.src;
currentAudio.removeAttribute("src");
currentAudio.load();
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
} catch {
// ignore
}
currentAudio = null;
}
try {
synth.cancel();
window.speechSynthesis?.cancel();
} catch {
// ignore
}
const lang = preview.lang === "auto" ? detectLang(text) : preview.lang;
const utter = new SpeechSynthesisUtterance(text.slice(0, 2000));
utter.lang = lang;
utter.rate = preview.rate;
utter.pitch = preview.pitch;
const pick = pickVoice(voices, { lang, timbre: preview.timbre });
if (pick) utter.voice = pick;
utter.onend = () => opts.onend?.();
utter.onerror = () => opts.onerror?.("error");
try {
synth.speak(utter);
} catch {
return null;
}
return pick;
}
// --- speech recognition (STT) ---
/** Stop whatever is playing (server audio or browser speech). */
export function stopPlayback(): void {
stopCurrentAudio();
}
export interface PlayRequest {
text: string;
/** "server" forces neural, "browser" forces synthesis, "auto" tries neural first. */
engine: "auto" | "browser" | "server";
browser: {
voices: SpeechSynthesisVoice[];
/** "auto" detects per text, otherwise a BCP-47 prefix. */
lang: string;
rate: number;
pitch: number;
/** Explicit voice URI (user pref); otherwise timbre-matched. */
voiceURI?: string | null;
/** Preview character timbre (ignored when voiceURI is set). */
timbre?: VoiceTimbre;
};
/** Server neural voice id (preview line voice or user serverVoice). */
serverVoice?: string | null;
/** True when the platform TTS tool is available. */
serverAvailable: boolean;
fetchAudio: (text: string, voice?: string) => Promise<Blob>;
onstart?: () => void;
onend?: () => void;
onerror?: (msg: string) => void;
}
/**
* Play one text with the best engine: neural server audio when requested
* and available (shared <audio> element, user speed), otherwise the
* browser character voice. Single global playback — a new play stops the
* previous one. `done` resolves when playback fully ends (fallback
* included); it never rejects (errors go to onend/onerror like speech).
*/
export function playText(req: PlayRequest): PlayHandle {
stopPlayback();
const wantServer = req.serverAvailable && req.engine !== "browser";
if (wantServer) return playServer(req, req.engine === "auto");
return playBrowser(req);
}
function playServer(req: PlayRequest, allowFallback: boolean): PlayHandle {
let settled = false;
const stop = () => {
settled = true;
stopCurrentAudio();
};
const done = (async () => {
try {
const blob = await req.fetchAudio(req.text.slice(0, 2000), req.serverVoice ?? undefined);
if (settled) return;
await playAudioBlob(blob, req, () => settled);
} catch (e) {
if (settled) return;
if (allowFallback) {
await playBrowserAudio(req, () => settled);
return;
}
req.onerror?.(e instanceof Error ? e.message : "playback failed");
}
})();
return { engine: "server", stop, done };
}
function playAudioBlob(blob: Blob, req: PlayRequest, cancelled: () => boolean): Promise<void> {
return new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.playbackRate = req.browser.rate;
currentAudio = audio;
const finish = (ok: boolean) => {
if (currentAudio === audio) stopCurrentAudio();
else {
try {
URL.revokeObjectURL(url);
} catch {
// ignore
}
}
if (!cancelled() && ok) req.onend?.();
resolve();
};
audio.onended = () => finish(true);
audio.onerror = () => finish(false);
req.onstart?.();
audio.play().catch(() => finish(false));
});
}
function playBrowser(req: PlayRequest): PlayHandle {
let settled = false;
const stop = () => {
settled = true;
stopSpeak();
};
const done = playBrowserAudio(req, () => settled);
return { engine: "browser", stop, done };
}
function playBrowserAudio(req: PlayRequest, cancelled: () => boolean): Promise<void> {
return new Promise((resolve) => {
const b = req.browser;
const lang = b.lang === "auto" ? detectLang(req.text) : b.lang;
const explicit = b.voiceURI ? b.voices.find((v) => v.voiceURI === b.voiceURI) : undefined;
const pick = explicit ?? pickVoice(b.voices, { lang, timbre: b.timbre ?? "any" });
const finish = (ok: boolean, msg?: string) => {
if (cancelled()) {
resolve();
return;
}
if (ok) req.onend?.();
else req.onerror?.(msg ?? "error");
resolve();
};
const ok = speak(
req.text,
{ voiceURI: pick?.voiceURI ?? null, rate: b.rate, pitch: b.pitch, lang, engine: "browser", serverVoice: null },
b.voices,
{ onstart: () => req.onstart?.(), onend: () => finish(true), onerror: (m) => finish(false, m) },
);
if (!ok) finish(false, "unsupported");
});
}
export interface Recognizer {
lang: string;