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

@@ -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) => `<option value="${escapeHtml(voice.voiceURI)}" ${state.settings.speechVoiceUri === voice.voiceURI ? "selected" : ""}>${escapeHtml(`${voice.name} (${voice.lang})`)}</option>`)
.join("");
const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
const raceAudioEventRows = raceAudioEventDefinitions.map((definition) => `
<tr>
<td><strong>${escapeHtml(definition.key.replaceAll("_", " "))}</strong><small>${escapeHtml(definition.group)} · ${escapeHtml(definition.priority)}</small></td>
<td><input type="checkbox" name="raceAudioEffect_${definition.key}" form="settingsForm" ${raceAudio.events[definition.key]?.effect ? "checked" : ""} ${definition.effect ? "" : "disabled"} /></td>
<td><input type="checkbox" name="raceAudioVoice_${definition.key}" form="settingsForm" ${raceAudio.events[definition.key]?.voice ? "checked" : ""} ${definition.voice ? "" : "disabled"} /></td>
<td><button type="button" class="btn" data-test-race-audio="${definition.key}">${t("audio.test")}</button></td>
</tr>
`).join("");
dom.view.innerHTML = `
<section class="panel">
@@ -159,7 +171,28 @@ export function renderSettings(deps) {
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.audio")}</h3></div>
<div class="panel-header"><h3>${t("audio.race_sounds")}</h3></div>
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
<label class="toggle"><input type="checkbox" name="raceAudioEnabled" form="settingsForm" ${raceAudio.enabled ? "checked" : ""} /><span>${t("audio.enabled")}</span></label>
<label class="toggle"><input type="checkbox" name="raceAudioMuted" form="settingsForm" ${raceAudio.muted ? "checked" : ""} /><span>${t("audio.mute")}</span></label>
<label><span>${t("audio.language")}</span><select name="raceAudioLanguage" form="settingsForm"><option value="sv" ${raceAudio.language === "sv" ? "selected" : ""}>Svenska</option><option value="en" ${raceAudio.language === "en" ? "selected" : ""}>English</option></select></label>
<label><span>${t("audio.start_countdown")}</span><select name="raceAudioCountdown" form="settingsForm">${[0,10,30,60].map((value) => `<option value="${value}" ${raceAudio.startCountdownSeconds === value ? "selected" : ""}>${value ? `${value} s` : t("common.off")}</option>`).join("")}</select></label>
<label><span>${t("audio.master_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioMaster" form="settingsForm" value="${raceAudio.masterVolume}" /></label>
<label><span>${t("audio.effects_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioEffects" form="settingsForm" value="${raceAudio.effectsVolume}" /></label>
<label><span>${t("audio.voice_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioVoice" form="settingsForm" value="${raceAudio.voiceVolume}" /></label>
<label><span>${t("audio.passing_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioPassing" form="settingsForm" value="${raceAudio.passingVolume}" /></label>
<label class="toggle"><input type="checkbox" name="raceAudioStartLights" form="settingsForm" ${raceAudio.startLights ? "checked" : ""} /><span>${t("audio.start_lights")}</span></label>
<label class="toggle"><input type="checkbox" name="raceAudioRandomDelay" form="settingsForm" ${raceAudio.randomStartDelay ? "checked" : ""} /><span>${t("audio.random_delay")}</span></label>
<label><span>${t("audio.min_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMinDelay" form="settingsForm" value="${raceAudio.minStartDelay}" /></label>
<label><span>${t("audio.max_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMaxDelay" form="settingsForm" value="${raceAudio.maxStartDelay}" /></label>
<label class="toggle"><input type="checkbox" name="raceAudioPerCompetitor" form="settingsForm" ${raceAudio.perCompetitorPassingTone ? "checked" : ""} /><span>${t("audio.per_competitor")}</span></label>
<button id="settingsRaceAudioTest" class="btn" type="button">${t("audio.test")}</button>
</div>
<div class="panel-body"><p class="hint">${t("audio.note")}</p><div class="table-wrap"><table class="audio-event-table"><thead><tr><th>Event</th><th>${t("audio.effect")}</th><th>${t("audio.voice")}</th><th></th></tr></thead><tbody>${raceAudioEventRows}</tbody></table></div></div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.audio")} · Legacy</h3></div>
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
<label class="toggle">
<input type="checkbox" name="audioEnabled" form="settingsForm" ${state.settings.audioEnabled ? "checked" : ""} />
@@ -429,6 +462,28 @@ export function renderSettings(deps) {
</section>
`;
const applyRaceAudioFormValues = (form) => {
const events = {};
raceAudioEventDefinitions.forEach((definition) => {
events[definition.key] = {
effect: form.get("raceAudioEffect_" + definition.key) === "on",
voice: form.get("raceAudioVoice_" + definition.key) === "on",
};
});
state.settings.raceAudio = normalizeRaceAudioSettings({
...state.settings.raceAudio,
enabled: form.get("raceAudioEnabled") === "on",
muted: form.get("raceAudioMuted") === "on",
language: String(form.get("raceAudioLanguage") || "sv"),
masterVolume: Number(form.get("raceAudioMaster")), effectsVolume: Number(form.get("raceAudioEffects")),
voiceVolume: Number(form.get("raceAudioVoice")), passingVolume: Number(form.get("raceAudioPassing")),
startCountdownSeconds: Number(form.get("raceAudioCountdown")), startLights: form.get("raceAudioStartLights") === "on",
randomStartDelay: form.get("raceAudioRandomDelay") === "on", minStartDelay: Number(form.get("raceAudioMinDelay")),
maxStartDelay: Number(form.get("raceAudioMaxDelay")), perCompetitorPassingTone: form.get("raceAudioPerCompetitor") === "on",
events,
});
};
const applyAudioFormValues = () => {
const formNode = document.getElementById("settingsForm");
if (!(formNode instanceof HTMLFormElement)) return;
@@ -487,6 +542,8 @@ export function renderSettings(deps) {
state.settings.backendUrl = String(form.get("backendUrl") || "").trim();
state.settings.autoReconnect = form.get("autoReconnect") === "on";
applyAudioFormValues();
applyRaceAudioFormValues(form);
state.settings.audioEnabled = state.settings.audioEnabled || state.settings.raceAudio.enabled;
state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on";
state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on";
state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on";
@@ -504,6 +561,8 @@ export function renderSettings(deps) {
});
document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder);
document.getElementById("settingsRaceAudioTest")?.addEventListener("click", () => triggerRaceAudio("audio_test", { force: true, interrupt: true }));
document.querySelectorAll("[data-test-race-audio]").forEach((button) => button.addEventListener("click", () => triggerRaceAudio(button.dataset.testRaceAudio, { force: true, interrupt: true, variables: { driverName: state.drivers[0]?.name || "Driver", lapTime: "16.000", marshalPost: "1", transponder: "123", podium: "P1, P2, P3" } })));
document.getElementById("settingsTestStart")?.addEventListener("click", () => {
applyAudioFormValues();
if (state.settings.startSoundEnabled) playStartCue();