/** * LANE-UI — the bridge to THE SPEAKER's radio control. * * Order 9's tuner dock drives the audio engine through a narrow control that LANE-SCREEN * exports from `src/audio` (their documented API: setFrequency / setBand / volume / mute / * getState / subscribe). That export does not exist yet — `src/audio` is still a stub — so * this module resolves the control at runtime and, until it lands, drives a local echo * stand-in so every dial is fully functional and testable now. * * COORDINATION (see NOTES): I resolve the control from, in order, * 1. `window.__fktrySpeaker.radio` — the handshake I'm proposing (SCREEN already keeps * a `window.__fktryScreen` debug hook, so this is idiomatic for them), or * 2. a `radio` / `getRadio()` export from `../audio` (dynamic, cast — never breaks the * build if absent). * SCREEN: set one of those to a RadioControl and the dock drives real audio with zero * change here. Tell me in NOTES which name you pick. * * The stand-in is silent — it only echoes state back through `subscribe` so the readout is * honest about what the dock is asking for, even with no engine attached. */ export type Band = 'AM' | 'FM' | 'SW'; export interface RadioState { freqMHz: number; band: Band; volume: number; // 0..1 muted: boolean; /** Station name if the current frequency locks one, else null (static). */ station: string | null; } export interface RadioControl { setFrequency(mhz: number): void; setBand(band: Band): void; volume(v: number): void; mute(on: boolean): void; getState(): RadioState; subscribe(cb: (s: RadioState) => void): () => void; } /** Band frequency ranges (MHz-ish; SW/AM are scaled for a single readable dial). */ export const BAND_RANGE: Record = { AM: { min: 0.53, max: 1.7, step: 0.01 }, FM: { min: 88.0, max: 108.0, step: 0.1 }, SW: { min: 5.9, max: 26.1, step: 0.05 }, }; /** * Local echo stand-in. Holds state, notifies subscribers, locks no stations (the real * station map is SCREEN's). Keeps the dock alive before the engine exists. */ export function createEchoRadio(): RadioControl { let state: RadioState = { freqMHz: BAND_RANGE.FM.min, band: 'FM', volume: 0.7, muted: false, station: null }; const subs = new Set<(s: RadioState) => void>(); const notify = () => { for (const cb of subs) cb(state); }; return { setFrequency(mhz) { state = { ...state, freqMHz: mhz }; notify(); }, setBand(band) { state = { ...state, band, freqMHz: BAND_RANGE[band].min }; notify(); }, volume(v) { state = { ...state, volume: Math.max(0, Math.min(1, v)) }; notify(); }, mute(on) { state = { ...state, muted: on }; notify(); }, getState: () => state, subscribe(cb) { subs.add(cb); cb(state); return () => subs.delete(cb); }, }; } /** Resolve the real control if THE SPEAKER has published one; else the echo stand-in. */ export function resolveRadio(): { control: RadioControl; live: boolean } { const speaker = (globalThis as any).__fktrySpeaker; if (speaker?.radio && typeof speaker.radio.subscribe === 'function') { return { control: speaker.radio as RadioControl, live: true }; } // Dynamic, casted probe of the audio module — safe whether or not the export exists. const audioMod = (globalThis as any).__fktryAudioModule; const fromMod = audioMod?.radio ?? audioMod?.getRadio?.(); if (fromMod && typeof fromMod.subscribe === 'function') { return { control: fromMod as RadioControl, live: true }; } return { control: createEchoRadio(), live: false }; }