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>
306 lines
17 KiB
JavaScript
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 }),
|
|
};
|
|
}
|