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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user