feat(audio): add offline local neural voice via Piper (sv + en)
Announcements can use Piper, a free local neural TTS, instead of the browser's system voice. No account, no paid service, and it keeps working with no internet at the track. - npm run tts:install (or Settings -> Audio -> Install) downloads the standalone Piper release for Linux/Windows/macOS plus Swedish and English voices into piper/ once - server/tts.js generates each line once, one at a time, and caches it as WAV under data/tts/; a new line takes ~0.3 s with the standalone build - src/piper_speech.js plays it and falls back to the browser voice if Piper is missing, errors, or takes over 2.5 s, so no announcement is lost and timing is never held up - "Pre-generate phrases and driver names" renders every fixed phrase and name in both languages before race day Browser voice stays the default. Generation routes fall under the admin PIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
60
src/piper_speech.js
Normal file
60
src/piper_speech.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// 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, 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, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user