Files
Live_RC/src/race_audio.js
larssand 1bc75f8ec1 feat(race-day): add warm-up transponder check before a heat
Race Day gets a 1-3 minute warm-up per heat with a green check per
driver when their transponder is seen, countdown announcements
(sv/en), quick transponder fix from the panel, and a start warning
that still allows starting when someone is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:47:15 +02:00

306 lines
17 KiB
JavaScript

export const RACE_AUDIO_PRIORITIES = Object.freeze({ pass: 0, low: 10, normal: 20, high: 30, critical: 40 });
export const START_LIGHT_STATES = Object.freeze(["IDLE", "GRID", "READY", "LIGHT_1", "LIGHT_2", "LIGHT_3", "LIGHT_4", "LIGHT_5", "WAIT_RANDOM", "GO"]);
const event = (key, group, priority, voice = true, effect = "", cooldownMs = 0) => ({ key, group, priority, voice, effect, cooldownMs });
export const RACE_AUDIO_EVENT_DEFINITIONS = Object.freeze([
event("grid_open", "start", "normal"), event("grid_closed", "start", "normal"),
event("race_1_minute", "start", "normal"), event("race_30_seconds", "start", "normal"),
event("race_10_seconds", "start", "normal", true, "effects/warning.wav"),
event("drivers_ready", "start", "high"), event("start_sequence", "start", "high"),
event("start_tone", "start", "high", false, "effects/start_beep.wav"),
event("false_start", "start", "critical", true, "effects/warning.wav", 1000), event("restart_race", "start", "high"),
event("staggered_release", "start", "normal"),
event("warmup_start", "start", "normal"), event("warmup_1_minute", "start", "normal"), event("warmup_end", "start", "high"),
event("transponder_pass", "race", "pass", false, "effects/transponder_pass.wav"), event("lap_completed", "race", "pass", false),
event("personal_best", "race", "low", true, "effects/personal_best.wav", 1500),
event("fastest_lap", "race", "normal", true, "effects/fastest_lap_trophy.wav", 1500),
event("track_record", "race", "normal", true, "effects/track_record_fanfare.wav", 2000),
event("training_stable_pace", "training", "low", true, "", 120000),
event("new_leader", "race", "normal", true, "", 1000),
event("position_gain", "race", "low", false, "effects/position_gain.wav", 750),
event("position_loss", "race", "low", false, "effects/position_loss.wav", 750),
event("final_lap", "race", "high", true, "effects/final_lap.wav"),
event("one_minute_remaining", "race", "normal"), event("thirty_seconds_remaining", "race", "normal"),
event("ten_seconds_remaining", "race", "normal", true, "effects/warning.wav"),
event("time_expired", "race", "high", true, "effects/warning.wav"),
event("race_finished", "race", "high", true, "effects/race_finish.wav"),
event("track_call", "control", "critical", true, "effects/warning.wav", 1000), event("track_clear", "control", "critical"),
event("yellow_flag", "control", "critical", true, "effects/warning.wav"), event("red_flag", "control", "critical", true, "effects/warning.wav"),
event("race_paused", "control", "critical", true, "effects/warning.wav"), event("race_resumed", "control", "high"),
event("marshal_warning", "control", "high", true, "effects/warning.wav", 1000), event("marshal_required", "control", "high", true, "effects/warning.wav", 1000),
event("penalty", "control", "high", true, "effects/warning.wav", 750), event("stop_and_go", "control", "high", true, "effects/warning.wav"),
event("drive_through", "control", "high", true, "effects/warning.wav"), event("disqualified", "control", "high", true, "effects/warning.wav"),
event("pit_lane_open", "pit", "normal"), event("pit_lane_closed", "pit", "normal"),
event("entering_pits", "pit", "low", false, "effects/transponder_pass.wav"), event("leaving_pits", "pit", "low", false, "effects/transponder_pass.wav"),
event("pit_speed_warning", "pit", "high", true, "effects/warning.wav"),
event("qualifying_start", "qualifying", "high"), event("qualifying_remaining", "qualifying", "normal"),
event("qualifying_finished", "qualifying", "high", true, "effects/race_finish.wav"), event("provisional_pole", "qualifying", "normal"),
event("pole_position", "qualifying", "normal", true, "effects/fastest_lap_trophy.wav"),
event("race_winner", "results", "high", true, "effects/fastest_lap_trophy.wav"), event("second_place", "results", "normal"),
event("third_place", "results", "normal"), event("podium", "results", "normal"),
event("heat_winner", "results", "high", true, "effects/fastest_lap_trophy.wav"), event("overall_winner", "results", "high", true, "effects/fastest_lap_trophy.wav"),
event("timing_connected", "system", "normal"), event("timing_disconnected", "system", "critical", true, "effects/warning.wav", 2000),
event("transponder_detected", "system", "pass", false, "effects/transponder_pass.wav"),
event("unknown_transponder", "system", "low", true, "effects/warning.wav", 2000), event("audio_test", "system", "normal", true, "effects/start_beep.wav"),
]);
const EVENT_BY_KEY = new Map(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => [item.key, item]));
const DEFAULT_PASSING_SOUND = "effects/transponder_pass.wav";
const clamp = (value, fallback, min = 0, max = 1) => {
const number = Number(value);
return Math.max(min, Math.min(max, Number.isFinite(number) ? number : fallback));
};
export function normalizeRaceAudioSettings(value = {}) {
const source = value && typeof value === "object" ? value : {};
const events = {};
RACE_AUDIO_EVENT_DEFINITIONS.forEach((definition) => {
const configured = source.events?.[definition.key] || {};
events[definition.key] = {
effect: Boolean(definition.effect) && configured.effect !== false,
voice: Boolean(definition.voice) && configured.voice !== false,
};
});
const minStartDelay = clamp(source.minStartDelay ?? source.start?.randomDelay?.min, 1, 0, 10);
const maxStartDelay = clamp(source.maxStartDelay ?? source.start?.randomDelay?.max, 4, minStartDelay, 10);
return {
enabled: source.enabled !== false,
muted: source.muted === true,
language: source.language === "en" ? "en" : "sv",
voiceUri: String(source.voiceUri || ""),
voiceRate: clamp(source.voiceRate, 1.08, 0.65, 1.35),
voicePitch: clamp(source.voicePitch, 0.9, 0.7, 1.3),
masterVolume: clamp(source.masterVolume ?? source.volume?.master, 0.8),
effectsVolume: clamp(source.effectsVolume ?? source.volume?.effects, 0.8),
voiceVolume: clamp(source.voiceVolume ?? source.volume?.voice, 0.9),
passingVolume: clamp(source.passingVolume ?? source.volume?.passing, 0.45),
startCountdownSeconds: [0, 10, 30, 60].includes(Number(source.startCountdownSeconds)) ? Number(source.startCountdownSeconds) : 0,
startLights: source.startLights !== false,
lightIntervalMs: Math.round(clamp(source.lightIntervalMs, 300, 100, 2000)),
randomStartDelay: source.randomStartDelay !== false,
minStartDelay,
maxStartDelay,
perCompetitorPassingTone: source.perCompetitorPassingTone === true,
events,
templates: source.templates && typeof source.templates === "object" ? { ...source.templates } : {},
};
}
const joinUrl = (baseUrl, filePath) => `${String(baseUrl || "").replace(/\/+$/, "")}/${String(filePath || "").replace(/^\/+/, "")}`;
const interpolate = (template, variables = {}) => String(template || "").replace(/\{(\w+)\}/g, (_match, key) => String(variables[key] ?? ""));
function safeEffectPath(value) {
const clean = String(value || "").trim().replace(/\\/g, "/");
if (!clean || clean.includes("..") || clean.startsWith("/") || /^https?:/i.test(clean)) return "";
return clean.includes("/") ? clean : `effects/${clean}`;
}
function variantPlaybackRate(value) {
let hash = 0;
for (const char of String(value || "")) hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0;
return 0.92 + (Math.abs(hash) % 17) / 100;
}
export function createRaceAudioEngine(options = {}) {
const manifestUrl = options.manifestUrl || "/race-sounds/sound_manifest.json";
const baseUrl = options.baseUrl || "/race-sounds/sounds";
const fetchImpl = options.fetchImpl || globalThis.fetch?.bind(globalThis);
const audioFactory = options.audioFactory || ((src) => new Audio(src));
const speak = options.speak || (() => false);
const getSettings = options.getSettings || (() => normalizeRaceAudioSettings());
const wait = options.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
const random = options.random || Math.random;
const now = options.now || Date.now;
const log = options.log || (() => {});
let manifestPromise = null;
let manifest = null;
let currentVoice = null;
let voiceSequence = 0;
let lightState = "IDLE";
let startSequenceGeneration = 0;
const voiceQueue = [];
const lightSubscribers = new Set();
const missingEffects = new Set();
const cooldowns = new Map();
async function loadManifest() {
if (manifest) return manifest;
if (!manifestPromise) manifestPromise = (async () => {
if (!fetchImpl) return null;
try {
const response = await fetchImpl(manifestUrl, { cache: "no-store" });
if (!response?.ok) return null;
manifest = await response.json();
return manifest;
} catch { return null; }
})();
return manifestPromise;
}
function configFor(key) {
const settings = normalizeRaceAudioSettings(getSettings());
return { settings, definition: EVENT_BY_KEY.get(key), configured: settings.events[key] || {} };
}
async function attemptEffect(effectPath, volume, playbackRate) {
if (!effectPath || missingEffects.has(effectPath)) return false;
try {
const audio = audioFactory(joinUrl(baseUrl, effectPath));
audio.volume = clamp(volume, 0.8);
if (playbackRate) audio.playbackRate = playbackRate;
const playback = audio.play();
if (playback?.then) await playback;
return true;
} catch {
missingEffects.add(effectPath);
return false;
}
}
async function playEffect(key, eventOptions = {}) {
const { settings, definition, configured } = configFor(key);
if (!settings.enabled || settings.muted || !configured.effect || !definition?.effect) return false;
await loadManifest();
const customPath = safeEffectPath(eventOptions.effectPath);
const effectPath = customPath || safeEffectPath(manifest?.events?.[key]?.effect || definition.effect);
const isPassing = key === "transponder_pass" || key === "transponder_detected";
const volume = settings.masterVolume * (isPassing ? settings.passingVolume : settings.effectsVolume);
const rate = settings.perCompetitorPassingTone && isPassing && eventOptions.variant ? variantPlaybackRate(eventOptions.variant) : undefined;
const played = await attemptEffect(effectPath, volume, rate);
const fallbackRate = rate || (customPath && isPassing ? variantPlaybackRate(customPath) : undefined);
if (!played && customPath && isPassing) return attemptEffect(DEFAULT_PASSING_SOUND, volume, fallbackRate);
return played;
}
async function resolveVoice(key, eventOptions = {}) {
const { settings, definition, configured } = configFor(key);
if (!settings.enabled || settings.muted || !configured.voice || !definition?.voice) return null;
const loaded = await loadManifest();
const phraseKey = loaded?.events?.[key]?.voice || key;
const template = eventOptions.voiceText || settings.templates[key] || loaded?.voice_events?.[settings.language]?.[phraseKey] || "";
if (!template) return null;
return {
key,
text: interpolate(template, eventOptions.variables),
language: settings.language,
volume: settings.masterVolume * settings.voiceVolume,
priority: RACE_AUDIO_PRIORITIES[eventOptions.priority || definition.priority] ?? RACE_AUDIO_PRIORITIES.normal,
interrupt: eventOptions.interrupt === true || definition.priority === "critical",
};
}
function pumpVoiceQueue() {
if (currentVoice || !voiceQueue.length) return;
voiceQueue.sort((a, b) => b.priority - a.priority || a.sequence - b.sequence);
const task = voiceQueue.shift();
currentVoice = task;
Promise.resolve(speak(task.text, { language: task.language, volume: task.volume, interrupt: task.interrupt }))
.catch(() => false).finally(() => {
if (currentVoice === task) currentVoice = null;
task.resolve(true);
pumpVoiceQueue();
});
}
async function enqueueVoice(key, eventOptions = {}) {
const voice = await resolveVoice(key, eventOptions);
if (!voice) return false;
return new Promise((resolve) => {
const task = { ...voice, sequence: voiceSequence += 1, resolve };
if (task.interrupt && currentVoice && task.priority > currentVoice.priority) {
currentVoice.resolve(false);
currentVoice = null;
}
voiceQueue.push(task);
pumpVoiceQueue();
});
}
function isCoolingDown(key, eventOptions, definition) {
if (eventOptions.force) return false;
const cooldownMs = Number(eventOptions.cooldownMs ?? definition?.cooldownMs ?? 0);
if (!cooldownMs) return false;
const scope = `${key}:${eventOptions.scope || eventOptions.driverId || eventOptions.competitorKey || "global"}`;
const timestamp = now();
const previous = cooldowns.get(scope);
if (Number.isFinite(previous) && timestamp - previous < cooldownMs) return true;
cooldowns.set(scope, timestamp);
return false;
}
function trigger(key, eventOptions = {}) {
const definition = EVENT_BY_KEY.get(key);
if (!definition || isCoolingDown(key, eventOptions, definition)) return { effectDone: Promise.resolve(false), voiceDone: Promise.resolve(false) };
const effectDone = playEffect(key, eventOptions);
const voiceDone = effectDone.catch(() => false).then(() => enqueueVoice(key, eventOptions));
try { log({ timestamp: now(), event: key, audioFile: safeEffectPath(eventOptions.effectPath || definition.effect), priority: definition.priority, language: normalizeRaceAudioSettings(getSettings()).language, ...eventOptions.logData }); } catch { /* non-critical */ }
return { effectDone, voiceDone };
}
function emitLightState(nextState, details = {}) {
if (!START_LIGHT_STATES.includes(nextState)) return;
lightState = nextState;
lightSubscribers.forEach((listener) => { try { listener(nextState, details); } catch { /* UI-only */ } });
}
async function runStartSequence(session, callbacks = {}) {
const generation = ++startSequenceGeneration;
const cancelled = () => generation !== startSequenceGeneration;
const settings = normalizeRaceAudioSettings(getSettings());
if (!settings.enabled || settings.muted || callbacks.skipAudio) {
emitLightState("GO", { sessionId: session?.id });
callbacks.onTone?.();
return;
}
emitLightState("GRID", { sessionId: session?.id });
if (settings.startCountdownSeconds >= 60) { trigger("race_1_minute", { interrupt: true }); callbacks.onCountdown?.(60); await wait(30000); if (cancelled()) return false; }
if (settings.startCountdownSeconds >= 30) { trigger("race_30_seconds", { interrupt: true }); callbacks.onCountdown?.(30); await wait(20000); if (cancelled()) return false; }
if (settings.startCountdownSeconds >= 10) {
trigger("race_10_seconds", { interrupt: true });
for (let seconds = 10; seconds > 0; seconds -= 1) { callbacks.onCountdown?.(seconds); await wait(1000); if (cancelled()) return false; }
}
emitLightState("READY", { sessionId: session?.id });
await trigger("drivers_ready", { interrupt: true }).voiceDone; if (cancelled()) return false;
if (session?.type === "qualification") await trigger("qualifying_start").voiceDone; if (cancelled()) return false;
await trigger("start_sequence").voiceDone; if (cancelled()) return false;
if (settings.startLights) {
for (let light = 1; light <= 5; light += 1) {
emitLightState(`LIGHT_${light}`, { sessionId: session?.id, lights: light });
callbacks.onLight?.(light);
await wait(settings.lightIntervalMs); if (cancelled()) return false;
}
}
emitLightState("WAIT_RANDOM", { sessionId: session?.id });
const delaySeconds = settings.randomStartDelay
? settings.minStartDelay + random() * (settings.maxStartDelay - settings.minStartDelay)
: settings.minStartDelay;
callbacks.onArmed?.(delaySeconds);
await wait(delaySeconds * 1000); if (cancelled()) return false;
if (cancelled()) return false;
emitLightState("GO", { sessionId: session?.id });
await playEffect("start_tone", { force: true });
if (cancelled()) return false;
callbacks.onTone?.();
// Staggered heats don't release every car at once - the session is already live and
// recording by this point (each driver's own personal clock starts at their own first
// crossing regardless), this is purely the announcer calling cars out in grid order so
// marshals know who goes next, ~3s apart.
if (session?.startMode === "staggered" && Array.isArray(callbacks.staggerOrder) && callbacks.staggerOrder.length) {
for (let index = 0; index < callbacks.staggerOrder.length; index += 1) {
if (cancelled()) return false;
await trigger("staggered_release", { variables: { tp: callbacks.staggerOrder[index] }, scope: callbacks.staggerOrder[index], logData: { sessionId: session?.id, transponder: callbacks.staggerOrder[index] } }).voiceDone;
if (cancelled()) return false;
if (index < callbacks.staggerOrder.length - 1) { await wait(3000); if (cancelled()) return false; }
}
}
return true;
}
return {
loadManifest, playEffect, trigger, runStartSequence,
subscribeStartLights(listener) { lightSubscribers.add(listener); listener(lightState, {}); return () => lightSubscribers.delete(listener); },
cancelStartSequence() { startSequenceGeneration += 1; emitLightState("IDLE"); },
resetStartLights() { emitLightState("IDLE"); },
getStartLightState: () => lightState,
getStatus: () => ({ loaded: Boolean(manifest), missingEffects: [...missingEffects], queuedVoices: voiceQueue.length, currentVoice: currentVoice?.key || "", lightState }),
};
}