Staggered-start qualifying heats now announce each car's transponder number in grid order (~3s apart) right after the official start, matching what a track announcer does when releasing cars one at a time; mass/position starts are unaffected. The Timing leaderboard and every overlay variant now also show a green "Done" flag next to a driver's name once their own personal timing window (which already started at their own first crossing) has elapsed, even while the session is still running for other drivers. Also confirmed two things already worked correctly: finals already always start everyone together with the shared countdown/lights/tone sequence, and practice's time-up announcement already says "finish the lap you are on" - only a "2 minutes remaining" cue is genuinely missing from the existing 1min/30s/10s ladder, left as an open question for the user. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
222 lines
12 KiB
JavaScript
222 lines
12 KiB
JavaScript
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.voiceRate, 1.08);
|
|
assert.equal(settings.voicePitch, 0.9);
|
|
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("staggered start mode calls out each car in grid order, 3s apart, after the official start", async () => {
|
|
const calls = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: { sv: { ...manifest.voice_events.sv, staggered_release: "Bil {tp}." } } }) }),
|
|
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]),
|
|
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 0, maxStartDelay: 0 }),
|
|
});
|
|
await engine.runStartSequence(
|
|
{ id: "q1h1", type: "qualification", startMode: "staggered" },
|
|
{ onTone: () => calls.push(["timing-start"]), staggerOrder: ["111", "222", "333"] }
|
|
);
|
|
const afterStart = calls.slice(calls.findIndex((call) => call[0] === "timing-start") + 1);
|
|
assert.deepEqual(afterStart, [
|
|
["voice", "Bil 111."], ["wait", 3000],
|
|
["voice", "Bil 222."], ["wait", 3000],
|
|
["voice", "Bil 333."],
|
|
]);
|
|
});
|
|
|
|
test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => {
|
|
const calls = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
|
audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
|
|
speak: async (text) => { calls.push(text); return true; },
|
|
wait: async () => {},
|
|
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 0, maxStartDelay: 0 }),
|
|
});
|
|
await engine.runStartSequence(
|
|
{ id: "final", type: "final", startMode: "mass" },
|
|
{ staggerOrder: ["111", "222"] }
|
|
);
|
|
assert.ok(!calls.some((text) => text.startsWith("Bil") || text.startsWith("Car")), "no per-car call-out for a mass start: " + JSON.stringify(calls));
|
|
});
|
|
|
|
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", "staggered_release",
|
|
"transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "training_stable_pace", "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"]);
|
|
});
|