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:
larssand
2026-09-27 19:28:15 +02:00
parent 9016ce02e4
commit e8a2799a92
25 changed files with 776 additions and 19 deletions

60
src/piper_speech.js Normal file
View 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;
}