Group any number of races in freely chosen classes into a series with a points table (ÖRC/100-80-70/F1/custom), best-N or drop-X counting, a club filter that re-ranks members without guests, tie-breaks, rounds created from a date list, and per-class standings with print and PDF. Also fixes PDF export of wide tables: they are compacted and switched to landscape instead of truncating names and dropping columns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
459 lines
25 KiB
JavaScript
459 lines
25 KiB
JavaScript
import { normalizeSeries } from "./series_logic.js";
|
|
import { speakWithPiper, isPiperSpeaking } from "./piper_speech.js";
|
|
export function createDefaultAmmcConfigHelper() {
|
|
return {
|
|
managedEnabled: false,
|
|
autoStart: false,
|
|
decoderHost: "",
|
|
wsPort: 9000,
|
|
splitEnabled: false,
|
|
splitDecoderHost: "",
|
|
splitWsPort: 9001,
|
|
pitEnabled: false,
|
|
pitEntryDecoderHost: "",
|
|
pitEntryWsPort: 9002,
|
|
pitExitDecoderHost: "",
|
|
pitExitWsPort: 9003,
|
|
executablePath: "",
|
|
workingDirectory: "",
|
|
extraArgs: "",
|
|
};
|
|
}
|
|
|
|
function migrateBrandDefault(value, legacyValues, fallback) {
|
|
const normalized = String(value || "").trim();
|
|
return !normalized || legacyValues.includes(normalized) ? fallback : normalized;
|
|
}
|
|
|
|
export function getManagedWsUrlHelper({ ammc, getBackendUrl }, role = "main") {
|
|
const portKey = role === "split" ? "splitWsPort" : role === "pit_entry" ? "pitEntryWsPort" : role === "pit_exit" ? "pitExitWsPort" : "wsPort";
|
|
const defaultPort = role === "split" ? 9001 : role === "pit_entry" ? 9002 : role === "pit_exit" ? 9003 : 9000;
|
|
const port = Number(ammc.config?.[portKey] || defaultPort);
|
|
try {
|
|
const backendUrl = new URL(getBackendUrl());
|
|
return `ws://${backendUrl.hostname}:${port}`;
|
|
} catch {
|
|
return `ws://127.0.0.1:${port}`;
|
|
}
|
|
}
|
|
|
|
export async function loadAmmcConfigFromBackendHelper({ ammc, t, getBackendUrl, createDefaultAmmcConfig }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/config`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const payload = await res.json();
|
|
ammc.config = { ...createDefaultAmmcConfig(), ...(payload.config || {}) };
|
|
ammc.status = payload.status || null;
|
|
ammc.lastError = "";
|
|
ammc.loaded = true;
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
ammc.loaded = false;
|
|
}
|
|
}
|
|
|
|
export async function saveAmmcConfigToBackendHelper(config, { ammc, t, getBackendUrl, createDefaultAmmcConfig }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(config) });
|
|
const payload = await res.json();
|
|
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
|
|
ammc.config = { ...createDefaultAmmcConfig(), ...(payload.config || {}) };
|
|
ammc.status = payload.status || ammc.status;
|
|
ammc.lastError = "";
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_save_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
export async function refreshAmmcStatusHelper({ ammc, t, getBackendUrl }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/status`);
|
|
const payload = await res.json();
|
|
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
|
|
ammc.status = payload;
|
|
ammc.lastError = "";
|
|
ammc.loaded = true;
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
export async function startManagedAmmcHelper({ ammc, t, getBackendUrl }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/start`, { method: "POST" });
|
|
const payload = await res.json();
|
|
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
|
|
ammc.status = payload.status || null;
|
|
ammc.lastError = "";
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_start_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
export async function stopManagedAmmcHelper({ ammc, t, getBackendUrl }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/stop`, { method: "POST" });
|
|
const payload = await res.json();
|
|
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
|
|
ammc.status = payload.status || null;
|
|
ammc.lastError = "";
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_stop_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
export function applyPersistedStateHelper(persisted, deps) {
|
|
const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings = (value = {}) => value || {}, normalizeTrackLayouts = (value) => (Array.isArray(value) ? value : []), applyTheme } = deps;
|
|
state.classes = persisted.classes || [];
|
|
state.drivers = (persisted.drivers || []).map((driver) => normalizeDriver(driver)).filter((driver) => driver.name);
|
|
state.cars = (persisted.cars || []).map((car) => normalizeCar(car)).filter((car) => car.name);
|
|
state.events = (persisted.events || []).map((event) => normalizeEvent(event));
|
|
state.sessions = (persisted.sessions || []).map((session) => normalizeSession(session));
|
|
state.resultsBySession = persisted.resultsBySession || {};
|
|
state.audioEventLog = Array.isArray(persisted.audioEventLog) ? persisted.audioEventLog.slice(0, 500) : [];
|
|
state.training = normalizeTrainingState(persisted.training);
|
|
state.activeSessionId = persisted.activeSessionId || null;
|
|
state.trackLayouts = normalizeTrackLayouts(persisted.trackLayouts);
|
|
state.series = (Array.isArray(persisted.series) ? persisted.series : []).map((series) => normalizeSeries(series)).filter((series) => series.id);
|
|
state.settings = {
|
|
wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000",
|
|
splitDecoderEnabled: persisted.settings?.splitDecoderEnabled === true,
|
|
splitWsUrl: String(persisted.settings?.splitWsUrl || state.settings.splitWsUrl || "ws://127.0.0.1:9001"),
|
|
pitDecoderEnabled: persisted.settings?.pitDecoderEnabled === true,
|
|
pitEntryWsUrl: String(persisted.settings?.pitEntryWsUrl || state.settings.pitEntryWsUrl || "ws://127.0.0.1:9002"),
|
|
pitExitWsUrl: String(persisted.settings?.pitExitWsUrl || state.settings.pitExitWsUrl || "ws://127.0.0.1:9003"),
|
|
backendUrl: persisted.settings?.backendUrl || state.settings.backendUrl || getDefaultBackendUrl(),
|
|
publicBaseUrl: String(persisted.settings?.publicBaseUrl || state.settings.publicBaseUrl || ""),
|
|
language: persisted.settings?.language || state.settings.language || DEFAULT_LANGUAGE,
|
|
theme: AVAILABLE_THEMES.includes(String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase()) ? String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase() : DEFAULT_THEME,
|
|
autoReconnect: persisted.settings?.autoReconnect !== false,
|
|
audioEnabled: persisted.settings?.audioEnabled !== false,
|
|
passingSoundMode: persisted.settings?.passingSoundMode || state.settings.passingSoundMode || "beep",
|
|
finishVoiceEnabled: persisted.settings?.finishVoiceEnabled !== false,
|
|
startSoundEnabled: persisted.settings?.startSoundEnabled !== false,
|
|
finishSoundEnabled: persisted.settings?.finishSoundEnabled !== false,
|
|
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",
|
|
ttsVoiceSv: String(persisted.settings?.ttsVoiceSv || ""),
|
|
ttsVoiceEn: String(persisted.settings?.ttsVoiceEn || ""),
|
|
speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true,
|
|
speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false,
|
|
speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false,
|
|
speakerBestLapCueEnabled: persisted.settings?.speakerBestLapCueEnabled !== false,
|
|
speakerTop3CueEnabled: persisted.settings?.speakerTop3CueEnabled === true,
|
|
speakerSessionStartCueEnabled: persisted.settings?.speakerSessionStartCueEnabled !== false,
|
|
clubName: migrateBrandDefault(persisted.settings?.clubName || state.settings.clubName, ["JMK RB RaceController", "RC RaceCore"], "JMK RB"),
|
|
clubTagline: migrateBrandDefault(persisted.settings?.clubTagline || state.settings.clubTagline, ["RC Timing System"], "Live Timing & Race Control"),
|
|
pdfFooter: migrateBrandDefault(persisted.settings?.pdfFooter || state.settings.pdfFooter, ["Generated by JMK RB RaceController"], "Generated by RC RaceCore"),
|
|
pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic",
|
|
logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "",
|
|
raceAudio: normalizeRaceAudioSettings(persisted.settings?.raceAudio || state.settings?.raceAudio),
|
|
racePresets: Array.isArray(persisted.settings?.racePresets) ? persisted.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : Array.isArray(state.settings?.racePresets) ? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [],
|
|
obsOverlay: normalizeObsOverlaySettings(persisted.settings?.obsOverlay || state.settings?.obsOverlay),
|
|
};
|
|
applyTheme();
|
|
}
|
|
|
|
export async function hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/state`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const payload = await res.json();
|
|
if (payload && payload.state && typeof payload.state === "object") {
|
|
applyPersistedState(payload.state);
|
|
saveState({ skipBackend: true });
|
|
backend.lastSyncAt = payload.updatedAt || new Date().toISOString();
|
|
backend.lastLoadedAt = new Date().toISOString();
|
|
}
|
|
backend.available = true;
|
|
backend.lastError = "";
|
|
} catch (error) {
|
|
backend.available = false;
|
|
backend.lastError = t("error.backend_offline", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
export function scheduleBackendSyncHelper({ getBackendSyncTimer, setBackendSyncTimer, syncStateToBackend }) {
|
|
clearTimeout(getBackendSyncTimer());
|
|
setBackendSyncTimer(setTimeout(() => { syncStateToBackend(); }, 350));
|
|
}
|
|
|
|
export async function syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion, getSyncedStateVersion, setSyncedStateVersion, setLocalStateDirty }) {
|
|
backend.pendingWrites = (backend.pendingWrites || 0) + 1;
|
|
const syncVersion = getLocalStateVersion();
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(buildPersistableState()) });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
backend.available = true;
|
|
backend.lastError = "";
|
|
backend.lastSyncAt = new Date().toISOString();
|
|
backend.lastSavedAt = backend.lastSyncAt;
|
|
backend.writeError = "";
|
|
setSyncedStateVersion(Math.max(getSyncedStateVersion(), syncVersion));
|
|
if (syncVersion === getLocalStateVersion()) {
|
|
setLocalStateDirty(false);
|
|
backend.passingError = "";
|
|
}
|
|
} catch (error) {
|
|
backend.available = false;
|
|
backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
backend.writeError = backend.lastError;
|
|
} finally {
|
|
backend.pendingWrites = Math.max(0, (backend.pendingWrites || 1) - 1);
|
|
}
|
|
}
|
|
|
|
export async function pingBackendHelper({ backend, t, getBackendUrl }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/health`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
backend.available = true;
|
|
backend.lastError = "";
|
|
} catch (error) {
|
|
backend.available = false;
|
|
backend.lastError = t("error.health_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
export async function checkAppVersionHelper({ getBackendUrl, getBaselineAppVersion, setBaselineAppVersion, getPendingAppVersion, setPendingAppVersion, getLocalStateDirty, onVersion = () => {}, onContact = () => {} }) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/app-version`, { cache: "no-store" });
|
|
if (!res.ok) { onContact(false); return; }
|
|
const payload = await res.json();
|
|
onContact(true);
|
|
if (typeof payload.version === "string" && payload.version) onVersion(payload.version);
|
|
const key = `${payload.revision}:${payload.updatedAt}`;
|
|
if (!getBaselineAppVersion()) {
|
|
setBaselineAppVersion(key);
|
|
setPendingAppVersion("");
|
|
return;
|
|
}
|
|
if (key === getBaselineAppVersion()) {
|
|
setPendingAppVersion("");
|
|
return;
|
|
}
|
|
if (getPendingAppVersion() !== key) {
|
|
setPendingAppVersion(key);
|
|
return;
|
|
}
|
|
if (getLocalStateDirty()) {
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
} catch { onContact(false); }
|
|
}
|
|
|
|
export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer, setAppVersionPollTimer }) {
|
|
if (!window.location.protocol.startsWith("http")) return;
|
|
clearInterval(getAppVersionPollTimer());
|
|
checkAppVersion();
|
|
setAppVersionPollTimer(setInterval(checkAppVersion, 5000));
|
|
}
|
|
|
|
export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) {
|
|
clearInterval(getOverlaySyncTimer());
|
|
setOverlaySyncTimer(setInterval(async () => {
|
|
if (getCurrentView() !== "overlay") return;
|
|
if (!getLocalStateDirty()) await hydrateFromBackend();
|
|
renderView();
|
|
}, 800));
|
|
}
|
|
|
|
// A browser left open on any other view never re-fetches state on its own, so the copy it
|
|
// would push on its next save only gets staler over time - the longer it sits, the more of
|
|
// another browser's meanwhile-made edits its next save can silently discard (see
|
|
// syncStateToBackendHelper/the whole-document overwrite it does). This keeps that copy fresh
|
|
// in the background without ever touching the DOM, so it can never interrupt an open, unsaved
|
|
// edit form the way calling renderView() here could. Overlay already refreshes and renders
|
|
// itself on its own, faster cycle above, so it's left alone.
|
|
export function startBackgroundHydrationHelper({ getBackgroundHydrationTimer, setBackgroundHydrationTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, intervalMs = 15000 }) {
|
|
clearInterval(getBackgroundHydrationTimer());
|
|
setBackgroundHydrationTimer(setInterval(async () => {
|
|
if (getCurrentView() === "overlay") return;
|
|
if (getLocalStateDirty()) return;
|
|
await hydrateFromBackend();
|
|
}, intervalMs));
|
|
}
|
|
|
|
export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlayRotationTimer, getOverlayRotationIndex, setOverlayRotationIndex, getCurrentView, getOverlayViewMode, renderView }) {
|
|
clearInterval(getOverlayRotationTimer());
|
|
setOverlayRotationTimer(setInterval(() => {
|
|
setOverlayRotationIndex((getOverlayRotationIndex() + 1) % 3);
|
|
if (getCurrentView() === "overlay" && getOverlayViewMode() === "leaderboard") renderView();
|
|
}, 8000));
|
|
}
|
|
|
|
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, refreshOverlayDynamics }) {
|
|
clearInterval(getOverlayLiveRefreshTimer());
|
|
setOverlayLiveRefreshTimer(setInterval(() => {
|
|
if (getCurrentView() === "overlay" && ["leaderboard", "speaker", "results", "tv", "team", "obs"].includes(getOverlayViewMode())) refreshOverlayDynamics();
|
|
}, 250));
|
|
}
|
|
|
|
export function ensureAudioContextHelper({ state, getAudioCtx, setAudioCtx }) {
|
|
if (!state.settings.audioEnabled) return null;
|
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
if (!Ctx) return null;
|
|
if (!getAudioCtx()) setAudioCtx(new Ctx());
|
|
const ctx = getAudioCtx();
|
|
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
|
return ctx;
|
|
}
|
|
|
|
function playTone({ ensureAudioContext, type, startFreq, ramps = [], attack = 0.01, peak = 0.08, release = 0.2, stopAt = 0.25 }) {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) return;
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.setValueAtTime(startFreq, ctx.currentTime);
|
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(peak, ctx.currentTime + attack);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + release);
|
|
ramps.forEach(([freq, at, kind]) => {
|
|
if (kind === 'linear') osc.frequency.linearRampToValueAtTime(freq, ctx.currentTime + at);
|
|
else osc.frequency.setValueAtTime(freq, ctx.currentTime + at);
|
|
});
|
|
osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + stopAt);
|
|
}
|
|
|
|
export function playPassingBeepHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sine', startFreq: 1480, peak: 0.07, release: 0.12, stopAt: 0.14, ramps: [[1760,0.08,'linear']] }); }
|
|
export function playFinishSirenHelper({ ensureAudioContext }) {
|
|
playTone({ ensureAudioContext, type: 'triangle', startFreq: 980, peak: 0.12, release: 0.58, stopAt: 0.62, attack: 0.02, ramps: [[620,0.56,'linear']] });
|
|
setTimeout(() => playTone({ ensureAudioContext, type: 'triangle', startFreq: 980, peak: 0.1, release: 0.72, stopAt: 0.76, attack: 0.02, ramps: [[520,0.68,'linear']] }), 520);
|
|
}
|
|
export function playLeaderCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'triangle', startFreq: 880, peak: 0.09, release: 0.26, stopAt: 0.28, ramps: [[1320,0.12,'linear'],[1760,0.24,'linear']] }); }
|
|
export function playStartCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'square', startFreq: 760, peak: 0.1, release: 0.62, stopAt: 0.66, attack: 0.015, ramps: [[1520,0.16,'linear'],[1520,0.54,'linear']] }); }
|
|
export function playBestLapCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sine', startFreq: 1540, peak: 0.07, release: 0.22, stopAt: 0.24, ramps: [[1980,0.2,'linear']] }); }
|
|
|
|
export function pushOverlayEventHelper(type, label, deps, options = {}) {
|
|
const { uid, state, t, getOverlayEvents, setOverlayEvents, getOverlayMode, getCurrentView, getOverlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue } = deps;
|
|
const events = getOverlayEvents();
|
|
events.unshift({ id: uid("overlay"), type, label, ts: Date.now() });
|
|
setOverlayEvents(events.length > 12 ? events.slice(0, 12) : events);
|
|
if (!options.silentCue && getOverlayMode() && getCurrentView() === "overlay" && getOverlayViewMode() === "speaker") {
|
|
if (type === "leader" && state.settings.speakerLeaderCueEnabled) playLeaderCue();
|
|
else if (type === "passing" && state.settings.speakerPassingCueEnabled) playPassingBeep();
|
|
else if (type === "finish" && state.settings.speakerFinishCueEnabled) playFinishSiren();
|
|
else if (type === "start" && state.settings.speakerSessionStartCueEnabled) playStartCue();
|
|
else if (type === "bestlap" && state.settings.speakerBestLapCueEnabled) playBestLapCue();
|
|
else if (type === "top3" && state.settings.speakerTop3CueEnabled) playLeaderCue();
|
|
}
|
|
}
|
|
|
|
export function getSpeechVoicesHelper() {
|
|
if (!("speechSynthesis" in window)) return [];
|
|
return window.speechSynthesis.getVoices().map((voice) => ({
|
|
name: voice.name,
|
|
lang: voice.lang,
|
|
voiceURI: voice.voiceURI,
|
|
localService: voice.localService,
|
|
}));
|
|
}
|
|
|
|
export function selectSpeechVoice(voices, voiceUri, language) {
|
|
const items = Array.isArray(voices) ? voices : [];
|
|
const selected = items.find((voice) => voice.voiceURI === voiceUri);
|
|
if (selected) return selected;
|
|
const prefix = String(language || "").toLowerCase().split("-")[0];
|
|
return items.find((voice) => String(voice.lang || "").toLowerCase().startsWith(prefix) && voice.localService !== false)
|
|
|| items.find((voice) => String(voice.lang || "").toLowerCase().startsWith(prefix))
|
|
|| null;
|
|
}
|
|
|
|
export function speakTextHelper(text, { state, currentLanguage }, options = {}) {
|
|
if (!text) return false;
|
|
if (state.settings.ttsEngine === "piper" && !options.skipPiper) {
|
|
const piperLanguage = String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en";
|
|
return speakWithPiper(text, {
|
|
language: piperLanguage,
|
|
voice: piperLanguage === "sv" ? state.settings.ttsVoiceSv : state.settings.ttsVoiceEn,
|
|
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);
|
|
const language = options.language || currentLanguage();
|
|
utterance.lang = String(language).toLowerCase().startsWith("sv") ? "sv-SE" : "en-US";
|
|
utterance.rate = Math.max(0.65, Math.min(1.35, Number(options.rate ?? state.settings.speechRate ?? 0.95) || 0.95));
|
|
utterance.pitch = Math.max(0.7, Math.min(1.3, Number(options.pitch ?? state.settings.speechPitch ?? 1) || 1));
|
|
utterance.volume = Math.max(0, Math.min(1, Number(options.volume ?? 1)));
|
|
if (typeof options.onEnd === "function") utterance.onend = options.onEnd;
|
|
if (typeof options.onError === "function") utterance.onerror = options.onError;
|
|
const configuredVoiceUri = options.voiceUri !== undefined ? options.voiceUri : state.settings.speechVoiceUri;
|
|
const voice = selectSpeechVoice(synth.getVoices(), String(configuredVoiceUri || ""), utterance.lang);
|
|
if (voice) utterance.voice = voice;
|
|
synth.speak(utterance);
|
|
return true;
|
|
}
|
|
|
|
// Race audio (race_audio.js) owns every start/finish sound; these only log the overlay event.
|
|
export function announceRaceStartedHelper(session, { t, pushOverlayEvent }) {
|
|
if (!session) return;
|
|
pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`, { silentCue: true });
|
|
}
|
|
|
|
export function announceRaceFinishedHelper({ t, getActiveSession, pushOverlayEvent }) {
|
|
const session = getActiveSession();
|
|
if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`, { silentCue: true });
|
|
}
|
|
|
|
export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId, announceRaceFinished, announceRaceClockEvent = () => {} }) {
|
|
const active = getActiveSession();
|
|
if (!active || active.status !== "running") return { changed: false };
|
|
if (isUntimedSession(active)) return { changed: false };
|
|
const timing = getSessionTiming(active);
|
|
if (!Array.isArray(active.audioAnnouncements)) active.audioAnnouncements = [];
|
|
let changed = false;
|
|
const announceOnce = (marker, eventKey) => {
|
|
if (active.audioAnnouncements.includes(marker)) return;
|
|
active.audioAnnouncements.push(marker);
|
|
announceRaceClockEvent(eventKey, active);
|
|
changed = true;
|
|
};
|
|
if (timing.remainingMs > 0 && timing.remainingMs <= 60000) announceOnce("remaining_60", "one_minute_remaining");
|
|
if (timing.remainingMs > 0 && timing.remainingMs <= 30000) announceOnce("remaining_30", "thirty_seconds_remaining");
|
|
if (timing.remainingMs > 0 && timing.remainingMs <= 10000) announceOnce("remaining_10", "ten_seconds_remaining");
|
|
if (timing.remainingMs > 0) {
|
|
if (changed) saveState();
|
|
return { changed };
|
|
}
|
|
announceOnce("time_expired", "time_expired");
|
|
if (!["open_practice", "free_practice", "practice"].includes(String(active.type || ""))) announceOnce("final_lap", "final_lap");
|
|
if (Number(active.followUpSec || 0) > 0) {
|
|
if (!active.followUpStartedAt) { active.followUpStartedAt = Date.now(); saveState(); return { changed: true }; }
|
|
if (timing.followUpRemainingMs > 0) { if (changed) saveState(); return { changed }; }
|
|
}
|
|
active.status = "finished";
|
|
active.endedAt = Date.now();
|
|
active.finishedByTimer = true;
|
|
active.followUpStartedAt = null;
|
|
if (getLastFinishAnnouncementSessionId() !== active.id) { announceRaceFinished(); setLastFinishAnnouncementSessionId(active.id); }
|
|
saveState();
|
|
return { changed: true };
|
|
}
|
|
|
|
export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, refreshTimingDynamics = () => {}, updateHeaderState }) {
|
|
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
|
|
const timerState = handleSessionTimerTick();
|
|
const active = getActiveSession();
|
|
if (["timing", "race_day"].includes(getCurrentView()) && active) {
|
|
if (timerState.changed) renderView();
|
|
else if (["running", "paused"].includes(active.status)) refreshTimingDynamics();
|
|
}
|
|
if (getCurrentView() === "dashboard" && timerState.changed) renderView();
|
|
if (getOverlayMode() && getCurrentView() === "overlay" && active && timerState.changed) renderView();
|
|
if (timerState.changed) updateHeaderState();
|
|
}
|