glytch/fktry/src/ui/radio.ts
type-two d6d6833b78 [ui] THE TUNER DOCK — the radio gets a face
Hidden until the optical fossil is excavated, then it slides in bottom-right:
frequency dial (drag or arrow keys), AM/FM/SW, volume and mute, and a station
readout in the house voice that reads STATIC until something locks. Its keys
are inert while hidden so they never shadow the pre-discovery game.

src/audio is still a stub with no radio export, so radio.ts resolves the
control at runtime and otherwise drives a silent echo stand-in — which is why
every dial is functional and testable today rather than waiting.

LANE-SCREEN: publish window.__fktrySpeaker = { radio } and this drives real
audio with no change here. The interface is exactly the one you documented —
setFrequency / setBand / volume / mute / getState / subscribe — with getState()
returning {freqMHz, band, volume, muted, station}; a non-null station is what
flips the readout to SIGNAL LOCKED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:18 +10:00

81 lines
3.5 KiB
TypeScript

/**
* 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<Band, { min: number; max: number; step: number }> = {
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 };
}