The installer now fetches one male and one female Piper voice per language (Swedish NST/Lisa, English Ryan/Lessac), with genders confirmed by measuring each voice's pitch rather than guessed from the name. Settings -> Audio gets a voice selector per language with its own Test button that always plays the Piper voice. Male is preselected for both. The integration test now runs its server with an empty home directory, so voices installed on the developer's own machine can't leak into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61 lines
2.0 KiB
JavaScript
61 lines
2.0 KiB
JavaScript
// Plays announcements generated by the server's local Piper voice. Any failure - Piper not
|
|
// installed, generation too slow, network hiccup - falls back to the browser's own voice,
|
|
// so an announcement is never lost and never holds up timing.
|
|
const PIPER_TIMEOUT_MS = 2500;
|
|
let current = null;
|
|
|
|
export function stopPiperSpeech() {
|
|
if (!current) return;
|
|
current.cancelled = true;
|
|
current.controller.abort();
|
|
if (current.audio) current.audio.pause();
|
|
if (current.url) URL.revokeObjectURL(current.url);
|
|
current = null;
|
|
}
|
|
|
|
export function isPiperSpeaking() {
|
|
return Boolean(current);
|
|
}
|
|
|
|
// Returns true when the line was accepted (it will play via Piper or the fallback).
|
|
export function speakWithPiper(text, { language, voice = "", rate, volume = 1, interrupt, onEnd, onError, fallback }) {
|
|
if (current && !interrupt) return false;
|
|
stopPiperSpeech();
|
|
const job = { cancelled: false, controller: new AbortController(), audio: null, url: "" };
|
|
current = job;
|
|
const finish = (callback) => {
|
|
if (job.url) URL.revokeObjectURL(job.url);
|
|
if (current === job) current = null;
|
|
callback?.();
|
|
};
|
|
const timer = setTimeout(() => job.controller.abort(), PIPER_TIMEOUT_MS);
|
|
fetch("/api/tts/speech", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ text, language, voice, rate }),
|
|
signal: job.controller.signal,
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error(`tts ${response.status}`);
|
|
return response.blob();
|
|
})
|
|
.then((blob) => {
|
|
clearTimeout(timer);
|
|
if (job.cancelled) return;
|
|
job.url = URL.createObjectURL(blob);
|
|
job.audio = new Audio(job.url);
|
|
job.audio.volume = Math.max(0, Math.min(1, Number(volume)));
|
|
job.audio.onended = () => finish(onEnd);
|
|
job.audio.onerror = () => finish(onError);
|
|
return job.audio.play();
|
|
})
|
|
.catch(() => {
|
|
clearTimeout(timer);
|
|
if (job.cancelled) return;
|
|
finish(() => {
|
|
if (!fallback?.()) onError?.();
|
|
});
|
|
});
|
|
return true;
|
|
}
|