natural voice
This commit is contained in:
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user