${t("dashboard.schedule_drift")}
@@ -228,9 +224,6 @@ export function renderDashboardView(deps) {
document.getElementById("dashboardTestAudio")?.addEventListener("click", () => {
ensureAudioContext();
playPassingBeep();
- if (state.settings.finishVoiceEnabled) {
- setTimeout(playFinishSiren, 220);
- }
});
}
diff --git a/src/race_audio.js b/src/race_audio.js
index fb4bf23..617e4ea 100644
--- a/src/race_audio.js
+++ b/src/race_audio.js
@@ -84,11 +84,25 @@ export function normalizeRaceAudioSettings(value = {}) {
minStartDelay,
maxStartDelay,
perCompetitorPassingTone: source.perCompetitorPassingTone === true,
+ raceCallouts: source.raceCallouts === "full" ? "full" : "passing",
events,
templates: source.templates && typeof source.templates === "object" ? { ...source.templates } : {},
};
}
+// During a race, timing systems give each passing its tone and nothing more: no names, no
+// "personal best" / "new leader" calls per lap. raceCallouts "passing" (the default) keeps it
+// that way for race sessions; "full" is the speaker mode. Practice always gets the full set,
+// and the clock, start, final-lap and results calls are never affected.
+export const RACE_PASSING_CALLOUT_EVENTS = Object.freeze(["personal_best", "fastest_lap", "track_record", "new_leader", "position_gain", "position_loss", "provisional_pole"]);
+const PRACTICE_TYPES = new Set(["open_practice", "free_practice", "practice"]);
+
+export function isSilencedDuringRace(eventKey, settings, session) {
+ if (!session || PRACTICE_TYPES.has(String(session.type || ""))) return false;
+ if (normalizeRaceAudioSettings(settings).raceCallouts === "full") return false;
+ return RACE_PASSING_CALLOUT_EVENTS.includes(eventKey);
+}
+
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) {
diff --git a/src/runtime_services.js b/src/runtime_services.js
index 14d7160..ad6ebe7 100644
--- a/src/runtime_services.js
+++ b/src/runtime_services.js
@@ -361,14 +361,8 @@ export function selectSpeechVoice(voices, voiceUri, language) {
|| null;
}
-export function getSessionAnnouncementText(session, phase, { t }) {
- const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || "").toLowerCase());
- if (phase === "start") return t(practice ? "timing.practice_started_voice" : "timing.race_started_voice");
- return t(practice ? "timing.practice_finished_voice" : "timing.race_finished_voice");
-}
-
export function speakTextHelper(text, { state, currentLanguage }, options = {}) {
- if (!state.settings.audioEnabled || !text) return false;
+ if (!text) return false;
if (state.settings.ttsEngine === "piper" && !options.skipPiper) {
const piperLanguage = String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en";
return speakWithPiper(text, {
@@ -402,35 +396,15 @@ export function speakTextHelper(text, { state, currentLanguage }, options = {})
return true;
}
-export function announcePassingHelper(entry, { state, t, playPassingBeep, speakText }) {
- if (!state.settings.audioEnabled) return;
- if (state.settings.passingSoundMode === "beep") { playPassingBeep(); return; }
- if (state.settings.passingSoundMode === "name") {
- const spoken = speakText(entry?.speechName || entry?.displayName || entry?.driverName || t("common.unknown_driver"), { interrupt: false });
- if (!spoken) playPassingBeep();
- }
-}
-
-export function announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }) {
+// Race audio (race_audio.js) owns every start/finish sound; these only log the overlay event.
+export function announceRaceStartedHelper(session, { t, pushOverlayEvent }) {
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) {
- setTimeout(() => speakText(getSessionAnnouncementText(session, "start", { t }), { interrupt: true }), state.settings.startSoundEnabled === false ? 0 : 650);
- }
}
-export function announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText }) {
+export function announceRaceFinishedHelper({ t, getActiveSession, pushOverlayEvent }) {
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) {
- setTimeout(() => speakText(getSessionAnnouncementText(session, "finish", { t }), { interrupt: true }), state.settings.finishSoundEnabled === false ? 0 : 1150);
- }
}
export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId, announceRaceFinished, announceRaceClockEvent = () => {} }) {
diff --git a/src/settings.js b/src/settings.js
index 11f0d93..a30a64f 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -98,9 +98,6 @@ export function renderSettings(deps) {
const running = Boolean(ammcStatus.running);
const speechVoices = getSpeechVoices();
const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
- const speechVoiceOptions = speechVoices
- .map((voice) => ``)
- .join("");
const raceAudioVoiceOptions = speechVoices
.map((voice) => ``)
.join("");
@@ -243,6 +240,7 @@ export function renderSettings(deps) {
@@ -668,6 +594,7 @@ Erik Eriksson;654321;4WD;Associated;;
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",
+ raceCallouts: String(form.get("raceAudioRaceCallouts") || "passing"),
events,
});
};
@@ -676,7 +603,6 @@ Erik Eriksson;654321;4WD;Associated;;
const formNode = document.getElementById("settingsForm");
if (!(formNode instanceof HTMLFormElement)) return false;
applyRaceAudioFormValues(new FormData(formNode));
- state.settings.audioEnabled = state.settings.audioEnabled || state.settings.raceAudio.enabled;
return true;
};
@@ -686,19 +612,6 @@ Erik Eriksson;654321;4WD;Associated;;
return true;
};
- const applyAudioFormValues = () => {
- const formNode = document.getElementById("settingsForm");
- if (!(formNode instanceof HTMLFormElement)) return;
- const form = new FormData(formNode);
- state.settings.audioEnabled = form.get("audioEnabled") === "on";
- state.settings.passingSoundMode = String(form.get("passingSoundMode") || "beep");
- state.settings.finishVoiceEnabled = form.get("finishVoiceEnabled") === "on";
- state.settings.startSoundEnabled = form.get("startSoundEnabled") === "on";
- state.settings.finishSoundEnabled = form.get("finishSoundEnabled") === "on";
- state.settings.speechVoiceUri = String(form.get("speechVoiceUri") || "");
- state.settings.speechRate = Math.max(0.65, Math.min(1.35, Number(form.get("speechRate") || 0.95) || 0.95));
- state.settings.speechPitch = Math.max(0.7, Math.min(1.3, Number(form.get("speechPitch") || 1) || 1));
- };
const splitDecoderToggle = document.getElementById("settingsSplitEnabled");
const pitDecoderToggle = document.getElementById("settingsPitEnabled");
@@ -743,15 +656,7 @@ Erik Eriksson;654321;4WD;Associated;;
state.settings.pitExitWsUrl = String(form.get("pitExitWsUrl") || "").trim();
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";
- state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on";
- state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on";
- state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on";
state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB";
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Timing & Race Control";
state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by RC RaceCore";
@@ -781,25 +686,6 @@ Erik Eriksson;654321;4WD;Associated;;
};
document.getElementById("settingsRaceAudioTest")?.addEventListener("click", () => testRaceAudio("audio_test"));
document.querySelectorAll("[data-test-race-audio]").forEach((button) => button.addEventListener("click", () => testRaceAudio(button.dataset.testRaceAudio)));
- document.getElementById("settingsTestStart")?.addEventListener("click", () => {
- applyAudioFormValues();
- if (state.settings.startSoundEnabled) playStartCue();
- if (state.settings.finishVoiceEnabled) {
- setTimeout(() => speakText(t("timing.race_started_voice"), { interrupt: true }), state.settings.startSoundEnabled ? 650 : 0);
- }
- });
- document.getElementById("settingsTestFinish")?.addEventListener("click", () => {
- applyAudioFormValues();
- if (state.settings.finishSoundEnabled) playFinishSiren();
- if (state.settings.finishVoiceEnabled) {
- setTimeout(() => speakText(t("timing.race_finished_voice"), { interrupt: true }), state.settings.finishSoundEnabled ? 1150 : 0);
- }
- });
- document.getElementById("settingsTestDriverVoice")?.addEventListener("click", () => {
- applyAudioFormValues();
- const driver = state.drivers[0];
- speakText(driver?.speechName || driver?.name || t("settings.driver_voice_sample"), { interrupt: true });
- });
document.getElementById("settingsLaunchObs")?.addEventListener("click", () => openOverlayWindow("obs", { public: true }));
document.getElementById("settingsCopyObsUrl")?.addEventListener("click", async () => {
const url = buildOverlayUrl("obs", { public: true });
diff --git a/tests/audio_runtime.test.js b/tests/audio_runtime.test.js
index b764460..71cb2c1 100644
--- a/tests/audio_runtime.test.js
+++ b/tests/audio_runtime.test.js
@@ -2,9 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
- announcePassingHelper,
announceRaceStartedHelper,
- getSessionAnnouncementText,
handleSessionTimerTickHelper,
selectSpeechVoice,
speakTextHelper,
@@ -21,48 +19,21 @@ test("speech voice selection prefers an explicit voice and then the current lang
assert.equal(selectSpeechVoice(voices, "missing", "de-DE"), null);
});
-test("session announcements distinguish practice from racing", () => {
- const t = (key) => key;
- assert.equal(getSessionAnnouncementText({ type: "open_practice" }, "start", { t }), "timing.practice_started_voice");
- assert.equal(getSessionAnnouncementText({ type: "practice" }, "finish", { t }), "timing.practice_finished_voice");
- assert.equal(getSessionAnnouncementText({ type: "final" }, "start", { t }), "timing.race_started_voice");
- assert.equal(getSessionAnnouncementText({ type: "heat" }, "finish", { t }), "timing.race_finished_voice");
-});
-
-test("passing announcement uses the driver's spoken name and beeps while speech is busy", () => {
- const calls = [];
- announcePassingHelper(
- { displayName: "Anders", speechName: "Ann-ders" },
- {
- state: { settings: { audioEnabled: true, passingSoundMode: "name" } },
- t: () => "Unknown",
- speakText: (value, options) => { calls.push([value, options]); return false; },
- playPassingBeep: () => calls.push(["beep"]),
- }
- );
-
- assert.deepEqual(calls, [["Ann-ders", { interrupt: false }], ["beep"]]);
-});
-
-test("race start creates an overlay event and plays enabled start cues", async () => {
+test("race start only creates the overlay event - the old start tone and voice are gone (race audio owns the start)", async () => {
const calls = [];
announceRaceStartedHelper(
{ id: "session_1", name: "A Final", type: "final" },
{
- state: { settings: { audioEnabled: true, startSoundEnabled: true, finishVoiceEnabled: true } },
+ // Old saved settings from the removed legacy panel must not bring the extra cues back.
+ state: { settings: { audioEnabled: true, startSoundEnabled: true, finishVoiceEnabled: true, raceAudio: { enabled: false } } },
t: (key) => key,
pushOverlayEvent: (type, label, options) => calls.push([type, label, options]),
playStartCue: () => calls.push(["tone"]),
speakText: (value, options) => calls.push(["speech", value, options]),
}
);
-
- assert.deepEqual(calls, [
- ["start", "A Final • timing.start", { silentCue: true }],
- ["tone"],
- ]);
await new Promise((resolve) => setTimeout(resolve, 700));
- assert.deepEqual(calls[2], ["speech", "timing.race_started_voice", { interrupt: true }]);
+ assert.deepEqual(calls, [["start", "A Final • timing.start", { silentCue: true }]]);
});
test("session timer emits each race-audio clock marker once without changing finish rules", () => {
@@ -111,8 +82,10 @@ test("race audio can ignore a mismatched configured voice and select the announc
constructor(text) { this.text = text; }
};
try {
+ // audioEnabled false: the removed legacy "Sound on" switch used to silence every race-audio
+ // voice (while effects still played). It must no longer have any effect.
const started = speakTextHelper("Race finished", {
- state: { settings: { audioEnabled: true, speechVoiceUri: "voice-sv", speechRate: 1, speechPitch: 1 } },
+ state: { settings: { audioEnabled: false, speechVoiceUri: "voice-sv", speechRate: 1, speechPitch: 1 } },
currentLanguage: () => "sv",
}, { language: "en", voiceUri: "", rate: 1.1, pitch: 0.85 });
assert.equal(started, true);
diff --git a/tests/browser_settings_tabs.js b/tests/browser_settings_tabs.js
index aecf3e8..95ffc99 100644
--- a/tests/browser_settings_tabs.js
+++ b/tests/browser_settings_tabs.js
@@ -43,4 +43,17 @@ export async function runSettingsTabsSmoke({ evaluate, navigate }) {
assert.equal(visible.system, true);
assert.equal(visible.connection, false);
console.log("PASS the active settings tab is preserved across a save-triggered re-render");
+
+ // The old "Audio · Legacy" panel is gone; "Calls during a race" defaults to passing tone
+ // only and survives a save.
+ await evaluate(`document.querySelector('[data-settings-tab="audio"]').click()`);
+ assert.equal(await evaluate(`/Legacy/.test(document.querySelector('[data-settings-panel="audio"]').innerText)`), false, "no legacy audio panel");
+ assert.equal(await evaluate(`Boolean(document.querySelector('[name="audioEnabled"], [name="passingSoundMode"]'))`), false, "no legacy sound switches");
+ const callouts = () => evaluate(`document.querySelector('[name="raceAudioRaceCallouts"]').value`);
+ assert.equal(await callouts(), "passing");
+ await evaluate(`(() => { document.querySelector('[name="raceAudioRaceCallouts"]').value = "full"; document.getElementById("settingsForm").requestSubmit(); })()`);
+ assert.equal(await callouts(), "full", "speaker mode is saved");
+ await evaluate(`(() => { document.querySelector('[name="raceAudioRaceCallouts"]').value = "passing"; document.getElementById("settingsForm").requestSubmit(); })()`);
+ assert.equal(await callouts(), "passing");
+ console.log("PASS the legacy audio panel is gone and race calls default to passing tone only");
}
diff --git a/tests/race_audio.test.js b/tests/race_audio.test.js
index 001e860..7ed52ee 100644
--- a/tests/race_audio.test.js
+++ b/tests/race_audio.test.js
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "../src/race_audio.js";
+import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings, isSilencedDuringRace } from "../src/race_audio.js";
const manifest = {
events: { transponder_pass: { effect: "effects/transponder_pass.wav" }, start_tone: { effect: "effects/start_beep.wav" } },
@@ -115,6 +115,20 @@ test("after the prologue a grid start skips its own count-down and intro calls",
assert.deepEqual(calls, [["wait", 2000], ["effect", "start_beep.wav"], ["timing-start"]], "only the 0-5 s delay and the horn are left");
});
+test("during a race only the passing tone plays by default - no names, personal bests or leader calls", () => {
+ const race = { type: "heat" };
+ assert.equal(normalizeRaceAudioSettings({}).raceCallouts, "passing");
+ ["personal_best", "fastest_lap", "track_record", "new_leader", "position_gain", "position_loss"].forEach((key) => {
+ assert.equal(isSilencedDuringRace(key, {}, race), true, key);
+ });
+ ["transponder_pass", "final_lap", "one_minute_remaining", "race_finished", "race_winner", "clock_running"].forEach((key) => {
+ assert.equal(isSilencedDuringRace(key, {}, race), false, `${key} is not a per-passing call`);
+ });
+ assert.equal(isSilencedDuringRace("personal_best", { raceCallouts: "full" }, race), false, "speaker mode keeps the calls");
+ assert.equal(isSilencedDuringRace("personal_best", {}, { type: "open_practice" }), false, "practice keeps the calls");
+ assert.equal(isSilencedDuringRace("personal_best", {}, null), false);
+});
+
test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => {
const calls = [];
const engine = createRaceAudioEngine({