Add configurable manifest-driven race audio system

This commit is contained in:
larssand
2026-08-28 13:29:27 +02:00
parent 3f3b410869
commit 5f79228941
22 changed files with 1133 additions and 109 deletions

View File

@@ -262,7 +262,7 @@ export function renderTimingView(deps) {
getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings,
getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid,
connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap,
ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView,
ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, startSessionWithAudio, cancelRaceStart, triggerRaceAudio, getStartLightState, saveState, updateHeaderState, renderView,
normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey,
getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings,
getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, renderSessionChannelAnalysis,
@@ -353,6 +353,10 @@ export function renderTimingView(deps) {
<button id="stopSession" class="btn">${t("timing.stop")}</button>
<button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button>
</div>
<div id="raceStartLights" class="race-start-lights" data-state="${escapeHtml(getStartLightState())}">
${[1, 2, 3, 4, 5].map((light) => `<span data-start-light="${light}"></span>`).join("")}
<strong>${escapeHtml(getStartLightState())}</strong>
</div>
</div>
</div>
<div class="panel-body timing-session-summary">
@@ -392,6 +396,15 @@ export function renderTimingView(deps) {
<div class="panel-header"><h3>${t("timing.speaker_panel")}</h3></div>
<div class="panel-body">
<p class="hint">${t("timing.speaker_panel_hint")}</p>
<div class="race-control-cues">
<button class="btn btn-danger" type="button" data-race-audio-cue="track_call">${t("audio.track_call")}</button>
<button class="btn" type="button" data-race-audio-cue="track_clear">${t("audio.track_clear")}</button>
<button class="btn btn-danger" type="button" data-race-audio-cue="red_flag">${t("audio.red_flag")}</button>
<button class="btn" type="button" data-race-audio-cue="race_paused">${t("audio.race_pause")}</button>
<button class="btn" type="button" data-race-audio-cue="race_resumed">${t("audio.race_resume")}</button>
<button class="btn" type="button" data-race-audio-cue="false_start">${t("audio.false_start")}</button>
<button class="btn" type="button" data-race-audio-cue="marshal_warning">${t("audio.marshal_warning")}</button>
</div>
<div class="check-grid">
${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;