Add configurable manifest-driven race audio system
This commit is contained in:
287
src/race_audio.js
Normal file
287
src/race_audio.js
Normal file
@@ -0,0 +1,287 @@
|
||||
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("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("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",
|
||||
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?.();
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user