Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/apps/rd-console/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions frontend/apps/rd-console/src/lib/callouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof setTimeout> | undefined;

constructor(speech?: CalloutSpeech) {
this.#speech = speech ?? platformSpeech();
Expand Down Expand Up @@ -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 {
Expand All @@ -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"`. */
Expand Down
225 changes: 225 additions & 0 deletions frontend/apps/rd-console/src/lib/raceDayAudio.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<HeatSummary[]>([]);
let pilots = $state<Pilot[]>([]);
let catalog = $state<ChannelCatalogEntry[]>([]);
$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<RoundDef | undefined>(() => {
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<PilotId, Pilot>(pilots.map((p) => [p.id, p])));
const explicitPilotByRef = $derived(
new Map<CompetitorRef, PilotId>(
(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<CompetitorRef, string>();
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<HeatId | undefined>(undefined);
let tonePreRunningForHeat = $state<HeatId | undefined>(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;
}
Loading