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", "warmup_start", "warmup_1_minute", "warmup_end", "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"]); });