Files
Live_RC/tests/race_audio.test.js
2026-08-28 13:47:25 +02:00

185 lines
9.8 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("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",
"transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "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"]);
});