Add configurable manifest-driven race audio system
This commit is contained in:
182
tests/race_audio.test.js
Normal file
182
tests/race_audio.test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
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.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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user