Files
Live_RC/src/piper_speech.js
larssand 196437d25b feat(audio): choose a male or female local voice per language
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>
2026-09-27 20:29:59 +02:00

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;
}