- ${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5"], "", { t })}
+ ${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8"], "", { t })}
diff --git a/src/race_audio.js b/src/race_audio.js
new file mode 100644
index 0000000..86db196
--- /dev/null
+++ b/src/race_audio.js
@@ -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 }),
+ };
+}
diff --git a/src/runtime_services.js b/src/runtime_services.js
index 02c3a66..c8f088c 100644
--- a/src/runtime_services.js
+++ b/src/runtime_services.js
@@ -96,13 +96,14 @@ export async function stopManagedAmmcHelper({ ammc, t, getBackendUrl }) {
}
export function applyPersistedStateHelper(persisted, deps) {
- const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, applyTheme } = deps;
+ const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings = (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.settings = {
@@ -135,6 +136,7 @@ export function applyPersistedStateHelper(persisted, deps) {
pdfFooter: persisted.settings?.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController",
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),
};
@@ -331,9 +333,13 @@ export function speakTextHelper(text, { state, currentLanguage }, options = {})
if (!options.interrupt && (synth.speaking || synth.pending)) return false;
if (options.interrupt) synth.cancel();
const utterance = new SpeechSynthesisUtterance(text);
- utterance.lang = currentLanguage() === "sv" ? "sv-SE" : "en-US";
+ 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(state.settings.speechRate || 0.95) || 0.95));
utterance.pitch = Math.max(0.7, Math.min(1.3, Number(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 voice = selectSpeechVoice(synth.getVoices(), String(state.settings.speechVoiceUri || ""), utterance.lang);
if (voice) utterance.voice = voice;
synth.speak(utterance);
@@ -352,6 +358,7 @@ export function announcePassingHelper(entry, { state, t, playPassingBeep, speakT
export function announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }) {
if (!session) return;
pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`, { silentCue: true });
+ if (state.settings.raceAudio?.enabled) return;
if (!state.settings.audioEnabled) return;
if (state.settings.startSoundEnabled !== false) playStartCue();
if (state.settings.finishVoiceEnabled !== false) {
@@ -362,6 +369,7 @@ export function announceRaceStartedHelper(session, { state, t, pushOverlayEvent,
export function announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText }) {
const session = getActiveSession();
if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`, { silentCue: true });
+ if (state.settings.raceAudio?.enabled) return;
if (!session || !state.settings.audioEnabled) return;
if (state.settings.finishSoundEnabled !== false) playFinishSiren();
if (state.settings.finishVoiceEnabled !== false) {
@@ -369,15 +377,31 @@ export function announceRaceFinishedHelper({ state, t, getActiveSession, pushOve
}
}
-export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId, announceRaceFinished }) {
+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 (timing.remainingMs > 0) return { changed: false };
+ 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) return { changed: false };
+ if (timing.followUpRemainingMs > 0) { if (changed) saveState(); return { changed }; }
}
active.status = "finished";
active.endedAt = Date.now();
diff --git a/src/settings.js b/src/settings.js
index 38aa5e0..03dba9e 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -42,6 +42,9 @@ export function renderSettings(deps) {
refreshAmmcStatus,
startManagedAmmc,
stopManagedAmmc,
+ normalizeRaceAudioSettings,
+ raceAudioEventDefinitions,
+ triggerRaceAudio,
} = deps;
const ammcStatus = ammc.status || {};
const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : [];
@@ -54,6 +57,15 @@ export function renderSettings(deps) {
const speechVoiceOptions = speechVoices
.map((voice) => `
`)
.join("");
+ const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
+ const raceAudioEventRows = raceAudioEventDefinitions.map((definition) => `
+
+ | ${escapeHtml(definition.key.replaceAll("_", " "))}${escapeHtml(definition.group)} · ${escapeHtml(definition.priority)} |
+ |
+ |
+ |
+
+ `).join("");
dom.view.innerHTML = `
@@ -159,7 +171,28 @@ export function renderSettings(deps) {
+
+
+
+
+ ${[1, 2, 3, 4, 5].map((light) => ``).join("")}
+ ${escapeHtml(getStartLightState())}
+
@@ -392,6 +396,15 @@ export function renderTimingView(deps) {
${t("timing.speaker_panel_hint")}
+
+
+
+
+
+
+
+
+
${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue", { state, t })}
${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue", { state, t })}
@@ -583,36 +596,57 @@ export function renderTimingView(deps) {
document.getElementById("startSession")?.addEventListener("click", () => {
const session = getActiveSession();
- if (!session) {
- return;
- }
+ const startButton = document.getElementById("startSession");
+ if (!session || startButton?.disabled) return;
ensureAudioContext();
-
if (session.mode === "track") {
const trackValidation = validateTrackSessionForStart(session);
- if (!trackValidation.ok) {
- alert(trackValidation.message);
- return;
- }
+ if (!trackValidation.ok) { alert(trackValidation.message); return; }
}
-
- session.status = "running";
- session.startedAt = Date.now();
- session.endedAt = null;
- session.finishedByTimer = false;
- session.followUpStartedAt = null;
- setLastFinishAnnouncementSessionId(null);
- lastOverlayLeaderKeyBySession[session.id] = null;
- lastOverlayTop3BySession[session.id] = [];
- setOverlayEvents([]);
- ensureSessionResult(session.id);
- announceRaceStarted(session);
- saveState();
- updateHeaderState();
- renderView();
+ if (startButton) startButton.disabled = true;
+ const updateStartDisplay = (label, lights = 0) => {
+ if (startButton && label) startButton.textContent = label;
+ const panel = document.getElementById("raceStartLights");
+ if (!panel) return;
+ panel.dataset.state = label;
+ panel.querySelector("strong")?.replaceChildren(document.createTextNode(label));
+ panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights));
+ };
+ let committed = false;
+ const commitStart = () => {
+ if (committed) return;
+ committed = true;
+ session.status = "running";
+ session.startedAt = Date.now();
+ session.endedAt = null;
+ session.finishedByTimer = false;
+ session.followUpStartedAt = null;
+ session.pausedAt = null;
+ session.totalPausedMs = 0;
+ session.followUpPausedMs = 0;
+ session.audioAnnouncements = [];
+ setLastFinishAnnouncementSessionId(null);
+ lastOverlayLeaderKeyBySession[session.id] = null;
+ lastOverlayTop3BySession[session.id] = [];
+ setOverlayEvents([]);
+ ensureSessionResult(session.id);
+ announceRaceStarted(session);
+ saveState();
+ updateHeaderState();
+ renderView();
+ };
+ startSessionWithAudio(session, {
+ onCountdown: (seconds) => updateStartDisplay(String(seconds), 0),
+ onLight: (light) => updateStartDisplay("LIGHT_" + light, light),
+ onArmed: () => updateStartDisplay("WAIT_RANDOM", 5),
+ onTone: commitStart,
+ }).then((started) => {
+ if (started === false && startButton) { startButton.disabled = false; updateStartDisplay(t("timing.start"), 0); }
+ }).catch(() => commitStart());
});
document.getElementById("stopSession")?.addEventListener("click", () => {
+ cancelRaceStart();
const session = getActiveSession();
if (!session) {
return;
@@ -646,6 +680,33 @@ export function renderTimingView(deps) {
renderView();
});
+ document.querySelectorAll("[data-race-audio-cue]").forEach((node) => {
+ node.addEventListener("click", () => {
+ const cue = node.dataset.raceAudioCue;
+ const session = getActiveSession();
+ if (cue === "false_start") {
+ cancelRaceStart();
+ const startButton = document.getElementById("startSession");
+ if (startButton) { startButton.disabled = false; startButton.textContent = t("timing.start"); }
+ } else if (cue === "race_paused" && session?.status === "running") {
+ session.status = "paused";
+ session.pausedAt = Date.now();
+ saveState();
+ } else if (cue === "race_resumed" && session?.status === "paused") {
+ const pausedDuration = Math.max(0, Date.now() - Number(session.pausedAt || Date.now()));
+ session.totalPausedMs = Math.max(0, Number(session.totalPausedMs || 0)) + pausedDuration;
+ if (session.followUpStartedAt) session.followUpPausedMs = Math.max(0, Number(session.followUpPausedMs || 0)) + pausedDuration;
+ session.pausedAt = null;
+ session.status = "running";
+ saveState();
+ }
+ triggerRaceAudio(cue, { interrupt: true, force: true, logData: { raceId: session?.eventId, sessionId: session?.id } });
+ // Persist after dispatch so the audio-event log includes this manual race-control call.
+ saveState();
+ if (["race_paused", "race_resumed"].includes(cue)) { updateHeaderState(); renderView(); }
+ });
+ });
+
document.querySelectorAll("[data-speaker-setting]").forEach((node) => {
node.addEventListener("change", (event) => {
const input = event.currentTarget;
diff --git a/tests/audio_runtime.test.js b/tests/audio_runtime.test.js
index 5ea4bad..e0200cf 100644
--- a/tests/audio_runtime.test.js
+++ b/tests/audio_runtime.test.js
@@ -5,6 +5,7 @@ import {
announcePassingHelper,
announceRaceStartedHelper,
getSessionAnnouncementText,
+ handleSessionTimerTickHelper,
selectSpeechVoice,
} from "../src/runtime_services.js";
@@ -62,3 +63,28 @@ test("race start creates an overlay event and plays enabled start cues", async (
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(calls[2], ["speech", "timing.race_started_voice", { interrupt: true }]);
});
+
+test("session timer emits each race-audio clock marker once without changing finish rules", () => {
+ const active = { id: "s1", eventId: "e1", type: "final", status: "running", followUpSec: 30, audioAnnouncements: [] };
+ const announced = [];
+ let remainingMs = 59_000;
+ let saves = 0;
+ const run = () => handleSessionTimerTickHelper({
+ getActiveSession: () => active,
+ isUntimedSession: () => false,
+ getSessionTiming: () => ({ remainingMs, followUpRemainingMs: 30_000 }),
+ saveState: () => { saves += 1; },
+ getLastFinishAnnouncementSessionId: () => null,
+ setLastFinishAnnouncementSessionId: () => {},
+ announceRaceFinished: () => announced.push("race_finished"),
+ announceRaceClockEvent: (key) => announced.push(key),
+ });
+ run(); run();
+ remainingMs = 29_000; run();
+ remainingMs = 9_000; run();
+ remainingMs = 0; run();
+ assert.deepEqual(announced, ["one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "final_lap"]);
+ assert.equal(active.status, "running");
+ assert.ok(active.followUpStartedAt);
+ assert.ok(saves >= 4);
+});
diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js
index a05e3e8..9f614c2 100644
--- a/tests/decoder_runtime.test.js
+++ b/tests/decoder_runtime.test.js
@@ -29,6 +29,7 @@ function createHarness() {
};
let saveCount = 0;
const overlayEvents = [];
+ const audioEvents = [];
const deps = {
state,
t: (key) => key,
@@ -42,6 +43,7 @@ function createHarness() {
buildLeaderboard: () => [],
formatLap: (ms) => `${ms}ms`,
announcePassing: () => {},
+ triggerRaceAudio: (key) => audioEvents.push(key),
saveState: () => { saveCount += 1; },
lastOverlayLeaderKeyBySession: {},
lastOverlayTop3BySession: {},
@@ -52,7 +54,7 @@ function createHarness() {
transponder,
rtc_time: new Date(timestamp).toISOString(),
}, deps, role);
- return { state, session, passing, overlayEvents, getSaveCount: () => saveCount };
+ return { state, session, passing, overlayEvents, audioEvents, getSaveCount: () => saveCount };
}
test("split decoder records intermediate time without changing main lap state", () => {
@@ -93,6 +95,7 @@ test("pit entry and exit create one timed stop without adding laps", () => {
assert.equal(result.pitStops[0].transponder, "123");
assert.equal(result.pitStops[0].exitTransponder, "456");
assert.equal(result.passings.length, 1);
+ assert.deepEqual(harness.audioEvents, ["entering_pits", "leaving_pits"]);
});
test("connect decoder opens one websocket per enabled role", () => {
diff --git a/tests/race_audio.test.js b/tests/race_audio.test.js
new file mode 100644
index 0000000..6a22b9a
--- /dev/null
+++ b/tests/race_audio.test.js
@@ -0,0 +1,182 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "../src/race_audio.js";
+
+const manifest = {
+ events: { transponder_pass: { effect: "effects/transponder_pass.wav" }, start_tone: { effect: "effects/start_beep.wav" } },
+ voice_events: { sv: { drivers_ready: "Redo", start_sequence: "Start på signalen", track_call: "Stopp på banan" }, en: { drivers_ready: "Ready" } },
+};
+
+test("race audio settings normalize volume, delay, language and switches", () => {
+ const settings = normalizeRaceAudioSettings({ masterVolume: 4, effectsVolume: -1, language: "en", minStartDelay: 2, maxStartDelay: 1, events: { transponder_pass: { effect: false } } });
+ assert.equal(settings.masterVolume, 1);
+ assert.equal(settings.effectsVolume, 0);
+ assert.equal(settings.language, "en");
+ assert.equal(settings.minStartDelay, 2);
+ assert.equal(settings.maxStartDelay, 2);
+ assert.equal(settings.events.transponder_pass.effect, false);
+});
+
+test("missing custom passing sound never rejects and falls back safely", async () => {
+ const played = [];
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => manifest }),
+ audioFactory: (src) => ({ play: () => { played.push(src); if (src.includes("pass_01")) throw new Error("missing"); return Promise.resolve(); }, volume: 1 }),
+ getSettings: () => normalizeRaceAudioSettings(),
+ });
+ assert.equal(await engine.playEffect("transponder_pass", { effectPath: "pass_01.wav" }), true);
+ assert.deepEqual(played, ["/race-sounds/sounds/effects/pass_01.wav", "/race-sounds/sounds/effects/transponder_pass.wav"]);
+});
+
+test("critical control voice is prioritized over queued low-priority voice", async () => {
+ const resolvers = [];
+ const spoken = [];
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: { sv: { ...manifest.voice_events.sv, personal_best: "PB" } } }) }),
+ audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
+ speak: (text, options) => new Promise((resolve) => { spoken.push([text, options.interrupt]); resolvers.push(resolve); }),
+ getSettings: () => normalizeRaceAudioSettings(),
+ });
+ engine.trigger("personal_best");
+ while (spoken.length < 1) await new Promise((resolve) => setImmediate(resolve));
+ engine.trigger("track_call");
+ while (spoken.length < 2) await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(spoken.at(-1)[0], "Stopp på banan");
+ assert.equal(spoken.at(-1)[1], true);
+ resolvers.forEach((resolve) => resolve(true));
+});
+
+test("start lights, random delay and tone precede the official start callback", async () => {
+ const calls = [];
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => manifest }),
+ audioFactory: (src) => ({ play: () => { calls.push(["effect", src]); return Promise.resolve(); }, volume: 1 }),
+ speak: async (text) => { calls.push(["voice", text]); return true; },
+ wait: async (ms) => calls.push(["wait", ms]),
+ random: () => 0.5,
+ getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, lightIntervalMs: 300, minStartDelay: 1, maxStartDelay: 4 }),
+ });
+ const states = [];
+ engine.subscribeStartLights((state) => states.push(state));
+ await engine.runStartSequence({ id: "race", type: "final" }, { onTone: () => calls.push(["timing-start"]) });
+ assert.deepEqual(states, ["IDLE", "GRID", "READY", "LIGHT_1", "LIGHT_2", "LIGHT_3", "LIGHT_4", "LIGHT_5", "WAIT_RANDOM", "GO"]);
+ assert.equal(calls.at(-1)[0], "timing-start");
+ assert.deepEqual(calls.slice(-3), [["wait", 2500], ["effect", "/race-sounds/sounds/effects/start_beep.wav"], ["timing-start"]]);
+});
+
+test("cancelling a pending start prevents the official start callback", async () => {
+ let releaseDelay;
+ let started = false;
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => manifest }),
+ audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
+ speak: async () => true,
+ wait: () => new Promise((resolve) => { releaseDelay = resolve; }),
+ getSettings: () => normalizeRaceAudioSettings({ startLights: false, minStartDelay: 1, maxStartDelay: 1 }),
+ });
+ const pending = engine.runStartSequence({ id: "race" }, { onTone: () => { started = true; } });
+ while (!releaseDelay) await new Promise((resolve) => setImmediate(resolve));
+ engine.cancelStartSequence();
+ releaseDelay();
+ assert.equal(await pending, false);
+ assert.equal(started, false);
+});
+
+test("event catalog covers every supported race-audio event", () => {
+ const keys = new Set(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => item.key));
+ [
+ "grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race",
+ "transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished",
+ "track_call", "track_clear", "yellow_flag", "red_flag", "race_paused", "race_resumed", "marshal_warning", "marshal_required", "penalty", "stop_and_go", "drive_through", "disqualified",
+ "pit_lane_open", "pit_lane_closed", "entering_pits", "leaving_pits", "pit_speed_warning",
+ "qualifying_start", "qualifying_remaining", "qualifying_finished", "provisional_pole", "pole_position",
+ "race_winner", "second_place", "third_place", "podium", "heat_winner", "overall_winner",
+ "timing_connected", "timing_disconnected", "transponder_detected", "unknown_transponder", "audio_test",
+ ].forEach((key) => assert.equal(keys.has(key), true, `missing event: ${key}`));
+});
+
+test("voice follows the selected language and interpolates dynamic race data", async () => {
+ let language = "sv";
+ const spoken = [];
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: {
+ sv: { ...manifest.voice_events.sv, new_leader: "Ny ledare {driverName}" },
+ en: { ...manifest.voice_events.en, new_leader: "New leader {driverName}" },
+ } }) }),
+ audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
+ speak: async (text, options) => { spoken.push([text, options.language]); return true; },
+ getSettings: () => normalizeRaceAudioSettings({ language }),
+ });
+ await engine.trigger("new_leader", { variables: { driverName: "Alex" } }).voiceDone;
+ language = "en";
+ await engine.trigger("new_leader", { variables: { driverName: "Sam" }, force: true }).voiceDone;
+ assert.deepEqual(spoken, [["Ny ledare Alex", "sv"], ["New leader Sam", "en"]]);
+});
+
+test("simultaneous transponder pass effects overlap without entering the voice queue", async () => {
+ const releases = [];
+ const played = [];
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => manifest }),
+ audioFactory: (src) => ({ play: () => { played.push(src); return new Promise((resolve) => releases.push(resolve)); }, volume: 1 }),
+ getSettings: () => normalizeRaceAudioSettings(),
+ });
+ const first = engine.trigger("transponder_pass", { competitorKey: "one" });
+ const second = engine.trigger("transponder_pass", { competitorKey: "two" });
+ while (played.length < 2) await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(played.length, 2);
+ assert.equal(engine.getStatus().currentVoice, "");
+ releases.forEach((resolve) => resolve());
+ assert.deepEqual(await Promise.all([first.effectDone, second.effectDone]), [true, true]);
+});
+
+test("event cooldown debounces one scope without suppressing another competitor", async () => {
+ let timestamp = 1_000;
+ const logged = [];
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => manifest }),
+ audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
+ speak: async () => true,
+ now: () => timestamp,
+ log: (entry) => logged.push(entry),
+ getSettings: () => normalizeRaceAudioSettings(),
+ });
+ engine.trigger("personal_best", { driverId: "d1" });
+ timestamp += 100;
+ engine.trigger("personal_best", { driverId: "d1" });
+ engine.trigger("personal_best", { driverId: "d2" });
+ timestamp += 1_500;
+ engine.trigger("personal_best", { driverId: "d1" });
+ await Promise.resolve();
+ assert.deepEqual(logged.map((entry) => entry.event), ["personal_best", "personal_best", "personal_best"]);
+});
+
+test("fastest lap, track record and leader events use mapped effects and dynamic voice", async () => {
+ const played = [];
+ const spoken = [];
+ const completeManifest = {
+ ...manifest,
+ events: {
+ ...manifest.events,
+ fastest_lap: { effect: "effects/fastest.wav" },
+ track_record: { effect: "effects/record.wav" },
+ },
+ voice_events: { sv: {
+ ...manifest.voice_events.sv,
+ fastest_lap: "Snabbast {driverName} {lapTime}",
+ track_record: "Rekord {driverName} {lapTime}",
+ new_leader: "Ledare {driverName}",
+ } },
+ };
+ const engine = createRaceAudioEngine({
+ fetchImpl: async () => ({ ok: true, json: async () => completeManifest }),
+ audioFactory: (src) => ({ play: () => { played.push(src); return Promise.resolve(); }, volume: 1 }),
+ speak: async (text) => { spoken.push(text); return true; },
+ getSettings: () => normalizeRaceAudioSettings(),
+ });
+ for (const key of ["fastest_lap", "track_record", "new_leader"]) {
+ await engine.trigger(key, { variables: { driverName: "Kim", lapTime: "16.200" }, force: true }).voiceDone;
+ }
+ assert.deepEqual(played, ["/race-sounds/sounds/effects/fastest.wav", "/race-sounds/sounds/effects/record.wav"]);
+ assert.deepEqual(spoken, ["Snabbast Kim 16.200", "Rekord Kim 16.200", "Ledare Kim"]);
+});
diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js
index 45dae46..799cd2b 100644
--- a/tests/race_timing_logic.test.js
+++ b/tests/race_timing_logic.test.js
@@ -447,3 +447,13 @@ test("session channel statistics summarize splits and pit stops per competitor",
assert.equal(stats.competitors[0].bestPitStopMs, 9000);
assert.equal(stats.pitStops[1].driverName, "Driver Two");
});
+
+test("paused session timing freezes and resumes without counting the pause", () => {
+ const getSessionTargetMs = () => 300_000;
+ const paused = getSessionTiming({ startedAt: 1_000, pausedAt: 61_000, totalPausedMs: 0, followUpSec: 0 }, 121_000, { getSessionTargetMs });
+ assert.equal(paused.elapsedMs, 60_000);
+ assert.equal(paused.remainingMs, 240_000);
+ const resumed = getSessionTiming({ startedAt: 1_000, pausedAt: null, totalPausedMs: 60_000, followUpSec: 0 }, 121_000, { getSessionTargetMs });
+ assert.equal(resumed.elapsedMs, 60_000);
+ assert.equal(resumed.remainingMs, 240_000);
+});
diff --git a/tests/run.js b/tests/run.js
index cd4b878..b629880 100644
--- a/tests/run.js
+++ b/tests/run.js
@@ -4,6 +4,7 @@ import "./race_timing_logic.test.js";
import "./judging_logic.test.js";
import "./runtime_persistence.test.js";
import "./audio_runtime.test.js";
+import "./race_audio.test.js";
import "./decoder_runtime.test.js";
import "./csv_import.test.js";
import "./server_persistence.integration.test.js";
diff --git a/tests/runtime_persistence.test.js b/tests/runtime_persistence.test.js
index 829f3e4..0d1ae2f 100644
--- a/tests/runtime_persistence.test.js
+++ b/tests/runtime_persistence.test.js
@@ -57,6 +57,7 @@ test("backend hydration normalizes persisted entities and settings", () => {
events: [{ id: "event_1", name: "Race" }],
sessions: [{ id: "session_1", name: "Final" }],
resultsBySession: { session_1: { passings: [] } },
+ audioEventLog: [{ id: "audio_1", event: "race_start" }],
training: { goals: [{ id: "goal_1" }] },
activeSessionId: "session_1",
settings: {
@@ -100,6 +101,7 @@ test("backend hydration normalizes persisted entities and settings", () => {
assert.equal(state.sessions[0].normalized, true);
assert.equal(state.training.normalized, true);
assert.equal(state.activeSessionId, "session_1");
+ assert.deepEqual(state.audioEventLog, [{ id: "audio_1", event: "race_start" }]);
assert.equal(state.settings.theme, "light");
assert.equal(state.settings.splitDecoderEnabled, true);
assert.equal(state.settings.splitWsUrl, "ws://decoder:9001");
diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js
index def05fc..71efb75 100644
--- a/tests/server_persistence.integration.test.js
+++ b/tests/server_persistence.integration.test.js
@@ -87,6 +87,12 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
assert.equal(health.ok, true);
assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite"));
+ const soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`);
+ const soundManifest = await soundManifestResponse.json();
+ assert.equal(soundManifestResponse.status, 200);
+ assert.equal(soundManifest.events.start_tone.effect, "effects/start_beep.wav");
+ assert.equal(soundManifest.voice_events.sv.track_call, "Stopp på banan. Stopp på banan.");
+
const ammcConfigResponse = await fetch(`${baseUrl}/api/ammc/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },