diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 91a86f3..69be863 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -26,6 +26,7 @@ import '@gridfpv/components/tokens.css'; import { ToastHost, Dialog, Button, Field, Input, toast } from '@gridfpv/components'; import { Session } from './lib/session.svelte.js'; + import { mountRaceDayAudio } from './lib/raceDayAudio.svelte.js'; import ContextHeader from './ContextHeader.svelte'; import Breadcrumbs from './Breadcrumbs.svelte'; import Brand from './Brand.svelte'; @@ -55,6 +56,9 @@ } from './lib/route.js'; const session = new Session(); + // App-wide race-day audio: tones + lap callouts follow the live stream on EVERY page (the + // race must be audible while the RD is on rounds/marshaling/results, not just Live control). + mountRaceDayAudio(session); // Role seam (#55/#80): a `?role=readonly` query selects the read-only-pilot tier, which hides // every mutating control (the Marshaling actions, etc.). The Director is the enforced boundary; diff --git a/frontend/apps/rd-console/src/lib/callouts.ts b/frontend/apps/rd-console/src/lib/callouts.ts index b66477d..f24a01b 100644 --- a/frontend/apps/rd-console/src/lib/callouts.ts +++ b/frontend/apps/rd-console/src/lib/callouts.ts @@ -42,6 +42,16 @@ export interface CalloutSpeech { makeUtterance(text: string): SpeechUtteranceLike; } +/** + * The per-utterance WATCHDOG: if neither `onend` nor `onerror` fires within this window the + * utterance is declared dead and the queue advances. Chrome's speech engine is known to + * swallow an utterance silently — worst on its very first use after page load, while the TTS + * backend spins up — and with a serialized queue one dead utterance used to dam every + * subsequent callout (observed in the field as ~13s of race with no voice). A real lap + * callout speaks in 1–2s; 6s is comfortably past any legitimate utterance. + */ +export const UTTERANCE_WATCHDOG_MS = 6_000; + /** One queued callout: the spoken text plus an optional supersede key (see {@link CalloutQueue}). */ export interface Callout { text: string; @@ -77,6 +87,8 @@ export class CalloutQueue { /** The utterance currently being spoken — the serialization token (also guards a stale `onend` * from a cancelled utterance pumping a second, concurrent chain). */ #current: SpeechUtteranceLike | undefined; + /** The current utterance's dead-engine watchdog timer (see {@link UTTERANCE_WATCHDOG_MS}). */ + #watchdog: ReturnType | undefined; constructor(speech?: CalloutSpeech) { this.#speech = speech ?? platformSpeech(); @@ -114,6 +126,8 @@ export class CalloutQueue { cancel(): void { this.#queue = []; this.#current = undefined; + if (this.#watchdog !== undefined) clearTimeout(this.#watchdog); + this.#watchdog = undefined; try { this.#speech?.synth.cancel(); } catch { @@ -132,18 +146,51 @@ export class CalloutQueue { // A cancel() (or a newer utterance) may have superseded this one — a stale onend must not // pump a second, concurrent chain. if (this.#current !== utterance) return; + if (this.#watchdog !== undefined) clearTimeout(this.#watchdog); + this.#watchdog = undefined; this.#current = undefined; this.#pump(); }; utterance.onend = done; utterance.onerror = done; this.#current = utterance; + // The watchdog: a cold/buggy TTS engine can swallow an utterance without EVER calling + // back — declare it dead after the window, cancel the engine (unsticks Chrome), advance. + if (this.#watchdog !== undefined) clearTimeout(this.#watchdog); + this.#watchdog = setTimeout(() => { + if (this.#current !== utterance) return; + this.#watchdog = undefined; + this.#current = undefined; + try { + this.#speech?.synth.cancel(); + } catch { + /* unsticking a dead engine must never throw */ + } + this.#pump(); + }, UTTERANCE_WATCHDOG_MS); this.#speech.synth.speak(utterance); } catch { // The utterance never started — clear the token so the queue isn't wedged. this.#current = undefined; } } + + /** + * WARM UP the speech engine: speak a single space at volume 0 so the TTS backend loads its + * voices and spins up BEFORE the first real callout (the engine's first-ever utterance can + * take seconds — or die silently — while it initializes; see the watchdog). Called from the + * console's one-time audio-unlock gesture. A no-op where speech is unavailable; never throws. + */ + warmUp(): void { + if (!this.#speech || this.#current) return; + try { + const u = this.#speech.makeUtterance(' '); + (u as { volume?: number }).volume = 0; + this.#speech.synth.speak(u); + } catch { + /* warm-up is best-effort */ + } + } } /** Format a lap duration (µs) the way it is spoken: seconds to the hundredth, e.g. `"21.47"`. */ diff --git a/frontend/apps/rd-console/src/lib/raceDayAudio.svelte.ts b/frontend/apps/rd-console/src/lib/raceDayAudio.svelte.ts new file mode 100644 index 0000000..4cb2b97 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/raceDayAudio.svelte.ts @@ -0,0 +1,225 @@ +/** + * **App-wide race-day audio** — the tones + spoken lap callouts, hoisted OUT of the Live Race + * Control page so a running race is audible on EVERY page (marshaling, rounds, results, …). + * The RD stages a heat and walks over to the rounds screen; the start tone, crossing pips, + * callouts, end-of-race countdown, and buzzer must not care where the console is looking. + * + * One controller per console, mounted ONCE from `App.svelte` (component context — its + * `$effect`s need an owner) and shared with the pages through {@link raceDayAudio}. The Live + * page keeps its Callouts toggle + control-click unlocks; they drive this shared instance. + * + * Everything here consumes the app-wide `session.liveState` (the same stream the global + * header's clock uses on every in-event page) plus the open directory reads (heats, pilots, + * channels) it needs to resolve callsigns and the current round's tone/window config. The + * detection/scheduling logic itself is unchanged — `lapCallouts.svelte.ts` and + * `endTones.svelte.ts` are pure and were already page-agnostic; only their owner moved. + */ +import type { + ChannelCatalogEntry, + CompetitorRef, + HeatId, + HeatSummary, + Pilot, + PilotId, + PilotProgress, + RoundDef +} from '@gridfpv/types'; +import type { Session } from './session.svelte.js'; +import { RaceAudioPlayer } from './raceAudio.js'; +import { CalloutQueue, lapCalloutText } from './callouts.js'; +import { useEndOfRaceTones } from './endTones.svelte.js'; +import { useLapCallouts } from './lapCallouts.svelte.js'; +import { createCompetitorNameResolver } from './competitorName.js'; +import { channelLabel } from './channels.js'; +import { fixedEndWindowMicros } from './raceWindow.js'; + +/** The shared controller surface the pages use (the Live page's Callouts toggle). */ +export interface RaceDayAudio { + /** Whether the informational layer (pips + speech) is muted. Reactive. */ + readonly muted: boolean; + /** Toggle the informational mute (persisted); returns the new state. */ + toggleMuted(): boolean; + /** Resume/unlock the audio context — call from any RD gesture (Stage/Start clicks). */ + resume(): void; +} + +let controller: RaceDayAudio | undefined; + +/** A silent stand-in for contexts with no mounted controller (component unit tests render + * pages without the App shell; the real console always mounts in App.svelte). */ +const NOOP: RaceDayAudio = { + muted: false, + toggleMuted: () => false, + resume: () => {} +}; + +/** The mounted app-wide controller — or a silent no-op before/without `mountRaceDayAudio`. */ +export function raceDayAudio(): RaceDayAudio { + return controller ?? NOOP; +} + +/** + * Mount the app-wide audio controller. Call ONCE from `App.svelte`'s component setup — the + * internal `$effect`s bind to that component context and live for the app's lifetime. + */ +export function mountRaceDayAudio(session: Session): RaceDayAudio { + const audio = new RaceAudioPlayer(); + const callouts = new CalloutQueue(); + $effect(() => () => { + callouts.cancel(); + audio.dispose(); + }); + + // Autoplay unlock + speech warm-up: the RD's FIRST gesture anywhere resumes the + // AudioContext AND spins the TTS engine up (its first-ever utterance can take seconds — or + // die silently — while the backend initializes; warming here means the first real lap + // callout speaks promptly instead of ~10s into the race). + $effect(() => { + const unlock = () => { + void audio.resume(); + callouts.warmUp(); + }; + document.addEventListener('pointerdown', unlock, { once: true }); + return () => document.removeEventListener('pointerdown', unlock); + }); + + // ── The live essentials, straight off the app-wide stream ───────────────────────────────── + const live = $derived(session.liveState); + const phase = $derived(live?.phase); + const heat = $derived(live?.current_heat); + + // ── Directory reads (heats / pilots / channels), refreshed as the stream advances ───────── + // The same open-read pattern the Live page uses; errors leave the previous value standing + // (a resolver miss falls back to the raw ref, and the callout then SKIPS the name rather + // than speaking an id — the friendly-names rule). + let heats = $state([]); + let pilots = $state([]); + let catalog = $state([]); + $effect(() => { + void session.protocolState; + session + .listHeats() + .then((h) => (heats = h)) + .catch(() => {}); + session + .listPilots() + .then((p) => (pilots = p)) + .catch(() => {}); + }); + $effect(() => { + void session.currentEvent?.id; + session + .listChannels() + .then((c) => (catalog = c)) + .catch(() => {}); + }); + + // The current heat's round: the start-tone cue + the fixed-end window both live on it. + const currentRound = $derived.by(() => { + const summary = heats.find((h) => h.heat === heat); + const roundId = summary?.round; + if (!roundId) return undefined; + return session.currentEvent?.rounds?.find((r) => r.id === roundId); + }); + const toneCue = $derived(currentRound?.start_procedure?.tone); + const windowMicros = $derived(fixedEndWindowMicros(currentRound)); + + // The shared callsign resolver (friendly-names rule) — roster binding first, explicit + // register second, channel label for a bare node seat, raw ref last. + const pilotById = $derived(new Map(pilots.map((p) => [p.id, p]))); + const explicitPilotByRef = $derived( + new Map( + (live?.progress ?? []) + .filter((p): p is PilotProgress & { pilot: PilotId } => p.pilot != null) + .map((p) => [p.competitor, p.pilot]) + ) + ); + const channelByRef = $derived.by(() => { + const summary = heats.find((h) => h.heat === heat); + const map = new Map(); + for (const [ref, mhz] of summary?.frequencies ?? []) map.set(ref, channelLabel(mhz, catalog)); + return map; + }); + const competitorName = $derived.by<(ref: CompetitorRef) => string>(() => + createCompetitorNameResolver({ pilotById, explicitPilotByRef, channelByRef }) + ); + + // ── Start tone: fire on an OBSERVED transition into Running, never a late join ──────────── + // (Unchanged logic — see the original Live-page comment block. Hoisted here, "late join" + // now means the console APP loading onto an in-progress race, not a page navigation: moving + // between pages no longer remounts this controller, so navigating back to Live mid-race + // can never re-buzz.) + let toneFiredForHeat = $state(undefined); + let tonePreRunningForHeat = $state(undefined); + $effect(() => { + const p = phase; + const h = heat; + if (h === undefined) return; + if (p !== 'Running') { + if (p === 'Scheduled' || p === 'Staged' || p === 'Armed') tonePreRunningForHeat = h; + if (toneFiredForHeat !== h) toneFiredForHeat = undefined; + return; + } + if (toneFiredForHeat !== h && tonePreRunningForHeat === h) { + toneFiredForHeat = h; + audio.playStartTone(toneCue); + } + }); + + // ── End-of-race countdown + buzzer (procedure tones — always on) ────────────────────────── + useEndOfRaceTones( + () => phase, + () => heat, + () => live?.race_started_at, + () => windowMicros, + () => session.serverNowMs(), + { + onCountdown: () => audio.playCountdownBeep(), + onRaceEnd: () => audio.playRaceEndTone() + } + ); + + // ── Lap callouts (informational layer — mute-scoped) ────────────────────────────────────── + useLapCallouts( + () => phase, + () => heat, + () => live?.race_started_at, + () => live?.progress, + (crossing) => { + if (audio.muted) return; + audio.playCrossingBeep(); + const name = competitorName(crossing.ref); + callouts.enqueue({ + text: lapCalloutText( + name === crossing.ref ? undefined : name, + crossing.lap, + crossing.lastLapMicros + ), + key: crossing.ref + }); + } + ); + // A natural race end drains the queue; only a NEW run taking the stage drops the backlog. + $effect(() => { + if (phase === 'Scheduled' || phase === 'Staged' || phase === 'Armed') callouts.cancel(); + }); + + let muted = $state(audio.muted); + controller = { + get muted() { + return muted; + }, + toggleMuted() { + void audio.resume(); + callouts.warmUp(); + muted = audio.toggleMuted(); + if (muted) callouts.cancel(); + return muted; + }, + resume() { + void audio.resume(); + callouts.warmUp(); + } + }; + return controller; +} diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index 1a38e9a..3ae415b 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -49,10 +49,7 @@ import { useStagingClock, formatStaging } from '../lib/stagingClock.svelte.js'; import { useProtestClock, formatProtest } from '../lib/protestClock.svelte.js'; import { useArmingClock, formatArming } from '../lib/armingClock.svelte.js'; - import { RaceAudioPlayer } from '../lib/raceAudio.js'; - import { CalloutQueue, lapCalloutText } from '../lib/callouts.js'; - import { useEndOfRaceTones } from '../lib/endTones.svelte.js'; - import { useLapCallouts } from '../lib/lapCallouts.svelte.js'; + import { raceDayAudio } from '../lib/raceDayAudio.svelte.js'; import ConfirmButton from '../lib/ConfirmButton.svelte'; import ErrorBanner from '../lib/ErrorBanner.svelte'; @@ -280,8 +277,6 @@ }); // The round's staging window in seconds (default 5:00) — the staging countdown counts down from it. const stagingSecs = $derived(currentRound?.staging_timer_secs ?? 300); - // The round's start-tone cue (pitch/length), when configured; else the player's default tone. - const toneCue = $derived(currentRound?.start_procedure?.tone); // ── Open-practice per-channel board (open-practice Slice 2) ─────────────────────────────────── // The casual **open-practice** format runs one open heat over the active **channels**: its live @@ -408,68 +403,10 @@ () => session.serverNowMs() ); - // ── Race-day audio (procedure tones + informational callouts) ───────────────────────────────── - // One RaceAudioPlayer (Web-Audio tones) + one CalloutQueue (spoken lap callouts) per console. - // PROCEDURE tones — start tone, end-of-race countdown, race-end buzzer — are ALWAYS ON (the old - // "Tone on/off" toggle is gone; it was a testing convenience). The INFORMATIONAL layer — crossing - // pips + spoken callouts — is what the "Callouts" toggle mutes. - const audio = new RaceAudioPlayer(); - const callouts = new CalloutQueue(); - $effect(() => () => { - callouts.cancel(); - audio.dispose(); - }); - - // Autoplay unlock: with no tone toggle to click first, the RD's FIRST gesture anywhere on the - // page resumes the AudioContext (in addition to the Stage/Start handlers below), so the always-on - // procedure tones are never blocked by the browser autoplay policy. - $effect(() => { - const unlock = () => void audio.resume(); - document.addEventListener('pointerdown', unlock, { once: true }); - return () => document.removeEventListener('pointerdown', unlock); - }); - - // ── Start tone synced to race-go (heat-lifecycle Slice 3; robustness + late-join fix) ────────── - // A Web-Audio burst the moment a heat goes live (race-go). The runtime logs - // `HeatStarting { delay_ms }` then auto-appends the Running transition after the (hidden) random - // hold; the console plays the tone when the *live phase* turns Running. - // - // ── Fire on an OBSERVED transition into Running — not on a late join ────────────────────────── - // The tone is a race-go cue for the RD *watching* the heat go live. It must fire when the heat - // crosses **into** `Running` from a pre-Running phase the console actually saw this mount — i.e. - // a genuine race-go (Stage → Start → … → Running) or a fast/batched Armed → Running (we'd still - // have seen Staged/Armed first). It must **not** fire when the RD merely **navigates to the Live - // page of an already-running heat** (a late join): there the first phase the console observes for - // that heat *is* `Running`, with no prior pre-Running phase seen — an unwanted buzz on every - // navigation. So we only fire on `Running` if a pre-Running phase (`Scheduled`/`Staged`/`Armed`) - // was observed for this heat first; a heat seen Running as its first phase is suppressed. - // - // Per heat we track two things, both reset on a heat change: whether a pre-Running phase was seen - // (`tonePreRunningForHeat`), and whether the tone already fired (`toneFiredForHeat`, so repeated - // Running snapshots / progress updates don't re-fire). The next heat resets and fires its own. - let toneFiredForHeat = $state(undefined); - let tonePreRunningForHeat = $state(undefined); - $effect(() => { - const p = phase; - const h = heat; - if (h === undefined) return; - if (p !== 'Running') { - // A pre-Running phase for this heat (or a fold back out of Running): remember that we observed - // a non-Running phase first, and (on a heat change) clear the fired flag so the next heat that - // enters Running fires its own tone. We only *arm* on an actual pre-Running phase so a heat - // that started Running then folded back to Unofficial/Final doesn't arm a spurious tone. - if (p === 'Scheduled' || p === 'Staged' || p === 'Armed') tonePreRunningForHeat = h; - if (toneFiredForHeat !== h) toneFiredForHeat = undefined; - return; - } - // p === 'Running'. Fire once — but only if a pre-Running phase for THIS heat was observed first - // (a genuine race-go we watched), not when Running is the first phase seen (a late join / page - // load onto an in-progress heat). - if (toneFiredForHeat !== h && tonePreRunningForHeat === h) { - toneFiredForHeat = h; - audio.playStartTone(toneCue); - } - }); + // ── Race-day audio — APP-WIDE (see lib/raceDayAudio.svelte.ts) ──────────────────────────────── + // The tones + callouts controller is mounted once in App.svelte and follows the live stream on + // every page; this screen only exposes the Callouts toggle and unlocks audio on control clicks. + const audio = raceDayAudio(); // ── The known fixed race end (end-of-race tones + the countdown clock) ──────────────────────── // Only a heat whose end instant the clock already knows gets a countdown: a Timed win-condition @@ -485,68 +422,10 @@ windowMicros !== undefined ? windowMicros / 1000 - elapsedMs : undefined ); - // ── End-of-race countdown + buzzer (procedure tones — always on) ─────────────────────────────── - // At remaining 5…1s a short pip each second; at 0 the lower, longer race-end buzzer. The schedule - // derives from the SAME server-anchored clock the heat-time readout uses (`serverNowMs` against - // `race_started_at`) and fires each mark once per heat run — see endTones.svelte.ts. The buzzer - // marks the WINDOW end; grace-window laps still count after it (scoring untouched). - useEndOfRaceTones( - () => phase, - () => heat, - () => live?.race_started_at, - () => windowMicros, - () => session.serverNowMs(), - { - onCountdown: () => audio.playCountdownBeep(), - onRaceEnd: () => audio.playRaceEndTone() - } - ); - - // ── Lap callouts (informational layer — the "Callouts" toggle mutes these) ───────────────────── - // Each NEW lap on the current Running heat: an immediate crossing pip, then a queued spoken - // ", lap N, M.SS". Detection (once per count-increase per run, no ghost callouts from - // late joins / corrections / non-current heats) lives in lapCallouts.svelte.ts; the callsign - // resolves through the shared competitor-name resolver (friendly-names rule) — when even the - // resolver falls back to the raw ref, the callout skips the name rather than speaking an id. - // The ref keys the queue's per-pilot supersede: a pilot's next crossing replaces their still- - // waiting previous callout, so a pack burst never backs the voice up into narrating history - // (and the lap TIME is always spoken — see callouts.ts). - useLapCallouts( - () => phase, - () => heat, - () => live?.race_started_at, - () => live?.progress, - (crossing) => { - if (audio.muted) return; // the informational layer is mute-scoped - audio.playCrossingBeep(); - const name = competitorName(crossing.ref); - callouts.enqueue({ - text: lapCalloutText( - name === crossing.ref ? undefined : name, - crossing.lap, - crossing.lastLapMicros - ), - key: crossing.ref - }); - } - ); - // A natural race end (Running → Unofficial → Final) lets the queue DRAIN — the final laps' - // times are exactly what the RD and pilots are waiting to hear, and cancelling here used to - // chop the last callout mid-word at the buzzer. Only a *new run taking the stage* (the pre-run - // phases: the next heat scheduled/staged/armed, or this one restarted) drops the backlog — - // narrating a stale race over the next staging is noise. - $effect(() => { - if (phase === 'Scheduled' || phase === 'Staged' || phase === 'Armed') callouts.cancel(); - }); - let muted = $state(audio.muted); function toggleMute() { - // The toggle is itself a user gesture — unlock the audio context here too so an RD who only - // ever touches this button (never a transition) still gets audible tones. - void audio.resume(); + // The toggle is itself a user gesture — the controller unlocks + warms the engine too. muted = audio.toggleMuted(); - // Muting mid-race also drops the queued speech — silence means silence, immediately. - if (muted) callouts.cancel(); } // A live, provisional leaderboard from the running order + per-pilot progress, so the @@ -581,7 +460,7 @@ if (!heat) return; // Unlock the audio context on this user gesture (autoplay policy) so the later race-go tone is // audible — every transition click counts, well before the heat reaches Running. - void audio.resume(); + audio.resume(); const ack = await session.send(commandForAction(action, heat)); // Finalizing locks in the heat result; pull it so the Results screen has it to show. The // live stream only carries `LiveRaceState`, so the scored `HeatResult` is a separate diff --git a/frontend/apps/rd-console/tests/AudioHost.svelte b/frontend/apps/rd-console/tests/AudioHost.svelte new file mode 100644 index 0000000..819c03e --- /dev/null +++ b/frontend/apps/rd-console/tests/AudioHost.svelte @@ -0,0 +1,15 @@ + diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index cd06d85..657b07c 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -12,6 +12,7 @@ import type { } from '@gridfpv/types'; import LiveRaceControl from '../src/screens/LiveRaceControl.svelte'; import { makeTestSession } from './support.js'; +import AudioHost from './AudioHost.svelte'; import { liveRunning, failAck } from './fixtures.js'; // A round with a short staging window so the over-time path is reachable in a fake-timer test, and @@ -431,7 +432,8 @@ describe('LiveRaceControl', () => { const { started } = installAudioStub('running'); const { session, pushLive } = makeTestSession({ live: liveAt('Armed') }); - const { container } = render(LiveRaceControl, { session }); + const { container } = render(AudioHost, { session }); + render(LiveRaceControl, { session }); await tick(); expect(started).toHaveLength(0); // nothing plays while merely Armed @@ -452,6 +454,7 @@ describe('LiveRaceControl', () => { const { started } = installAudioStub('running'); const { session, pushLive } = makeTestSession({ live: liveAt('Running') }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await tick(); expect(started).toHaveLength(0); @@ -468,6 +471,7 @@ describe('LiveRaceControl', () => { const { started } = installAudioStub('running'); const { session, pushLive } = makeTestSession({ live: liveAt('Staged') }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await tick(); expect(started).toHaveLength(0); @@ -485,6 +489,7 @@ describe('LiveRaceControl', () => { // Start Staged (a pre-Running phase observed first) so the genuine race-go arms + fires once. const { session, pushLive } = makeTestSession({ live: liveAt('Staged') }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await tick(); pushLive(liveAt('Running')); @@ -506,6 +511,7 @@ describe('LiveRaceControl', () => { // heat-1 is watched through a genuine race-go (Staged → Running): its tone fires once. const { session, pushLive } = makeTestSession({ live: liveAt('Staged', 'heat-1') }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await tick(); pushLive(liveAt('Running', 'heat-1')); @@ -530,6 +536,7 @@ describe('LiveRaceControl', () => { // heat-1 watched Staged → Running (fires once). const { session, pushLive } = makeTestSession({ live: liveAt('Staged', 'heat-1') }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await tick(); pushLive(liveAt('Running', 'heat-1')); @@ -564,6 +571,7 @@ describe('LiveRaceControl', () => { const { started } = installAudioStub('running', { calloutsMuted: true }); const { session, pushLive } = makeTestSession({ live: liveAt('Staged') }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await tick(); // The toggle reads muted — and the race-go tone still fires. @@ -596,6 +604,7 @@ describe('LiveRaceControl', () => { live: liveAt('Staged'), listHeatsImpl: vi.fn(async () => [HEAT_IN_ROUND]) }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); // Let the heats/round directory settle so the Timed window resolves. await vi.advanceTimersByTimeAsync(0); @@ -640,6 +649,7 @@ describe('LiveRaceControl', () => { live: liveAt('Staged'), listHeatsImpl: vi.fn(async () => [HEAT_IN_ROUND]) }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await vi.advanceTimersByTimeAsync(0); await tick(); @@ -660,6 +670,7 @@ describe('LiveRaceControl', () => { live: liveAt('Staged'), listHeatsImpl: vi.fn(async () => [HEAT_IN_ROUND]) }); + render(AudioHost, { session }); render(LiveRaceControl, { session }); await vi.advanceTimersByTimeAsync(0); await tick(); @@ -703,6 +714,7 @@ describe('LiveRaceControl', () => { listHeatsImpl: vi.fn(async () => [{ ...HEAT_IN_ROUND, lineup: ['maverick-4d9rp8'] }]), listPilotsImpl: vi.fn(async () => CO_PILOTS as unknown as never) }); + render(AudioHost, { session: madeSession.session }); render(LiveRaceControl, { session: madeSession.session }); return { ...madeSession, ...audioStub, ...speech }; } diff --git a/frontend/apps/rd-console/tests/callouts.test.ts b/frontend/apps/rd-console/tests/callouts.test.ts index 17cca75..8cfd694 100644 --- a/frontend/apps/rd-console/tests/callouts.test.ts +++ b/frontend/apps/rd-console/tests/callouts.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from 'vitest'; import { CalloutQueue, + UTTERANCE_WATCHDOG_MS, formatLapSeconds, lapCalloutText, type CalloutSpeech, @@ -161,3 +162,36 @@ describe('lapCalloutText / formatLapSeconds', () => { expect(lapCalloutText('Goose', 1, undefined)).toBe('Goose, lap 1'); }); }); + +describe('the dead-utterance watchdog', () => { + it('advances the queue when the engine never calls back (the ~13s field stall)', () => { + vi.useFakeTimers(); + const { speech, spoken, pending, finish, cancelSpy } = makeFakeSpeech(); + const q = new CalloutQueue(speech); + q.enqueue({ text: 'first', key: 'a' }); // the engine swallows this one silently + q.enqueue({ text: 'second', key: 'b' }); + expect(spoken).toEqual(['first']); + // No onend/onerror ever fires; the watchdog declares it dead, unsticks the engine + // (cancel), and pumps the next entry. + vi.advanceTimersByTime(UTTERANCE_WATCHDOG_MS + 1); + expect(spoken).toEqual(['first', 'second']); + expect(cancelSpy).toHaveBeenCalled(); + // The healthy utterance completes normally and needs no watchdog help. + pending.shift(); // drop the dead one; finish() then completes 'second' + finish(); + q.enqueue({ text: 'third', key: 'c' }); + expect(spoken).toEqual(['first', 'second', 'third']); + vi.useRealTimers(); + }); + + it('warmUp speaks one silent utterance and never blocks the queue', () => { + const { speech, spoken, pending } = makeFakeSpeech(); + const q = new CalloutQueue(speech); + q.warmUp(); + expect(spoken).toEqual([' ']); + expect((pending[0] as { volume?: number }).volume).toBe(0); + // A real callout is unaffected (warm-up holds no serialization token). + q.enqueue({ text: 'real', key: 'a' }); + expect(spoken).toEqual([' ', 'real']); + }); +});