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

@@ -1064,6 +1064,19 @@ const TRANSLATIONS = {
"guide.tab_run": "Köra ett race",
"guide.tab_training": "Träning & resultat",
"guide.tab_videos": "Videoguider",
"tts.title": "Röstmotor",
"tts.hint": "Lokal röst (Piper) låter mer naturligt än webbläsarens röst och fungerar helt utan internet. Installera en gång (kräver internet just då), sedan körs allt lokalt. Om Piper saknas eller dröjer används webbläsarens röst automatiskt.",
"tts.engine": "Röstmotor",
"tts.engine_browser": "Webbläsarens röst",
"tts.engine_piper": "Lokal röst (Piper, offline)",
"tts.status_ready": "Piper är installerat.",
"tts.status_missing": "Piper eller röster saknas - klicka Installera.",
"tts.cached": "{count} repliker sparade",
"tts.install": "Installera Piper + svenska/engelska röster",
"tts.reinstall": "Kontrollera installation",
"tts.pregenerate": "Förgenerera fraser och förarnamn",
"tts.pregenerate_progress": "Förgenererat {done} av {total} ({failed} misslyckades)",
"tts.test": "Testa rösten",
"auth.title": "Admin-PIN",
"auth.hint": "Skyddar alla ändringar (race, resultat, inställningar, uppdatering) med en PIN. Läsning och publika overlay-/träningslänkar påverkas inte. Inloggningen sparas i webbläsaren i 30 dagar.",
"auth.state_off": "Av - vem som helst på nätverket kan ändra.",
@@ -1507,7 +1520,7 @@ const TRANSLATIONS = {
"audio.max_delay": "Största fördröjning (sek)",
"audio.per_competitor": "Olika passing-ton per förare/bil",
"audio.test": "Testa",
"audio.note": "Fasta effekter spelas från ljudpaketets WAV-filer. Röstutrop använder webbläsarens installerade systemröster. Val sparas automatiskt; kritiska utrop avbryter lägre prioriterat tal.",
"audio.note": "Fasta effekter spelas från ljudpaketets WAV-filer. Röstutrop använder vald röstmotor ovan - lokal Piper-röst eller webbläsarens systemröster. Val sparas automatiskt; kritiska utrop avbryter lägre prioriterat tal.",
"audio.effect": "Effekt",
"audio.voice": "Röst",
"audio.track_call": "STOPP PÅ BANAN",
@@ -1746,6 +1759,7 @@ const TRANSLATIONS = {
"guide.audio_7": "7. Personbästa, snabbaste varv, banrekord, ledarbyte, tidsvarningar, depå in/ut, finish och podium hämtas från befintliga timinghändelser.",
"guide.audio_8": "8. Ljudet spelas i operatörens webbläsare. Klicka Testa före racet, kontrollera ljudutgången och håll Tidtagning-fliken öppen.",
"guide.audio_9": "9. Staggered start i ett kvalheat läser upp varje bils transpondernummer i startordning, cirka 3 sekunder mellan varje bil, direkt efter den vanliga starten - mass/position-start läser aldrig upp enskilda bilar.",
"guide.audio_10": "10. Bättre röst utan internet: under Inställningar -> Ljud -> Röstmotor, klicka Installera en gång (hämtar Piper och svenska + engelska röster, kräver internet just då) och välj Lokal röst. Klicka sedan Förgenerera fraser och förarnamn före racedagen. Allt körs sedan lokalt; saknas Piper eller dröjer den används webbläsarens röst automatiskt.",
"guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar",
"guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.",
"guide.sponsor_2": "2. Lägg upp 10 förare i sidan Förare. Du kan också spara team/märke i brandfältet och filtrera listan på det senare.",
@@ -2645,6 +2659,19 @@ const TRANSLATIONS = {
"guide.tab_run": "Run a race",
"guide.tab_training": "Training & results",
"guide.tab_videos": "Video guides",
"tts.title": "Voice engine",
"tts.hint": "The local voice (Piper) sounds more natural than the browser voice and works with no internet at all. Install once (needs internet just then), after that everything runs locally. If Piper is missing or slow, the browser voice is used automatically.",
"tts.engine": "Voice engine",
"tts.engine_browser": "Browser voice",
"tts.engine_piper": "Local voice (Piper, offline)",
"tts.status_ready": "Piper is installed.",
"tts.status_missing": "Piper or voices missing - click Install.",
"tts.cached": "{count} lines cached",
"tts.install": "Install Piper + Swedish/English voices",
"tts.reinstall": "Check installation",
"tts.pregenerate": "Pre-generate phrases and driver names",
"tts.pregenerate_progress": "Pre-generated {done} of {total} ({failed} failed)",
"tts.test": "Test the voice",
"auth.title": "Admin PIN",
"auth.hint": "Protects every change (races, results, settings, updates) with a PIN. Reading and public overlay/training links are unaffected. The login is remembered in this browser for 30 days.",
"auth.state_off": "Off - anyone on the network can make changes.",
@@ -3088,7 +3115,7 @@ const TRANSLATIONS = {
"audio.max_delay": "Maximum delay (seconds)",
"audio.per_competitor": "Different passing tone per driver/car",
"audio.test": "Test",
"audio.note": "Fixed effects use WAV files from the sound pack. Voice announcements use the browser installed system voices. Changes save automatically; critical announcements interrupt lower-priority speech.",
"audio.note": "Fixed effects use WAV files from the sound pack. Voice announcements use the voice engine chosen above - the local Piper voice or the browser's system voices. Changes save automatically; critical announcements interrupt lower-priority speech.",
"audio.effect": "Effect",
"audio.voice": "Voice",
"audio.track_call": "TRACK CALL",
@@ -3327,6 +3354,7 @@ const TRANSLATIONS = {
"guide.audio_7": "7. Personal best, fastest lap, track record, leader changes, time warnings, pit entry/exit, finish, and podium reuse existing timing events.",
"guide.audio_8": "8. Audio plays in the operator browser. Use Test before racing, verify the audio output, and keep the Timing tab open.",
"guide.audio_9": "9. A staggered start in a qualifying heat calls out each car's transponder number in grid order, about 3 seconds apart, right after the official start - a mass/position start never calls out individual cars.",
"guide.audio_10": "10. Better voice without internet: under Settings -> Audio -> Voice engine, click Install once (downloads Piper plus Swedish and English voices, needs internet just then) and choose Local voice. Then click Pre-generate phrases and driver names before race day. Everything then runs locally; if Piper is missing or slow the browser voice is used automatically.",
"guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars",
"guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.",
"guide.sponsor_2": "2. Add 10 drivers in Drivers. You can also store team/brand in the brand field and filter by it later.",
@@ -4080,6 +4108,7 @@ function loadState() {
speechVoiceUri: String(parsed.settings?.speechVoiceUri || ""),
speechRate: Math.max(0.65, Math.min(1.35, Number(parsed.settings?.speechRate || 0.95) || 0.95)),
speechPitch: Math.max(0.7, Math.min(1.3, Number(parsed.settings?.speechPitch || 1) || 1)),
ttsEngine: parsed.settings?.ttsEngine === "piper" ? "piper" : "browser",
speakerPassingCueEnabled: parsed.settings?.speakerPassingCueEnabled === true,
speakerLeaderCueEnabled: parsed.settings?.speakerLeaderCueEnabled !== false,
speakerFinishCueEnabled: parsed.settings?.speakerFinishCueEnabled !== false,
@@ -4148,6 +4177,7 @@ function loadState() {
speechVoiceUri: "",
speechRate: 0.95,
speechPitch: 1,
ttsEngine: "browser",
speakerPassingCueEnabled: false,
speakerLeaderCueEnabled: true,
speakerFinishCueEnabled: true,

View File

@@ -117,7 +117,7 @@ export function renderGuideView({ dom, t }) {
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8", "guide.audio_9"], "", { t })}
${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8", "guide.audio_9", "guide.audio_10"], "", { t })}
</div>
</div>

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

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

View File

@@ -1,5 +1,6 @@
import { decoderDiagnosticsMarkup, mountDecoderDiagnostics } from "./decoder_diagnostics_view.js";
import { mountAuthSettings } from "./auth_view.js";
import { mountTtsSettings } from "./tts_settings_view.js";
let activeSettingsTab = "connection";
@@ -233,6 +234,10 @@ export function renderSettings(deps) {
</div>
<div data-settings-panel="audio">
<section class="panel mt-16">
<div class="panel-header"><h3>${t("tts.title")}</h3></div>
<div class="panel-body" id="ttsSettings"></div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("audio.race_sounds")}</h3></div>
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
@@ -641,6 +646,7 @@ Erik Eriksson;654321;4WD;Associated;;</code></pre>
});
applySettingsTab(settingsTabButtons.some((button) => button.dataset.settingsTab === activeSettingsTab) ? activeSettingsTab : "connection");
mountAuthSettings(document.getElementById("authSettings"), { t, escapeHtml });
mountTtsSettings(document.getElementById("ttsSettings"), { state, t, escapeHtml, saveState, speakText });
const applyRaceAudioFormValues = (form) => {
const events = {};

79
src/tts_settings_view.js Normal file
View File

@@ -0,0 +1,79 @@
// "Voice engine" panel in Settings -> Audio: choose browser voices or the local Piper
// voices, install Piper + Swedish/English voices once, and pre-generate fixed phrases and
// driver names so race day never waits on generation.
const LANGUAGES = ["sv", "en"];
// Fixed per language on purpose - the sample must be in the voice being tested, not the UI language.
const SAMPLES = { sv: "Heat ett startar om tio sekunder. Nytt varvrekord för Alex Karlsson.", en: "Heat one starts in ten seconds. New lap record for Alex Karlsson." };
async function getStatus() {
return fetch("/api/tts/status").then((res) => res.json()).catch(() => null);
}
async function buildPregenerateItems(state) {
const manifest = await fetch("/race-sounds/sound_manifest.json").then((res) => res.json()).catch(() => ({}));
const items = [];
LANGUAGES.forEach((language) => {
Object.values(manifest?.voice_events?.[language] || {})
.filter((text) => typeof text === "string" && text && !text.includes("{"))
.forEach((text) => items.push({ text, language }));
(state.drivers || []).forEach((driver) => {
const name = String(driver.speechName || driver.name || "").trim();
if (name) items.push({ text: name, language });
});
});
return items;
}
export async function mountTtsSettings(container, deps) {
if (!container) return;
const { state, t, escapeHtml, saveState, speakText } = deps;
const status = await getStatus();
if (!container.isConnected) return;
const remount = () => mountTtsSettings(container, deps);
const hasLanguage = (language) => Boolean(status?.voices?.some((voice) => voice.language === language));
const engine = state.settings.ttsEngine === "piper" ? "piper" : "browser";
const install = status?.install || {};
const pregen = status?.pregenerate || {};
const busy = install.running || pregen.running;
container.innerHTML = `
<p class="hint">${escapeHtml(t("tts.hint"))}</p>
<div class="form-grid cols-2">
<label><span>${escapeHtml(t("tts.engine"))}</span>
<select id="ttsEngine">
<option value="browser" ${engine === "browser" ? "selected" : ""}>${escapeHtml(t("tts.engine_browser"))}</option>
<option value="piper" ${engine === "piper" ? "selected" : ""}>${escapeHtml(t("tts.engine_piper"))}</option>
</select>
</label>
<div>
<p><strong>${escapeHtml(status?.available ? t("tts.status_ready") : t("tts.status_missing"))}</strong></p>
<p class="hint">${LANGUAGES.map((language) => `${language === "sv" ? "Svenska" : "English"}: ${hasLanguage(language) ? "✓" : "✗"}`).join(" · ")} · ${escapeHtml(t("tts.cached", { count: status?.cachedLines || 0 }))}</p>
</div>
</div>
<div class="actions">
<button class="btn" type="button" id="ttsInstall" ${busy ? "disabled" : ""}>${escapeHtml(t(status?.available && hasLanguage("sv") && hasLanguage("en") ? "tts.reinstall" : "tts.install"))}</button>
<button class="btn" type="button" id="ttsPregenerate" ${busy || !status?.available ? "disabled" : ""}>${escapeHtml(t("tts.pregenerate"))}</button>
<button class="btn" type="button" id="ttsTest" ${!status?.available ? "disabled" : ""}>${escapeHtml(t("tts.test"))}</button>
</div>
${install.running || install.log?.length ? `<pre class="log-box">${escapeHtml((install.log || []).join("\n"))}${install.error ? `\n${escapeHtml(install.error)}` : ""}</pre>` : ""}
${pregen.total ? `<p class="hint">${escapeHtml(t("tts.pregenerate_progress", { done: pregen.done, total: pregen.total, failed: pregen.failed }))}</p>` : ""}`;
container.querySelector("#ttsEngine").addEventListener("change", (event) => {
state.settings.ttsEngine = event.target.value === "piper" ? "piper" : "browser";
saveState();
});
container.querySelector("#ttsInstall").addEventListener("click", async () => {
await fetch("/api/tts/install", { method: "POST" });
remount();
});
container.querySelector("#ttsPregenerate").addEventListener("click", async () => {
await fetch("/api/tts/pregenerate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: await buildPregenerateItems(state) }) });
remount();
});
container.querySelector("#ttsTest").addEventListener("click", () => {
const language = state.settings.raceAudio?.language === "en" ? "en" : "sv";
speakText(SAMPLES[language], { interrupt: true, language });
});
// Keep polling while a background install/pre-generation is running.
if (busy) setTimeout(() => { if (container.isConnected) remount(); }, 1500);
}