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