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

View File

@@ -1,3 +1,4 @@
import { speakWithPiper, isPiperSpeaking } from "./piper_speech.js";
export function createDefaultAmmcConfigHelper() {
return {
managedEnabled: false,
@@ -132,6 +133,7 @@ export function applyPersistedStateHelper(persisted, deps) {
speechVoiceUri: String(persisted.settings?.speechVoiceUri || state.settings.speechVoiceUri || ""),
speechRate: Math.max(0.65, Math.min(1.35, Number(persisted.settings?.speechRate || state.settings.speechRate || 0.95) || 0.95)),
speechPitch: Math.max(0.7, Math.min(1.3, Number(persisted.settings?.speechPitch || state.settings.speechPitch || 1) || 1)),
ttsEngine: persisted.settings?.ttsEngine === "piper" ? "piper" : "browser",
speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true,
speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false,
speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false,
@@ -364,8 +366,21 @@ export function getSessionAnnouncementText(session, phase, { t }) {
}
export function speakTextHelper(text, { state, currentLanguage }, options = {}) {
if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) return false;
if (!state.settings.audioEnabled || !text) return false;
if (state.settings.ttsEngine === "piper" && !options.skipPiper) {
return speakWithPiper(text, {
language: String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en",
rate: Math.max(0.65, Math.min(1.35, Number(options.rate ?? state.settings.speechRate ?? 0.95) || 0.95)),
volume: options.volume ?? 1,
interrupt: options.interrupt,
onEnd: options.onEnd,
onError: options.onError,
fallback: () => speakTextHelper(text, { state, currentLanguage }, { ...options, skipPiper: true, interrupt: true }),
});
}
if (!("speechSynthesis" in window)) return false;
const synth = window.speechSynthesis;
if (!options.interrupt && isPiperSpeaking()) return false;
if (!options.interrupt && (synth.speaking || synth.pending)) return false;
if (options.interrupt) synth.cancel();
const utterance = new SpeechSynthesisUtterance(text);