feat(audio): passing tone only during races and remove legacy audio

Adds 'Calls during a race' (default: passing tone only, like timing
systems) that drops name/personal best/leader/position calls in race
sessions; practice keeps them. Removes the legacy audio panel and
fallbacks, including its 'Sound on' switch that silently muted every
race-audio voice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-30 21:47:40 +02:00
parent 5bfb67ea02
commit aaca5e8c63
13 changed files with 108 additions and 205 deletions

View File

@@ -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);

View File

@@ -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");
}

View File

@@ -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({