radio / src /embed.ts
tfrere's picture
tfrere HF Staff
feat(audio): detect WebKit's silent createMediaElementSource capture
9a390b8
Raw History Blame Contribute Delete
94.7 kB
/**
* Reachy Mini Radio - embed entry.
*
* Runs INSIDE the host iframe once `connectToHost()` resolves (SDK loaded,
* WebRTC session up, robot awake). A JS-SDK port of the Python
* `reachy_mini_radio` app, keeping its *physical* soul:
*
* - Both antennas are BY-HAND controls, resting in a ~45Β° "V" pointing up so
* they never cross. The LEFT antenna (positive angles, leans left) tunes
* the radio; the RIGHT antenna (negative angles, leans right) sets the
* volume. Each sweeps a gentle range on its own side of vertical. On unlock
* we glide both to the rest V, then drop their torque and read their angles
* back (`requestState()` + `robotState.antennas`, ordered [right, left]) to
* drive tuning + volume. Static between stations, a station fades in when
* you land on it (same gain curve as the Python app).
* - Reachy is the speaker: the mixed audio is pushed onto the robot's
* outbound WebRTC audio sender (radio-audio.ts). Falls back to the local
* device when the robot has no audio sender.
* - The head + body stay powered and gently bob to the music, streamed via
* `setTarget` at ~20 Hz. The on-screen dial + volume knob mirror the
* antennas; dragging one holds and commands its antenna, then releases it.
*/
import {
connectToHost,
type ConnectedHandle,
} from "@pollen-robotics/reachy-mini-sdk/host/embed";
import { rpyToMatrix, degToRad } from "@pollen-robotics/reachy-mini-sdk";
import {
type Station,
type SearchResult,
loadStations,
saveStations,
resetToDefaults,
loadSavedTuning,
saveTuning,
clearSavedTuning,
stationPositions,
stationDescription,
TUNE_ARC_MIN,
TUNE_ARC_MAX,
MAX_STATIONS,
searchStations,
probeCors,
} from "./stations";
import { renderChassisHead } from "./chassis-head";
import { openOverlay, closeOverlay } from "./overlay";
import { createDropdown, type Dropdown } from "./dropdown";
import { TuningModel, type TuningFrame } from "./tuning";
import { RadioAudio } from "./radio-audio";
import { NowPlayingWatcher } from "./now-playing";
import { springTo } from "./spring";
import { initTooltips } from "./tooltip";
// Seismograph between the icon's antennas, in the illustration's own viewBox
// units (0..2048). It sits level with the antenna tips, measured at y 98, where
// the two arms splay outward and leave the gap at its widest.
//
// A scrolling history, not the instantaneous waveform: reading a fresh window
// every frame samples an arbitrary phase, which makes the shape jump sideways 60
// times a second. Here each slot is a past reading that never moves again, so the
// line can only drift, and it shows the dynamics of the music rather than 5ms of
// it.
//
// One profile, mirrored about the baseline, drawn as a closed outline. A carrier
// oscillating about the line would need a wavelength, and at ~56px wide there is
// no honest one to pick: the real waveform packs more cycles than a 2px stroke can
// separate. Reading it as a profile instead makes both axes mean something, x the
// last half second of time and y the energy, and it rests shut between events,
// which is what lets an event read as an event.
const TRACE_POINTS = 30;
const TRACE_X0 = 610;
const TRACE_X1 = 1380;
const TRACE_Y = 130;
/**
* How often a reading enters the trace: every display frame at 60Hz.
*
* A period that is not a whole number of frames lands on two frames then three,
* and that uneven drift on its own reads as lag however quick the response is.
*
* Constants below expressed per reading fall into two families, and the split
* matters if this ever changes. Those shaping the drawing, the envelope and the
* serration and the window, are in slots and hold. Those standing for a duration,
* the auto-range creep here and the flux averaging in radio-audio.ts, have to be
* rescaled or their real time constant moves with the frame rate.
*/
const TRACE_STEP_MS = 16;
/**
* Peak height either side of the baseline, on the loudest passage of the stream.
* Half what a one-sided profile would need, since the eye reads the gap between
* the two crests.
*/
const TRACE_SWING = 65;
/** Height an attack adds on top of the level. This is what lands on the beat. */
const TRACE_KICK = 45;
/**
* Gamma on the auto-ranged level. Without it the line sits at a mid plateau for
* the whole track, since music rarely visits either end of its own range; the
* curve pushes the ordinary down towards the baseline and keeps the height for
* what stands out.
*/
const TRACE_CONTRAST = 2.2;
/**
* Asymmetric envelope, as on a real meter: the rise is not filtered at all, the
* fall takes about a quarter of a second.
*
* Since the history scrolls, that asymmetry is also what draws the shape: a hit
* lands as a wall and trails off behind it, rather than as a one-slot blip two
* pixels wide that the spline would round away.
*/
const TRACE_ATTACK = 1;
const TRACE_RELEASE = 0.08;
/**
* The level is auto-ranged rather than mapped through fixed bounds, because its
* scale depends on the station's mastering and a band picked for one stream pins
* the trace flat on the next. The bounds creep by this fraction per reading, so
* the window crosses its whole range in about half a minute and holds still
* within a track.
*/
const TRACE_RANGE_CREEP = 0.00075;
/** Floor on the window's width, so near-silence cannot amplify into noise. */
const TRACE_RANGE_MIN_SPAN = 0.06;
/**
* Loudness on its own absolute scale, alongside the auto-ranged one.
*
* Auto-ranging has nothing to stretch on a stationary signal, so the hiss between
* two stations would collapse to a flat line however loud it is. This term keeps
* it on screen: a small floor under the shape, and the height of the serration.
*/
const TRACE_PRESENCE_GAIN = 3.2;
const TRACE_PRESENCE_LIFT = 13;
/**
* Share of the width the shape closes over, on the way out and on the way in.
*
* Lopsided on purpose. Readings enter on the right, so a taper there keeps every
* hit hidden until it has drifted clear of it, which is latency bought for
* nothing; the left has all the room it needs to bow out. A raised cosine rather
* than a straight ramp, because its slope vanishes at the baseline, so the two
* mirrored crests meet tangentially and the outline shuts without a corner.
*/
const TRACE_TAPER_OUT = 0.22;
const TRACE_TAPER_IN = 0.05;
let traceHistory: number[] = new Array(TRACE_POINTS).fill(0);
let traceSwell = 0;
let traceLastStep = 0;
let traceLevelLow = 1;
let traceLevelHigh = 0;
const POSE_SEND_MS = 50; // ~20 Hz head-bob streaming
const STATE_POLL_MS = 90; // ~11 Hz antenna readback for tuning + volume
const LEFT_MOTOR = "left_antenna";
const RIGHT_MOTOR = "right_antenna";
// On-screen knob sweep (~7 o'clock β†’ ~5 o'clock, gap at bottom) used for the
// volume knob face rotation only.
const VOL_ANGLE_MIN = TUNE_ARC_MIN;
const VOL_ANGLE_MAX = TUNE_ARC_MAX;
let volumeAngle = VOL_ANGLE_MIN + 0.8 * (VOL_ANGLE_MAX - VOL_ANGLE_MIN);
// ── Physical antenna control ranges ──
// Both antennas rest in a tight "V" pointing up and each sweeps a gentle range
// on its OWN side of vertical, so they never cross / collide. Left antenna
// (positive = leans left) tunes the radio; right antenna (negative = leans
// right) sets the volume. Up (near vertical) = loud, spread out = quiet.
// See SDK: antennas = [right, left].
const D2R = Math.PI / 180;
const ANT_REST = 28 * D2R; // resting "V" half-angle from vertical (acute)
const ANT_HALF_RANGE = 18 * D2R; // sweep on each side of the rest angle
// The mechanical "straight up" of the antennas isn't the encoder zero β€” nudge
// each antenna outward so the V's apex sits on the true physical centre. The
// two antennas use mirrored sign conventions (left +, right -), so the centre
// offset is mirrored too. Tune to taste (flip sign if it's off).
const ANT_CENTER = 8 * D2R;
const ANT_CENTER_LEFT = -ANT_CENTER;
const ANT_CENTER_RIGHT = ANT_CENTER;
const ANT_RADIO_MIN = ANT_REST - ANT_HALF_RANGE; // +10Β° (near vertical) β†’ first station
const ANT_RADIO_MAX = ANT_REST + ANT_HALF_RANGE; // +46Β° (outward) β†’ last station
const ANT_VOL_LOUD = -(ANT_REST - ANT_HALF_RANGE); // -10Β° (near vertical / up) β†’ loud
const ANT_VOL_QUIET = -(ANT_REST + ANT_HALF_RANGE); // -46Β° (outward) β†’ quiet
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
/** Internal tuning arc angle (Β±TUNE_ARC) β†’ left-antenna physical angle. */
function tuningArcToLeft(arc: number): number {
const f = (toTuningAngle(arc) - TUNE_ARC_MIN) / (TUNE_ARC_MAX - TUNE_ARC_MIN);
return ANT_CENTER_LEFT + ANT_RADIO_MIN + f * (ANT_RADIO_MAX - ANT_RADIO_MIN);
}
/** Left-antenna physical angle β†’ internal tuning arc angle. */
function leftToTuningArc(left: number): number {
const f = (clamp(left - ANT_CENTER_LEFT, ANT_RADIO_MIN, ANT_RADIO_MAX) - ANT_RADIO_MIN) / (ANT_RADIO_MAX - ANT_RADIO_MIN);
return TUNE_ARC_MIN + f * (TUNE_ARC_MAX - TUNE_ARC_MIN);
}
/** Volume [0..1] β†’ right-antenna physical angle. */
function volumeToRight(v: number): number {
return ANT_CENTER_RIGHT + ANT_VOL_QUIET + clamp(v, 0, 1) * (ANT_VOL_LOUD - ANT_VOL_QUIET);
}
/** Right-antenna physical angle β†’ volume [0..1]. */
function rightToVolume(right: number): number {
const lo = Math.min(ANT_VOL_QUIET, ANT_VOL_LOUD);
const hi = Math.max(ANT_VOL_QUIET, ANT_VOL_LOUD);
return (clamp(right - ANT_CENTER_RIGHT, lo, hi) - ANT_VOL_QUIET) / (ANT_VOL_LOUD - ANT_VOL_QUIET);
}
type Reachy = ConnectedHandle<unknown>["reachy"];
// ── Tuning state (closure-scoped) ──
let stations: Station[] = loadStations();
let angles = stationPositions(stations.length); // signed, centred on 0
/**
* The dial's brain β€” filtering, capture hysteresis and motor
* authority all live in here. This file only converts units, feeds it the
* antenna, and renders whatever it says. See `tuning.ts`.
*/
const tuning = new TuningModel({ arcMin: TUNE_ARC_MIN, arcMax: TUNE_ARC_MAX });
let frame: TuningFrame = tuning.frame();
let audioUnlocked = false; // set after the user gesture that starts Web Audio
let statusOverride: string | null = null;
let streamBuffering = false;
/** Static bed stays muted until the user's first genuine tuning gesture. */
let noiseArmed = false;
/** Latest physical antenna readbacks (radians), ordered as the SDK reports them. */
let physLeft = ANT_RADIO_MIN;
let physRight = ANT_VOL_QUIET;
/** Antenna readbacks only reach the model once the boot glide has landed. */
let armLive = false;
const RAIN_STORAGE_KEY = "reachy-radio.rain.v1";
const RAIN_LEVEL_STORAGE_KEY = "reachy-radio.rain-level.v1";
let rainEnabled = loadRainEnabled();
let rainLevel = loadRainLevel();
/** Wrap any angle to [-Ο€, Ο€]. */
function wrapPi(a: number): number {
return Math.atan2(Math.sin(a), Math.cos(a));
}
/** Clamp/wrap a raw angle onto the tuning arc (same sweep as volume). */
function toTuningAngle(raw: number): number {
const a = wrapPi(raw);
return Math.max(TUNE_ARC_MIN, Math.min(TUNE_ARC_MAX, a));
}
let leftHeld = true; // last torque state applied to the left (radio) antenna
let rightHeld = true; // last torque state applied to the right (volume) antenna
/** Time constant (s) for UI antenna easing β€” lower = snappier. */
const UI_ANTENNA_TAU_S = 0.14;
// Volume drive mirrors the tuning drive: while the user drags the on-screen
// volume knob we HOLD the right antenna and command it, then release it.
let volDriving = false;
let volTarget01 = 0.8; // UI intent for volume [0..1]
let volCommand01 = 0.8; // smoothed command actually sent to the right antenna
let volLastLerpMs = 0;
let volReleaseTimer: number | null = null;
let saveTuningTimer: number | null = null;
let lastPersistedAngle = NaN;
// ── Long-lived handles cleaned up on leave ──
let radio: RadioAudio | null = null;
let nowPlaying: NowPlayingWatcher | null = null;
/** Track currently on air, when the station publishes one. */
let nowPlayingTitle: string | null = null;
let reachyRef: Reachy | null = null;
let rafId: number | null = null;
/** Web Worker clock that keeps the robot/audio loop alive in a hidden tab. */
let poseWorker: Worker | null = null;
/** Fallback clock if Web Workers are unavailable (still throttled when hidden). */
let poseFallbackTimer: number | null = null;
/** Station the stream is currently pointed at, to spot genuine changes. */
let tunedIndex = -1;
const inputCleanups: Array<() => void> = [];
function addInputCleanup(fn: () => void): void {
inputCleanups.push(fn);
}
/** Point both the audio and the now-playing lookup at a station. */
function tuneTo(url: string): void {
void radio?.playStation(url);
nowPlaying?.watch(url);
}
async function main(): Promise<void> {
const handle = await connectToHost<unknown>();
const root = document.getElementById("root");
if (!root) throw new Error("missing #root");
reachyRef = handle.reachy;
handle.setAppState({ phase: "live" });
renderShell(root);
addInputCleanup(initTooltips(root));
applyInitialTuning();
radio = new RadioAudio({
onError: (msg) => {
setBuffering(false);
setStatus(msg);
},
onWaiting: () => {
setStatus("");
setBuffering(true);
},
onPlaying: () => {
setBuffering(false);
setStatus("");
},
});
nowPlaying = new NowPlayingWatcher((title) => {
nowPlayingTitle = title;
});
radio.setRainEnabled(rainEnabled);
radio.setRainLevel(rainLevel);
initRainToggle();
initKnob();
initVolKnob();
initTuner();
initModal();
initUnlockOverlay();
initControls();
startLoop(handle);
handle.onLeave(async () => {
if (rafId != null) cancelAnimationFrame(rafId);
rafId = null;
if (poseWorker) {
poseWorker.postMessage({ type: "stop" });
poseWorker.terminate();
poseWorker = null;
}
if (poseFallbackTimer != null) {
window.clearInterval(poseFallbackTimer);
poseFallbackTimer = null;
}
if (volReleaseTimer != null) window.clearTimeout(volReleaseTimer);
volReleaseTimer = null;
if (saveTuningTimer != null) window.clearTimeout(saveTuningTimer);
saveTuningTimer = null;
if (stations.length && Number.isFinite(frame.intent)) {
saveTuning(frame.intent, stations[frame.index]);
}
painted.clear();
held.clear();
for (const fn of inputCleanups.splice(0)) fn();
nowPlaying?.dispose();
nowPlaying = null;
nowPlayingTitle = null;
radio?.dispose();
radio = null;
try {
// Re-hold every joint (the tuning antenna was floating) and glide home.
handle.reachy.setMotorTorque(true, null);
handle.reachy.gotoTarget({
head: rpyToMatrix(0, 0, 0).flat(),
antennas: [0, 0],
body_yaw: 0,
duration: 0.8,
});
} catch {
/* channel may be closed */
}
});
}
// ───────────────────── UI scaffold ─────────────────────
/** Position (0..1) of an angle along the tuning arc. */
function angleFraction(a: number): number {
const clamped = toTuningAngle(a);
const span = TUNE_ARC_MAX - TUNE_ARC_MIN;
return span > 0 ? (clamped - TUNE_ARC_MIN) / span : 0.5;
}
/**
* The dial spans the whole arc, edge to edge. Nothing is inset here: the
* stations are spaced around the arc, so they already keep clear of the ends,
* and padding the track on top would only push them further in.
*/
function trackPercentFromAngle(a: number): number {
return angleFraction(a) * 100;
}
/** All tick positions along the arc, derived from the current station count. */
function dialTickAngles(): number[] {
return stationPositions(stations.length);
}
/**
* Roughly how far apart the hairlines should sit, in percent of the track.
* It is only a target: the real step is the nearest divisor of the station
* spacing, so the grid keeps a similar density whatever the station count.
*/
const TUNER_FINE_TICK_PCT = 2.5;
function renderTunerTicks(): string {
const n = stations.length;
if (n <= 0) return "";
const positions = dialTickAngles();
const anchor = trackPercentFromAngle(positions[0]);
// Hairlines are laid on a grid that divides the station spacing, so a station
// always falls exactly on one instead of drifting between two. Stations own
// one slot each, hence a spacing of a full slot whatever the count.
const spacing = 100 / n;
const step = spacing / Math.max(1, Math.round(spacing / TUNER_FINE_TICK_PCT));
// The grid is anchored on the first station and extended both ways until it
// runs off the track, so the ticks reach the edges past the outer stations.
const fine: string[] = [];
for (let k = Math.ceil(-anchor / step); anchor + k * step <= 100; k++) {
const pct = (anchor + k * step).toFixed(2);
fine.push(`<span class="tuner-fine-tick" style="left:${pct}%"></span>`);
}
const majors = positions
.map((a, i) => {
const pct = trackPercentFromAngle(a).toFixed(2);
return `<span class="tuner-station-tick" data-mark="${i}" style="left:${pct}%"></span>`;
})
.join("");
return fine.join("") + majors;
}
function renderTunerLabels(): string {
const positions = dialTickAngles();
return positions
.map((a, i) => {
const pct = trackPercentFromAngle(a).toFixed(2);
const name = escapeHtml(stations[i]?.name ?? "");
// The full name is kept aside: what is displayed gets trimmed to the slot
// by `fitTunerLabels`, and re-trimmed from the original when it changes.
return `<span class="tuner-label" data-mark="${i}" data-name="${name}" style="left:${pct}%">${name}</span>`;
})
.join("");
}
/** Room left between two neighbouring names, halos included. */
const TUNER_LABEL_GUTTER_PX = 12;
let labelMetrics: CanvasRenderingContext2D | null = null;
/**
* Trim each name to the width its slot affords, ellipsis included in the text.
* Letting CSS truncate looks identical right up until the labels get a halo:
* the generated ellipsis is a text run of its own, so its outline paints over
* the tail of the name rather than behind it.
*/
function fitTunerLabels(): void {
const track = document.getElementById("tuner-track");
const labels = [...document.querySelectorAll<HTMLElement>(".tuner-label")];
const probe = labels[0];
// A hidden dial measures zero and would trim every name to one letter; the
// observer below re-runs this the moment it has a width again.
if (!track || !probe || track.clientWidth <= 0) return;
labelMetrics ??= document.createElement("canvas").getContext("2d");
const ctx = labelMetrics;
if (!ctx) return;
const style = getComputedStyle(probe);
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
ctx.letterSpacing = style.letterSpacing;
// Names are centred on their station, so two neighbours share one slot.
const budget = track.clientWidth / labels.length - TUNER_LABEL_GUTTER_PX;
for (const el of labels) el.textContent = ellipsize(el.dataset.name ?? "", budget, ctx);
}
function ellipsize(text: string, maxPx: number, ctx: CanvasRenderingContext2D): string {
if (ctx.measureText(text).width <= maxPx) return text;
let cut = text.length;
while (cut > 1 && ctx.measureText(`${text.slice(0, cut)}…`).width > maxPx) cut--;
return `${text.slice(0, cut).trimEnd()}…`;
}
function refreshTunerDial(): void {
const scale = document.getElementById("tuner-scale");
const labels = document.getElementById("tuner-labels");
if (scale) scale.innerHTML = renderTunerTicks();
if (labels) labels.innerHTML = renderTunerLabels();
fitTunerLabels();
}
/** Outer-ring ticks: one mark per station, evenly spaced on the tuning arc. */
function renderKnobTicks(kind: "tune" | "vol"): string {
if (kind === "tune") {
const tickR = 74;
const marks = dialTickAngles();
return marks
.map((a) => {
const deg = (a * 180) / Math.PI;
return `<span class="knob-tick station" style="--tick-r:${tickR}px;transform:rotate(${deg.toFixed(2)}deg)"></span>`;
})
.join("");
}
const tickR = 74;
const minDeg = (VOL_ANGLE_MIN * 180) / Math.PI;
const maxDeg = (VOL_ANGLE_MAX * 180) / Math.PI;
const step = 28;
const ticks: string[] = [];
for (let deg = minDeg; deg <= maxDeg + 0.001; deg += step) {
ticks.push(
`<span class="knob-tick" style="--tick-r:${tickR}px;transform:rotate(${deg.toFixed(2)}deg)"></span>`,
);
}
return ticks.join("");
}
function updateKnobScale(): void {
const tune = document.getElementById("tune-knob-scale");
if (tune) tune.innerHTML = renderKnobTicks("tune");
}
// Antenna outlines from the SDK JS demo `reachy-antennas-icon.svg` (no head).
const ANT_RIGHT_PATH =
"M274.047,297.715 L273.902248,297.873191 C273.378792,298.462515 273.005794,299.104175 272.712298,299.77675 C272.444977,300.389343 272.266523,301.083363 272.16463,301.889015 L272.135,302.139 L272.138466,301.994421 C272.180784,300.84872 272.460048,299.747423 272.962149,298.686896 L273.119451,298.369958 L274.047,297.715 Z M265.358,284.007 L277.148758,288.821484 C278.477869,289.364148 280.116941,289.852908 281.054126,291.044415 C281.366442,291.441483 281.44062,291.968932 281.392298,292.604279 C281.195861,293.110341 280.912832,293.527567 280.454172,293.781473 C279.046576,294.561146 277.748482,295.305703 276.559282,296.01473 L276.559282,296.01473 L276.274,296.144 L280.649277,293.05966 C278.634372,291.751434 276.611298,290.45504 274.337173,289.309879 L273.5695,288.933951 C270.939368,287.682722 268.367779,286.839375 265.79014,286.007848 C265.517627,285.919938 265.245103,285.832027 264.976863,285.744932 L265.237,285.829 L265.221352,285.778475 C265.119756,285.435971 265.062048,285.105945 265.089467,284.786591 L265.116464,284.596245 C265.157418,284.391763 265.237578,284.199721 265.34604,284.014929 L265.34604,284.014929 L265.358,284.007 Z M265.003,257.887 L277.541811,262.556406 C277.895796,262.688219 278.258892,262.815918 278.625689,262.944583 C279.433703,263.228018 280.95552,263.056759 281.323793,264.260846 C281.403693,264.52252 281.410378,264.794517 281.327581,265.031172 C280.868224,265.369358 279.995917,265.731159 278.753553,266.218055 L278.753553,266.218055 L267.127481,270.774437 L278.685313,275.501241 C279.928139,276.009519 280.952474,276.413662 281.681856,276.904534 C281.822131,277.228106 281.886464,277.518509 281.759035,277.762957 C281.281463,278.679085 279.878326,278.689441 278.927812,278.9915 C278.146929,279.235357 277.813335,279.3381 277.567114,279.413928 L277.567114,279.413928 L269.001,282.051 L278.259422,277.080502 L267.053699,273.482751 C266.384664,273.267948 265.757158,273.117624 265.183243,272.781138 C265.054383,272.651368 264.932049,272.499856 264.811517,272.330865 C264.696411,271.899305 264.64016,271.505942 264.795476,271.173637 C264.938418,270.867807 265.378585,270.857421 265.720769,270.74578 L265.720769,270.74578 L264.959,270.88 C265.416635,270.780015 266.106237,270.597302 267.034551,270.264688 L267.271602,270.178373 L278.898969,265.87788 L267.541504,260.908183 C266.37157,260.396254 265.277751,260.17738 264.66933,259.436157 C264.49598,259.224968 264.597273,258.885883 264.606777,258.527642 C264.611711,258.340783 264.574215,258.147822 264.666715,258.009376 C264.755793,257.975778 264.859176,257.938443 264.976999,257.897308 L265.003,257.887 Z M271,61 C271.828427,61 272.578427,61.3357864 273.12132,61.8786797 C273.664214,62.4215729 274,63.1715729 274,64 L274,64 L274,82 C274,83.7818459 272.444564,85.1980216 270.628712,84.9829449 L270.628712,84.9829449 L270.858285,96.6735383 L271.002991,115.730097 L271.130347,134.878964 L271.348625,154.977115 L271.644262,174.873929 L271.798206,195.135445 L271.968726,214.227201 L272.182756,234.173295 L272.195196,243.590731 L275.215923,246.433604 L276.862472,247.249197 C278.307609,247.987713 279.743768,248.558372 280.425138,249.736849 C280.694076,250.201997 280.702497,250.770547 280.593923,251.447281 C279.943534,252.235729 279.095684,252.760044 278.053941,253.025049 L278.053941,253.025049 L277.954,253.05 L275.588368,248.484681 C274.437661,246.264092 273.486312,245.493488 273.033665,245.13147 L272.98176,245.089922 C271.075367,243.49497 271.640428,240.699598 271.689223,238.367427 L271.689223,238.367427 L271.704166,237.718629 C271.732588,236.61389 271.753206,235.780604 271.757229,235.028873 L271.757355,234.537717 L271.757355,234.537717 L271.755393,234.297293 L271.539423,214.838754 L271.386717,194.600913 L271.147059,175.377828 L270.969883,155.737836 L270.781175,135.771526 L270.698359,118.087243 L270.451077,96.0541006 L270.336459,86.0506205 L267.642395,84.6771572 C267.145017,84.4235884 266.727745,84.0357145 266.437317,83.5619013 C266.158616,83.1072219 266,82.5719806 266,82 L266,82 L266,64 C266,63.1715729 266.335786,62.4215729 266.87868,61.8786797 C267.421573,61.3357864 268.171573,61 269,61 L269,61 Z";
const ANT_LEFT_PATH =
"M97.047,297.715 L96.9022484,297.873191 C96.378792,298.462515 96.0057936,299.104175 95.7122976,299.77675 C95.4449766,300.389343 95.2665232,301.083363 95.1646303,301.889015 L95.135,302.139 L95.1384659,301.994421 C95.1807844,300.84872 95.4600481,299.747423 95.9621492,298.686896 L96.1194506,298.369958 L97.047,297.715 Z M88.358,284.007 L100.148758,288.821484 C101.477869,289.364148 103.116941,289.852908 104.054126,291.044415 C104.366442,291.441483 104.44062,291.968932 104.392298,292.604279 C104.195861,293.110341 103.912832,293.527567 103.454172,293.781473 C102.046576,294.561146 100.748482,295.305703 99.5592816,296.01473 L99.5592816,296.01473 L99.274,296.144 L103.649277,293.05966 C101.634372,291.751434 99.6112976,290.45504 97.3371728,289.309879 L96.5694999,288.933951 C93.939368,287.682722 91.367779,286.839375 88.7901397,286.007848 C88.5176275,285.919938 88.2451026,285.832027 87.9768627,285.744932 L88.237,285.829 L88.2213525,285.778475 C88.1197561,285.435971 88.0620483,285.105945 88.0894674,284.786591 L88.116464,284.596245 C88.1574176,284.391763 88.2375782,284.199721 88.3460397,284.014929 L88.3460397,284.014929 L88.358,284.007 Z M88.003,257.887 L100.541811,262.556406 C100.895796,262.688219 101.258892,262.815918 101.625689,262.944583 C102.433703,263.228018 103.95552,263.056759 104.323793,264.260846 C104.403693,264.52252 104.410378,264.794517 104.327581,265.031172 C103.868224,265.369358 102.995917,265.731159 101.753553,266.218055 L101.753553,266.218055 L90.1274811,270.774437 L101.685313,275.501241 C102.928139,276.009519 103.952474,276.413662 104.681856,276.904534 C104.822131,277.228106 104.886464,277.518509 104.759035,277.762957 C104.281463,278.679085 102.878326,278.689441 101.927812,278.9915 C101.146929,279.235357 100.813335,279.3381 100.567114,279.413928 L100.567114,279.413928 L92.001,282.051 L101.259422,277.080502 L90.0536991,273.482751 C89.3846642,273.267948 88.7571582,273.117624 88.1832425,272.781138 C88.054383,272.651368 87.9320492,272.499856 87.811517,272.330865 C87.6964109,271.899305 87.6401599,271.505942 87.7954758,271.173637 C87.938418,270.867807 88.3785846,270.857421 88.7207686,270.74578 L88.7207686,270.74578 L87.959,270.88 C88.4166352,270.780015 89.1062367,270.597302 90.0345513,270.264688 L90.2716018,270.178373 L101.898969,265.87788 L90.5415036,260.908183 C89.3715695,260.396254 88.277751,260.17738 87.6693302,259.436157 C87.4959797,259.224968 87.5972727,258.885883 87.6067767,258.527642 C87.6117107,258.340783 87.5742154,258.147822 87.6667147,258.009376 C87.7557926,257.975778 87.8591765,257.938443 87.9769992,257.897308 L88.003,257.887 Z M94,61 C94.8284271,61 95.5784271,61.3357864 96.1213203,61.8786797 C96.6642136,62.4215729 97,63.1715729 97,64 L97,64 L97,82 C97,83.7818459 95.4445635,85.1980216 93.6287116,84.9829449 L93.6287116,84.9829449 L93.8582846,96.6735383 L94.002991,115.730097 L94.1303469,134.878964 L94.3486249,154.977115 L94.6442616,174.873929 L94.7982058,195.135445 L94.9687257,214.227201 L95.1827564,234.173295 L95.1951956,243.590731 L98.2159228,246.433604 L99.8624725,247.249197 C101.307609,247.987713 102.743768,248.558372 103.425138,249.736849 C103.694076,250.201997 103.702497,250.770547 103.593923,251.447281 C102.943534,252.235729 102.095684,252.760044 101.053941,253.025049 L101.053941,253.025049 L100.954,253.05 L98.5883682,248.484681 C97.4376613,246.264092 96.4863125,245.493488 96.0336649,245.13147 L95.9817601,245.089922 C94.0753674,243.49497 94.6404282,240.699598 94.6892231,238.367427 L94.6892231,238.367427 L94.7041663,237.718629 C94.7325877,236.61389 94.7532063,235.780604 94.7572287,235.028873 L94.7573552,234.537717 L94.7573552,234.537717 L94.7553934,234.297293 L94.5394231,214.838754 L94.3867175,194.600913 L94.1470591,175.377828 L93.9698826,155.737836 L93.7811752,135.771526 L93.6983593,118.087243 L93.451077,96.0541006 L93.3364587,86.0506205 L90.6423949,84.6771572 C90.1450169,84.4235884 89.7277454,84.0357145 89.4373167,83.5619013 C89.1586163,83.1072219 89,82.5719806 89,82 L89,82 L89,64 C89,63.1715729 89.3357864,62.4215729 89.8786797,61.8786797 C90.4215729,61.3357864 91.1715729,61 92,61 L92,61 Z";
const ANT_LEFT_TRANSFORM =
"translate(96.160223, 185.182690) scale(-1, 1) translate(-96.160223, -185.182690)";
/**
* Start-modal control hint: the two Reachy antennas (from the SDK demo icon,
* no head) with the relevant one highlighted, plus an arc + arrowhead beside it
* showing the angle it sweeps through.
*/
function antennaDiagram(
active: "left" | "right",
sub?: { neg: string; pos: string },
): string {
// Antenna stays straight and centered; the sweep it can reach is shown as a
// symmetric angle on both sides. `sub` adds an optional symbol under each
// side's degree label (e.g. βˆ’/+ for volume).
const pivot = active === "left" ? { x: 96, y: 246 } : { x: 271, y: 246 };
const maxTilt = 30; // outward reach shown on each side (deg)
const arcR = 140; // arc rides near the top of the antenna
const rad = (d: number): number => (d * Math.PI) / 180;
const f = (n: number): string => n.toFixed(1);
const at = (deg: number, r: number) => ({
x: pivot.x + Math.sin(rad(deg)) * r,
y: pivot.y - Math.cos(rad(deg)) * r,
});
const side = (s: 1 | -1): string => {
const a0 = at(0, arcR);
const a1 = at(s * maxTilt, arcR);
const gt = at(s * maxTilt, 182);
// Both dashed guides fan out from one shared anchor at the antenna's
// base (artwork bottoms out around y 302), so they never cross.
const gb = { x: pivot.x, y: pivot.y + 54 };
const aRad = rad(s * maxTilt);
const tang = { x: Math.cos(aRad), y: Math.sin(aRad) };
const dir = s > 0 ? tang : { x: -tang.x, y: -tang.y };
const nrm = { x: -dir.y, y: dir.x };
const AL = 12;
const AW = 6;
const back = { x: a1.x - dir.x * AL, y: a1.y - dir.y * AL };
const pA = { x: back.x + nrm.x * AW, y: back.y + nrm.y * AW };
const pB = { x: back.x - nrm.x * AW, y: back.y - nrm.y * AW };
const sweep = s > 0 ? 1 : 0;
const lp = at(s * maxTilt, arcR + 44);
const subLabel = sub ? (s < 0 ? sub.neg : sub.pos) : "";
const subText = subLabel
? `<text class="diag-sub" x="${f(lp.x)}" y="${f(lp.y + 42)}" text-anchor="middle" dominant-baseline="central">${subLabel}</text>`
: "";
return `
<line class="diag-ghost" x1="${f(gb.x)}" y1="${f(gb.y)}" x2="${f(gt.x)}" y2="${f(gt.y)}" />
<path class="diag-arc" d="M${f(a0.x)} ${f(a0.y)} A ${arcR} ${arcR} 0 0 ${sweep} ${f(a1.x)} ${f(a1.y)}" />
<path class="diag-arrow" d="M${f(pA.x)} ${f(pA.y)} L${f(a1.x)} ${f(a1.y)} L${f(pB.x)} ${f(pB.y)} Z" />
<text class="diag-deg" x="${f(lp.x)}" y="${f(lp.y)}" text-anchor="middle" dominant-baseline="central">${maxTilt}Β°</text>
${subText}`;
};
const activePath = active === "left" ? ANT_LEFT_PATH : ANT_RIGHT_PATH;
const mirror = active === "left" ? ` transform="${ANT_LEFT_TRANSFORM}"` : "";
const vbX = pivot.x - 130;
return `
<svg class="ant-diag" viewBox="${vbX} 55 260 258" fill="none" aria-hidden="true">
${side(-1)}
${side(1)}
<path class="diag-ap diag-ap-on" d="${activePath}"${mirror} />
</svg>`;
}
function renderShell(root: HTMLElement): void {
root.innerHTML = `
<div class="radio-app">
<div class="radio-chassis">
${renderChassisHead()}
<section class="controls-panel">
<div class="radio-tuner">
<div class="tuner-track" id="tuner-track" role="slider" aria-label="Tuning"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" tabindex="0">
<span class="tuner-scale" id="tuner-scale">${renderTunerTicks()}</span>
<span class="tuner-needle tuner-needle-actual" id="tuner-needle-actual" hidden aria-hidden="true"></span>
<span class="tuner-needle tuner-needle-target" id="tuner-needle-target"></span>
<div class="tuner-labels" id="tuner-labels">${renderTunerLabels()}</div>
</div>
</div>
<div class="knobs-row">
<div class="knob-block knob-block-vol">
<div class="knob-wrap knob-wrap-vol">
<div class="knob-scale" id="vol-knob-scale" aria-hidden="true">${renderKnobTicks("vol")}</div>
<div class="knob knob-vol" id="vol-knob" role="slider" aria-label="Volume"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="80" tabindex="0">
<span class="knob-face" id="vol-knob-face"><span class="knob-mark"></span></span>
</div>
</div>
<span class="knob-label">Volume</span>
</div>
<div class="knob-block knob-block-tune">
<div class="knob-wrap knob-wrap-tune">
<div class="knob-scale" id="tune-knob-scale" aria-hidden="true">${renderKnobTicks("tune")}</div>
<div class="knob knob-tune" id="knob" role="slider" aria-label="Tuning knob"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" tabindex="0">
<span class="knob-face" id="knob-face"><span class="knob-mark"></span></span>
</div>
</div>
<span class="knob-label">Tuning</span>
</div>
</div>
</section>
</div>
<p class="gesture">
<span>Turn the <b>left antenna</b> for volume - the <b>right antenna</b> to tune</span>
</p>
</div>
<div class="overlay unlock-overlay is-open" id="unlock-overlay">
<div class="unlock-stack">
<img class="unlock-icon" src="/icon.svg" alt="Reachy Radio" width="140" height="140" aria-hidden="true" />
<div class="unlock-card">
<h2 class="unlock-title">Reachy Radio</h2>
<p class="unlock-copy">
<b>One tap</b> is all your browser needs before Reachy can play.
After that, the two <b>antennas</b> do the rest.
</p>
<div class="unlock-antennas">
<div class="ant-card">
${antennaDiagram("left", { neg: "\u2212", pos: "+" })}
<span class="ant-card-text"><b>Left</b><br />sets volume</span>
</div>
<div class="ant-card">
${antennaDiagram("right")}
<span class="ant-card-text"><b>Right</b><br />tunes stations</span>
</div>
</div>
<button class="btn primary unlock-cta" id="unlock-btn" type="button">
<span class="unlock-cta-label">Start listening</span>
<span class="unlock-cta-spinner" id="unlock-btn-spinner" hidden aria-hidden="true"></span>
</button>
</div>
</div>
</div>
<div class="overlay modal-overlay" id="manage-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="manage-title">
<header class="modal-head">
<h2 id="manage-title">Settings</h2>
<button class="btn-link" id="reset-defaults" type="button">Reset defaults</button>
<button class="icon-btn modal-close" id="manage-close" aria-label="Close">&times;</button>
</header>
<div class="modal-body">
<section class="modal-block" aria-labelledby="ambience-heading">
<h3 class="modal-block-title" id="ambience-heading">Ambience</h3>
<label class="setting-row">
<span class="setting-label">Rain</span>
<input type="range" id="rain-level" min="0" max="100" value="55"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="55"
aria-label="Rain level" />
<span class="setting-value" id="rain-level-value" aria-hidden="true">55%</span>
</label>
</section>
<section class="modal-block" aria-labelledby="add-heading">
<h3 class="modal-block-title" id="add-heading">Add station</h3>
<div class="search-group big">
<form class="search-bar" id="search-form">
<div class="search-field">
<input type="search" id="search-input" placeholder="FIP, jazz, lofi…"
autocomplete="off" aria-label="Search webradios" />
<button class="search-clear" id="search-clear" type="button"
aria-label="Clear search" hidden>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
</svg>
</button>
</div>
<button class="btn primary btn-big" type="submit">Search</button>
</form>
<div class="search-results" id="search-results" aria-live="polite" hidden></div>
</div>
</section>
<section class="modal-block modal-block-stations" aria-labelledby="stations-heading">
<h3 class="modal-block-title" id="stations-heading">
Stations <span class="modal-count" id="current-count">0/${MAX_STATIONS}</span>
</h3>
<div class="station-list" id="current-list"></div>
</section>
</div>
</div>
</div>
`;
fitTunerLabels();
updateNeedle();
}
/**
* Boot tuning: restore the last saved position, or land on the first station
* so the dial does not start in static between presets.
*/
function applyInitialTuning(): void {
const now = performance.now();
angles = stationPositions(stations.length);
tuning.setStations(angles);
if (!stations.length) {
tuning.placeAt(0, now);
} else {
const saved = loadSavedTuning();
const savedIdx = saved?.stationUrl
? stations.findIndex((s) => s.url.toLowerCase() === saved.stationUrl!.toLowerCase())
: -1;
if (savedIdx >= 0) tuning.jumpTo(savedIdx, now);
else if (saved) tuning.placeAt(saved.angle, now);
else tuning.jumpTo(firstRunStationIndex(), now);
}
frame = tuning.frame();
lastPersistedAngle = frame.intent;
updateKnobScale();
updateNeedle();
updateTuningUI();
}
/**
* Station a brand-new visitor lands on: the bundled Lofi preset, which sits
* at the centre of the default dial. Falls back to the middle slot when the
* list has no lofi station (custom list restored without saved tuning).
*/
function firstRunStationIndex(): number {
const lofi = stations.findIndex((s) => /lofi/i.test(s.name));
return lofi >= 0 ? lofi : Math.floor(stations.length / 2);
}
function scheduleSaveTuning(): void {
if (!stations.length) return;
if (saveTuningTimer != null) window.clearTimeout(saveTuningTimer);
saveTuningTimer = window.setTimeout(() => {
saveTuningTimer = null;
if (Math.abs(frame.intent - lastPersistedAngle) < 0.02) return;
lastPersistedAngle = frame.intent;
saveTuning(frame.intent, stations[frame.index]);
}, 450);
}
// ───────────────────── Dial (drag to tune the antenna) ─────────────────────
/** Apply torque to the tuning antenna, deduped so we don't spam the channel. */
function setLeftHold(hold: boolean): void {
if (hold === leftHeld) return;
leftHeld = hold;
try {
reachyRef?.setMotorTorque(hold, [LEFT_MOTOR]);
} catch {
/* channel closed */
}
}
function setRightHold(hold: boolean): void {
if (hold === rightHeld) return;
rightHeld = hold;
try {
reachyRef?.setMotorTorque(hold, [RIGHT_MOTOR]);
} catch {
/* channel closed */
}
}
/**
* Push one model frame out to the world: torque, needle, and the static bed's
* arming. The model decides *when* the motor is needed; we only relay it.
*/
function applyFrame(next: TuningFrame): void {
frame = next;
setLeftHold(next.command != null);
if (next.engaged && !noiseArmed) {
noiseArmed = true;
radio?.allowNoise();
}
}
/** Advance the model outside the logic loop (pointer events, boot, ring edits). */
function pumpTuning(): void {
applyFrame(tuning.update(performance.now()));
updateNeedle();
}
/** Take over the right antenna so the volume knob can command it. */
function beginVolDrive(): void {
if (volReleaseTimer != null) {
window.clearTimeout(volReleaseTimer);
volReleaseTimer = null;
}
volCommand01 = rightToVolume(physRight);
volLastLerpMs = 0;
volDriving = true;
setRightHold(true);
}
/** Ease the commanded volume toward the UI target (frame-rate independent). */
function stepVolCommand(nowMs: number): void {
if (!volDriving) return;
if (!volLastLerpMs) volLastLerpMs = nowMs;
const dt = Math.min(0.05, (nowMs - volLastLerpMs) / 1000);
volLastLerpMs = nowMs;
const alpha = 1 - Math.exp(-dt / UI_ANTENNA_TAU_S);
const delta = volTarget01 - volCommand01;
if (Math.abs(delta) < 0.004) {
volCommand01 = volTarget01;
return;
}
volCommand01 += delta * alpha;
}
/** Release the right antenna a beat after the volume drag ends. */
function endVolDriveSoon(): void {
if (volReleaseTimer != null) window.clearTimeout(volReleaseTimer);
volReleaseTimer = window.setTimeout(() => {
volReleaseTimer = null;
volDriving = false;
volLastLerpMs = 0;
setRightHold(false);
}, 400);
}
/**
* Hand the needle over to the finger, CSS transition included. The transition
* smooths the needle's *own* moves (boot, keyboard, the antenna); under a drag it
* only adds lag, so it steps aside for the duration.
*/
function setNeedleDragging(on: boolean): void {
document.getElementById("tuner-needle-target")?.classList.toggle("is-dragging", on);
}
function initTuner(): void {
const track = document.getElementById("tuner-track");
if (!track) return;
let dragging = false;
const angleFromX = (clientX: number): number => {
const r = track.getBoundingClientRect();
const p = r.width > 0 ? (clientX - r.left) / r.width : 0.5;
const frac = Math.max(0, Math.min(1, p));
return TUNE_ARC_MIN + frac * (TUNE_ARC_MAX - TUNE_ARC_MIN);
};
const onDown = (e: PointerEvent): void => {
dragging = true;
setNeedleDragging(true);
track.setPointerCapture(e.pointerId);
void ensureUnlocked();
tuning.setPointer(angleFromX(e.clientX));
pumpTuning();
};
const onMove = (e: PointerEvent): void => {
if (!dragging) return;
tuning.setPointer(angleFromX(e.clientX));
pumpTuning();
};
const onUp = (e: PointerEvent): void => {
if (!dragging) return;
dragging = false;
setNeedleDragging(false);
try {
track.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
tuning.endPointer(performance.now());
pumpTuning();
scheduleSaveTuning();
};
const onKey = makeTuneKeyHandler();
track.addEventListener("pointerdown", onDown);
track.addEventListener("pointermove", onMove);
track.addEventListener("pointerup", onUp);
track.addEventListener("pointercancel", onUp);
track.addEventListener("keydown", onKey);
// How much of a name fits is a function of the track's width, which follows
// the viewport until the chassis reaches its max width.
const fitLabels = new ResizeObserver(() => fitTunerLabels());
fitLabels.observe(track);
addInputCleanup(() => {
fitLabels.disconnect();
track.removeEventListener("pointerdown", onDown);
track.removeEventListener("pointermove", onMove);
track.removeEventListener("pointerup", onUp);
track.removeEventListener("pointercancel", onUp);
track.removeEventListener("keydown", onKey);
// Leaving mid-drag would otherwise strand the needle without its transition.
setNeedleDragging(false);
});
}
/**
* Shared arrow-key handler: step one station along the ring. Nudging by a fixed
* angle instead would spread the five stations over 280Β° of arc, so most presses
* would land in the static β€” from a keyboard the useful unit is the station.
*/
function makeTuneKeyHandler(): (e: KeyboardEvent) => void {
return (e: KeyboardEvent): void => {
let dir: 1 | -1;
if (e.key === "ArrowRight" || e.key === "ArrowUp") dir = 1;
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") dir = -1;
else return;
e.preventDefault();
void ensureUnlocked();
tuning.stepStation(dir, performance.now());
pumpTuning();
scheduleSaveTuning();
};
}
/** Rotary knob control: same tuning, driven by turning the on-screen knob. */
function initKnob(): void {
const knob = document.getElementById("knob");
if (!knob) return;
let dragging = false;
const angleFromEvent = (e: PointerEvent): number => {
const r = knob.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return toTuningAngle(Math.atan2(e.clientX - cx, -(e.clientY - cy)));
};
const onDown = (e: PointerEvent): void => {
dragging = true;
knob.setPointerCapture(e.pointerId);
void ensureUnlocked();
tuning.setPointer(angleFromEvent(e));
pumpTuning();
};
const onMove = (e: PointerEvent): void => {
if (!dragging) return;
tuning.setPointer(angleFromEvent(e));
pumpTuning();
};
const onUp = (e: PointerEvent): void => {
if (!dragging) return;
dragging = false;
try {
knob.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
tuning.endPointer(performance.now());
pumpTuning();
scheduleSaveTuning();
};
const onKey = makeTuneKeyHandler();
knob.addEventListener("pointerdown", onDown);
knob.addEventListener("pointermove", onMove);
knob.addEventListener("pointerup", onUp);
knob.addEventListener("pointercancel", onUp);
knob.addEventListener("keydown", onKey);
addInputCleanup(() => {
knob.removeEventListener("pointerdown", onDown);
knob.removeEventListener("pointermove", onMove);
knob.removeEventListener("pointerup", onUp);
knob.removeEventListener("pointercancel", onUp);
knob.removeEventListener("keydown", onKey);
});
}
function volumeFromAngle(a: number): number {
const clamped = Math.max(VOL_ANGLE_MIN, Math.min(VOL_ANGLE_MAX, a));
return (clamped - VOL_ANGLE_MIN) / (VOL_ANGLE_MAX - VOL_ANGLE_MIN);
}
function applyVolume(v: number): void {
const vol = Math.max(0, Math.min(1, v));
volumeAngle = VOL_ANGLE_MIN + vol * (VOL_ANGLE_MAX - VOL_ANGLE_MIN);
radio?.setVolume(vol);
updateVolKnob();
}
function updateVolKnob(): void {
const deg = (volumeAngle * 180) / Math.PI;
const face = document.getElementById("vol-knob-face");
if (face) face.style.transform = `rotate(${deg}deg)`;
document.getElementById("vol-knob")?.setAttribute("aria-valuenow", String(Math.round(volumeFromAngle(volumeAngle) * 100)));
}
/** Volume knob to the right of the tuning knob. */
function initVolKnob(): void {
const knob = document.getElementById("vol-knob");
if (!knob) return;
applyVolume(0.8);
let dragging = false;
const angleFromEvent = (e: PointerEvent): number => {
const r = knob.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.atan2(e.clientX - cx, -(e.clientY - cy));
};
const setFromPointer = (e: PointerEvent): void => {
volumeAngle = angleFromEvent(e);
const v = volumeFromAngle(volumeAngle);
volTarget01 = v;
applyVolume(v);
};
const onDown = (e: PointerEvent): void => {
dragging = true;
knob.setPointerCapture(e.pointerId);
beginVolDrive();
void ensureUnlocked();
setFromPointer(e);
};
const onMove = (e: PointerEvent): void => {
if (!dragging) return;
setFromPointer(e);
};
const onUp = (e: PointerEvent): void => {
if (!dragging) return;
dragging = false;
try {
knob.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
endVolDriveSoon();
};
const onKey = (e: KeyboardEvent): void => {
const step = 0.05;
const cur = volumeFromAngle(volumeAngle);
let next: number;
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
next = cur + step;
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
next = cur - step;
} else {
return;
}
e.preventDefault();
void ensureUnlocked();
beginVolDrive();
volTarget01 = clamp(next, 0, 1);
applyVolume(volTarget01);
endVolDriveSoon();
};
knob.addEventListener("pointerdown", onDown);
knob.addEventListener("pointermove", onMove);
knob.addEventListener("pointerup", onUp);
knob.addEventListener("pointercancel", onUp);
knob.addEventListener("keydown", onKey);
addInputCleanup(() => {
knob.removeEventListener("pointerdown", onDown);
knob.removeEventListener("pointermove", onMove);
knob.removeEventListener("pointerup", onUp);
knob.removeEventListener("pointercancel", onUp);
knob.removeEventListener("keydown", onKey);
});
}
// ───────────────────── Rainy mood toggle ─────────────────────
function loadRainEnabled(): boolean {
try {
return localStorage.getItem(RAIN_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function saveRainEnabled(on: boolean): void {
try {
localStorage.setItem(RAIN_STORAGE_KEY, on ? "1" : "0");
} catch {
/* storage disabled */
}
}
function loadRainLevel(): number {
try {
const raw = localStorage.getItem(RAIN_LEVEL_STORAGE_KEY);
if (raw == null) return 0.55;
const v = Number(raw);
return Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.55;
} catch {
return 0.55;
}
}
function saveRainLevel(level: number): void {
try {
localStorage.setItem(RAIN_LEVEL_STORAGE_KEY, String(Math.max(0, Math.min(1, level))));
} catch {
/* storage disabled */
}
}
function syncRainUi(): void {
// Only the pressed state follows the intent here. The icon itself waits for
// the bed to be audible β€” see `updateRainButton`.
document.getElementById("rain-btn")?.setAttribute("aria-pressed", rainEnabled ? "true" : "false");
// The level stays settable with the mood off: it is the volume the bed will
// come back at, and the gain is computed from `rainEnabled` anyway.
const range = document.getElementById("rain-level") as HTMLInputElement | null;
if (range) {
range.value = String(Math.round(rainLevel * 100));
range.setAttribute("aria-valuenow", range.value);
}
const valueEl = document.getElementById("rain-level-value");
if (valueEl) valueEl.textContent = `${Math.round(rainLevel * 100)}%`;
radio?.setRainEnabled(rainEnabled);
radio?.setRainLevel(rainLevel);
}
let rainPopCancel: (() => void) | null = null;
function prefersReducedMotion(): boolean {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
}
/**
* Elastic "pop" on the rain icon when the mood is toggled. Turning it on
* springs up from a compressed state with a playful overshoot; turning it
* off settles back from a gentler overshoot. Runs on the SVG only, so the
* CSS sun ⇄ cloud morph and drop animations layer cleanly on top.
*/
function playRainPop(on: boolean): void {
const svg = document.querySelector<SVGElement>("#rain-btn svg");
if (!svg) return;
rainPopCancel?.();
rainPopCancel = null;
if (prefersReducedMotion()) {
svg.style.transform = "";
return;
}
const from = on ? 0.62 : 1.14;
const config = on
? { stiffness: 340, damping: 12, mass: 1 }
: { stiffness: 300, damping: 18, mass: 1 };
rainPopCancel = springTo(
from,
1,
(s) => {
const tilt = (1 - s) * 7; // subtle counter-rotation off the overshoot
svg.style.transform = `scale(${s}) rotate(${tilt}deg)`;
},
config,
() => {
svg.style.transform = "";
rainPopCancel = null;
},
);
}
// How long the toggle stays locked/disabled while it switches (covers the
// gain fade + the pop/morph animation) so rapid re-clicks can't stutter it.
const RAIN_SWITCH_LOCK_MS = 320;
// How long the bed may stay silent before the spinner appears. Long enough that
// a healthy mount never trips it, short enough to explain a stream that hangs.
const RAIN_STALL_GRACE_MS = 700;
let rainToggling = false;
let rainBtnEl: HTMLButtonElement | null = null;
let rainSpinnerShown = false;
/** Whether the icon is currently showing the raining cloud. */
let rainVisualOn = false;
/** A click is waiting for its pop, played when the icon actually changes face. */
let rainPopPending = false;
/** When the bed last started being wanted but silent (0 when there's nothing to wait for). */
let rainSilentSince = 0;
/**
* Reconcile the icon with what is *audible*, not with what was asked for.
*
* Switching the face on the click looked responsive but lied for as long as the
* stream took to arrive, and the spinner then had to cut across a cloud that was
* already raining. So the sun holds until the bed plays, the spinner fills the
* wait once it stops being plausibly instant, and the cloud only ever appears
* over rain you can hear. Switching off needs none of that: the intent alone
* turns the face back, so the click stays immediate.
*/
function updateRainButton(): void {
if (!rainBtnEl) return;
const now = performance.now();
const playing = radio?.isRainPlaying() ?? false;
const waiting = rainEnabled && audioUnlocked && !playing;
if (!waiting) {
rainSilentSince = 0;
} else if (rainSilentSince === 0) {
rainSilentSince = now;
}
const stalled = rainSilentSince > 0 && now - rainSilentSince >= RAIN_STALL_GRACE_MS;
if (stalled !== rainSpinnerShown) {
rainSpinnerShown = stalled;
rainBtnEl.classList.toggle("loading", stalled);
syncRainBtnDisabled();
}
const visual = rainEnabled && playing;
if (visual === rainVisualOn) {
// Switching off a mood whose face never appeared owes no pop.
if (!rainEnabled) rainPopPending = false;
return;
}
rainVisualOn = visual;
rainBtnEl.classList.toggle("toggled", visual);
// Nothing to celebrate when the bed comes back on its own, or when the app
// mounts with the mood already saved on: the pop belongs to the click.
if (rainPopPending) {
rainPopPending = false;
playRainPop(visual);
}
}
/** The button takes no clicks while it switches or while the spinner shows. */
function syncRainBtnDisabled(): void {
if (rainBtnEl) rainBtnEl.disabled = rainToggling || rainSpinnerShown;
}
function initRainToggle(): void {
const btn = document.getElementById("rain-btn") as HTMLButtonElement | null;
rainBtnEl = btn;
// Fresh button on every mount, so the tracked state has to start over or the
// classes and the flags can disagree after a leave/enter.
rainSpinnerShown = false;
rainVisualOn = false;
rainPopPending = false;
rainSilentSince = 0;
syncRainUi();
btn?.addEventListener("click", (e) => {
e.stopPropagation();
if (rainToggling) return;
rainToggling = true;
syncRainBtnDisabled();
void (async () => {
try {
await ensureUnlocked();
rainEnabled = !rainEnabled;
rainPopPending = true;
saveRainEnabled(rainEnabled);
syncRainUi();
updateRainButton();
} finally {
window.setTimeout(() => {
rainToggling = false;
syncRainBtnDisabled();
}, RAIN_SWITCH_LOCK_MS);
}
})();
});
}
function initAmbienceControls(): void {
const range = document.getElementById("rain-level") as HTMLInputElement | null;
syncRainUi();
range?.addEventListener("input", () => {
rainLevel = Number(range.value) / 100;
saveRainLevel(rainLevel);
range.setAttribute("aria-valuenow", range.value);
const valueEl = document.getElementById("rain-level-value");
if (valueEl) valueEl.textContent = `${range.value}%`;
radio?.setRainLevel(rainLevel);
});
}
// ───────────────────── Unlock overlay (autoplay policy) ─────────────────────
function initUnlockOverlay(): void {
const overlay = document.getElementById("unlock-overlay");
const btn = document.getElementById("unlock-btn");
if (!overlay) return;
btn?.addEventListener("click", (e) => {
e.stopPropagation();
void unlockAudio();
});
overlay.addEventListener("pointerdown", (e) => {
if (e.target === overlay) void unlockAudio();
});
}
let unlockLoading = false;
function setUnlockLoading(loading: boolean): void {
unlockLoading = loading;
const btn = document.getElementById("unlock-btn") as HTMLButtonElement | null;
const spinner = document.getElementById("unlock-btn-spinner");
const label = btn?.querySelector<HTMLElement>(".unlock-cta-label");
if (btn) {
btn.disabled = loading;
btn.classList.toggle("loading", loading);
btn.setAttribute("aria-busy", loading ? "true" : "false");
}
if (spinner) spinner.hidden = !loading;
if (label) label.hidden = loading;
}
/** No-op once unlocked; otherwise starts audio (needs a user gesture). */
function ensureUnlocked(): Promise<void> {
return audioUnlocked ? Promise.resolve() : unlockAudio();
}
async function unlockAudio(): Promise<void> {
if (audioUnlocked || unlockLoading) return;
setUnlockLoading(true);
// Before the first await: WebKit only lifts its per-element playback gate
// while the tap's activation is live, and the stream connect below outlasts
// it by many seconds. See RadioAudio.primeForGesture.
radio?.primeForGesture();
try {
await radio?.ensureStarted();
// Second look at the sender, now that the graph exists. The first one runs
// before there is anything to hand it, and it is the only other one there
// is, so a session that came up without an audio sender gets its one chance
// to recover here rather than staying on the local speakers for good.
if (!radio?.hasSender()) attachAudioSender();
const vol0 = volumeFromAngle(volumeAngle);
volTarget01 = vol0;
volCommand01 = vol0;
applyVolume(vol0);
// Hold both antennas, glide them to the resting "V" (left = saved station,
// right = current volume), then free both so they can be hand-tuned.
const leftRest = tuningArcToLeft(frame.intent);
const rightRest = volumeToRight(vol0);
leftHeld = true;
rightHeld = true;
reachyRef?.setMotorTorque(true, [LEFT_MOTOR, RIGHT_MOTOR]);
try {
reachyRef?.gotoTarget({ antennas: [rightRest, leftRest], duration: 0.6 });
} catch {
/* channel closed */
}
physLeft = leftRest;
physRight = rightRest;
// Let them reach the V before going compliant, so the rest pose is visible.
// Readbacks stay out of the model until then: mid-glide the antenna is far
// from the restored station and would drag the needle along with it.
window.setTimeout(() => {
tuning.placeAt(frame.intent, performance.now());
armLive = true;
if (frame.command == null) setLeftHold(false);
if (!volDriving) setRightHold(false);
}, 700);
const station = stations[frame.index] ?? stations[frame.nearest] ?? stations[0];
if (station) {
await radio?.playStation(station.url);
nowPlaying?.watch(station.url);
}
audioUnlocked = true;
const unlockEl = document.getElementById("unlock-overlay");
if (unlockEl) closeOverlay(unlockEl);
const sender = radio?.hasSender() ? findAudioSender() : null;
setStatus(sender ? "" : "robot has no audio input");
if (sender) void verifyRobotAudio(sender);
void verifyGraphCapture();
scheduleSaveTuning();
} catch (err) {
setStatus(`audio failed: ${String(err)}`);
setUnlockLoading(false);
}
}
// ───────────────────── Manage-stations modal ─────────────────────
let searchAbort: AbortController | null = null;
/** Search results ride an anchored dropdown under the input (see dropdown.ts). */
let searchDropdown: Dropdown | null = null;
/** Pause after the last keystroke before a live search fires. */
const SEARCH_DEBOUNCE_MS = 350;
const stationUrls = (): Set<string> => new Set(stations.map((s) => s.url.toLowerCase()));
function initModal(): void {
const overlay = document.getElementById("manage-modal");
const openBtn = document.getElementById("manage-btn");
const closeBtn = document.getElementById("manage-close");
const resetBtn = document.getElementById("reset-defaults");
const form = document.getElementById("search-form") as HTMLFormElement | null;
const input = document.getElementById("search-input") as HTMLInputElement | null;
if (!overlay || !form || !input) return;
const resultsEl = document.getElementById("search-results");
const groupEl = resultsEl?.closest<HTMLElement>(".search-group");
const clearBtn = document.getElementById("search-clear") as HTMLButtonElement | null;
const modalBody = overlay.querySelector<HTMLElement>(".modal-body");
searchDropdown =
resultsEl && groupEl
? createDropdown(groupEl, resultsEl, { fitWithin: modalBody ?? undefined })
: null;
initAmbienceControls();
// ── Search behaviour ────────────────────────────────────────────────
// Results arrive as you type (debounced); Enter / the Search button skip
// the debounce. The clear cross resets everything in one tap.
let searchDebounce = 0;
const syncClear = (): void => {
if (clearBtn) clearBtn.hidden = !input.value;
};
const resetSearch = (): void => {
window.clearTimeout(searchDebounce);
searchAbort?.abort();
if (resultsEl) resultsEl.innerHTML = "";
searchDropdown?.close();
};
input.addEventListener("input", () => {
syncClear();
window.clearTimeout(searchDebounce);
const q = input.value.trim();
// Below two characters a live query is all noise; and results left over
// from a longer query would be stale, so they go away instead.
if (q.length < 2) {
resetSearch();
return;
}
searchDebounce = window.setTimeout(() => void runSearch(input.value), SEARCH_DEBOUNCE_MS);
});
// Coming back to a query whose results were dismissed: bring them back.
input.addEventListener("focus", () => {
if (input.value.trim() && resultsEl?.childElementCount) searchDropdown?.open();
});
clearBtn?.addEventListener("click", () => {
resetSearch();
input.value = "";
syncClear();
input.focus();
});
form.addEventListener("submit", (e) => {
e.preventDefault();
window.clearTimeout(searchDebounce);
void runSearch(input.value);
});
const open = (): void => {
openOverlay(overlay);
syncRainUi();
renderCurrentList();
input.focus();
};
const close = (): void => {
closeOverlay(overlay);
window.clearTimeout(searchDebounce);
searchDropdown?.close();
searchAbort?.abort();
};
openBtn?.addEventListener("click", open);
closeBtn?.addEventListener("click", close);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !overlay.hidden) close();
});
resetBtn?.addEventListener("click", () => {
clearSavedTuning();
stations = saveStations(resetToDefaults());
applyInitialTuning();
rebuildRing();
renderCurrentList();
});
initStationListDnD();
}
async function runSearch(query: string): Promise<void> {
const results = document.getElementById("search-results");
if (!results) return;
const q = query.trim();
if (!q) {
results.innerHTML = "";
searchDropdown?.close();
return;
}
searchAbort?.abort();
searchAbort = new AbortController();
searchDropdown?.open();
results.innerHTML = `<p class="list-empty">Searching…</p>`;
try {
const hits = await searchStations(q, searchAbort.signal);
if (!hits.length) {
results.innerHTML = `<p class="list-empty">No results for "${escapeHtml(q)}".</p>`;
return;
}
const have = stationUrls();
results.innerHTML = hits
.map((h, i) => {
const meta = [h.codec.toUpperCase(), h.bitrate ? `${h.bitrate} kbps` : "", h.country]
.filter(Boolean)
.join(" Β· ");
const already = have.has(h.url.toLowerCase());
return `
<div class="list-row" data-i="${i}">
<span class="row-info">
<span class="row-name">${escapeHtml(h.name)}</span>
<span class="row-meta">${escapeHtml([h.tags.join(", "), meta].filter(Boolean).join(" β€” "))}</span>
</span>
<button class="btn btn-compact row-action" data-i="${i}" ${already ? `data-state="added" disabled` : ""}>
${already ? "Added" : "Add"}
</button>
</div>`;
})
.join("");
results.querySelectorAll<HTMLButtonElement>(".row-action").forEach((btn) => {
btn.addEventListener("click", () => void addFromSearch(hits[Number(btn.dataset.i)]!, btn));
});
syncAddButtons();
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
results.innerHTML = `<p class="list-empty">Search failed: ${escapeHtml(String(err))}</p>`;
}
}
/**
* Search rows that are neither added nor blocked follow the dial's remaining
* room, so filling or freeing a slot is reflected on results already on screen.
*/
function syncAddButtons(): void {
const full = stations.length >= MAX_STATIONS;
document.querySelectorAll<HTMLButtonElement>("#search-results .row-action").forEach((btn) => {
if (btn.dataset.state) return;
btn.disabled = full;
btn.classList.toggle("primary", !full);
btn.textContent = full ? "Dial full" : "Add";
});
}
async function addFromSearch(hit: SearchResult, btn: HTMLButtonElement): Promise<void> {
if (stationUrls().has(hit.url.toLowerCase())) return;
if (stations.length >= MAX_STATIONS) return;
btn.disabled = true;
btn.textContent = "Checking…";
const ok = await probeCors(hit.url);
if (!ok) {
btn.dataset.state = "blocked";
btn.textContent = "CORS-blocked";
btn.classList.remove("primary");
btn.classList.add("blocked");
return;
}
stations = saveStations([
...stations,
{ name: hit.name, url: hit.url, tags: hit.tags, country: hit.country, robot: true },
]);
rebuildRing();
btn.dataset.state = "added";
btn.textContent = "Added";
btn.classList.remove("primary");
renderCurrentList();
}
function reorderStations(from: number, to: number): void {
if (from === to || from < 0 || to < 0 || from >= stations.length || to >= stations.length) return;
const next = [...stations];
const [moved] = next.splice(from, 1);
if (!moved) return;
next.splice(to, 0, moved);
stations = saveStations(next);
rebuildRing();
renderCurrentList();
}
let stationDragFrom: number | null = null;
function initStationListDnD(): void {
const list = document.getElementById("current-list");
if (!list || list.dataset.dndInit === "1") return;
list.dataset.dndInit = "1";
const clearDropTargets = (): void => {
list.querySelectorAll(".station-row.drop-target").forEach((el) => el.classList.remove("drop-target"));
};
list.addEventListener("dragstart", (e) => {
const row = (e.target as HTMLElement).closest<HTMLElement>(".station-row");
if (!row || !list.contains(row)) return;
if ((e.target as HTMLElement).closest(".remove-station, .row-name-input")) {
e.preventDefault();
return;
}
const from = Number(row.dataset.i);
if (Number.isNaN(from)) return;
stationDragFrom = from;
row.classList.add("is-dragging");
e.dataTransfer?.setData("text/plain", String(from));
e.dataTransfer!.effectAllowed = "move";
});
list.addEventListener("dragover", (e) => {
if (stationDragFrom == null) return;
e.preventDefault();
const row = (e.target as HTMLElement).closest<HTMLElement>(".station-row");
clearDropTargets();
if (row && list.contains(row)) row.classList.add("drop-target");
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
});
list.addEventListener("dragleave", (e) => {
const related = e.relatedTarget as Node | null;
if (related && list.contains(related)) return;
clearDropTargets();
});
list.addEventListener("drop", (e) => {
e.preventDefault();
clearDropTargets();
const from = stationDragFrom;
stationDragFrom = null;
if (from == null) return;
const row = (e.target as HTMLElement).closest<HTMLElement>(".station-row");
if (!row) return;
const to = Number(row.dataset.i);
if (Number.isNaN(to)) return;
reorderStations(from, to);
});
list.addEventListener("dragend", () => {
stationDragFrom = null;
clearDropTargets();
list.querySelectorAll(".station-row.is-dragging").forEach((el) => el.classList.remove("is-dragging"));
});
}
const DRAG_HANDLE_SVG = `<svg viewBox="0 0 10 16" width="10" height="16" aria-hidden="true"><circle cx="3" cy="3" r="1.2" fill="currentColor"/><circle cx="7" cy="3" r="1.2" fill="currentColor"/><circle cx="3" cy="8" r="1.2" fill="currentColor"/><circle cx="7" cy="8" r="1.2" fill="currentColor"/><circle cx="3" cy="13" r="1.2" fill="currentColor"/><circle cx="7" cy="13" r="1.2" fill="currentColor"/></svg>`;
function renderCurrentList(): void {
const list = document.getElementById("current-list");
const count = document.getElementById("current-count");
if (count) count.textContent = `${stations.length}/${MAX_STATIONS}`;
syncAddButtons();
if (!list) return;
if (!stations.length) {
list.innerHTML = `<p class="list-empty">No stations yet.</p>`;
return;
}
list.innerHTML = stations
.map(
(s, i) => `
<div class="list-row station-row" draggable="true" data-i="${i}">
<span class="drag-handle" aria-hidden="true">${DRAG_HANDLE_SVG}</span>
<span class="row-info">
<input class="row-name-input" data-i="${i}" value="${escapeHtml(s.name)}"
maxlength="40" spellcheck="false" autocomplete="off"
aria-label="Rename ${escapeHtml(s.name)}" />
<span class="row-meta">${escapeHtml(stationDescription(s) || s.url)}</span>
</span>
<button class="icon-btn icon-btn-sm remove-station" data-i="${i}" aria-label="Remove ${escapeHtml(s.name)}">&times;</button>
</div>`,
)
.join("");
list.querySelectorAll<HTMLButtonElement>(".remove-station").forEach((btn) => {
btn.addEventListener("click", () => {
const i = Number(btn.dataset.i);
if (Number.isNaN(i)) return;
stations = saveStations(stations.filter((_, idx) => idx !== i));
rebuildRing();
renderCurrentList();
});
});
list.querySelectorAll<HTMLInputElement>(".row-name-input").forEach((input) => {
const row = input.closest<HTMLElement>(".station-row");
// A draggable ancestor hijacks text selection inside the field, so the
// row stops being draggable for as long as the name is being edited.
input.addEventListener("pointerdown", () => {
if (row) row.draggable = false;
});
input.addEventListener("focus", () => {
if (row) row.draggable = false;
});
input.addEventListener("blur", () => {
if (row) row.draggable = true;
// An emptied field falls back to the URL in the model (see `sanitize`);
// reflect whatever actually got saved.
const s = stations[Number(input.dataset.i)];
if (s) input.value = s.name;
});
// Saved on every keystroke: the dial label and "now playing" follow live.
input.addEventListener("input", () => {
const i = Number(input.dataset.i);
if (!stations[i]) return;
stations[i].name = input.value.trim() || stations[i].url;
stations = saveStations(stations);
refreshTunerDial();
});
input.addEventListener("keydown", (e) => {
// Enter commits, Escape leaves the field - neither may reach the
// document handler that closes the whole settings modal.
if (e.key === "Enter" || e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
input.blur();
}
});
});
}
/**
* Rebuild the tuning ring after the station list changed: recompute angles,
* re-render the dial ticks + station rows, and force the loop to re-resolve
* the current station.
*/
function rebuildRing(): void {
const prevAngle = frame.intent;
const prevUrl = stations[frame.index]?.url;
angles = stationPositions(stations.length);
refreshTunerDial();
tuning.setStations(angles);
// Follow the station the user was on to its new slot; if it is gone, stay put
// on the arc and let the model re-resolve what we are nearest to.
const now = performance.now();
const idx = prevUrl
? stations.findIndex((s) => s.url.toLowerCase() === prevUrl.toLowerCase())
: -1;
if (idx >= 0) tuning.jumpTo(idx, now);
else tuning.placeAt(stations.length ? prevAngle : 0, now);
frame = tuning.frame();
updateNeedle();
updateKnobScale();
scheduleSaveTuning();
}
// ───────────────────── Controls ─────────────────────
function initControls(): void {
attachAudioSender();
}
/**
* The robot's outbound audio sender, or null when the session negotiated no
* audio at all.
*
* Matched through the transceiver rather than through `sender.track`: the SDK
* seeds the sender with a silent placeholder, and a sender whose track is null
* is precisely the one we are about to `replaceTrack()` on. Requiring a track
* to already be there is what makes this miss, and a miss is not recoverable on
* its own β€” `setSender(null)` latches the mix onto the local speakers for the
* rest of the session.
*/
function findAudioSender(): RTCRtpSender | null {
const pc = reachyRef?._pc;
if (!pc) return null;
const audio = pc
.getTransceivers()
.find((t) => (t.sender.track?.kind ?? t.receiver.track?.kind) === "audio");
return audio?.sender ?? null;
}
/** Point the mixed bus at the robot. False when it has no audio input. */
function attachAudioSender(): boolean {
const sender = findAudioSender();
radio?.setSender(sender);
radio?.setMode("robot");
return !!sender;
}
/** Wait for the station to settle before probing the mixed bus. */
const CAPTURE_PROBE_DELAY_MS = 6_000;
/** Analyser reads, 250 ms apart, that must all be silent to call it broken. */
const CAPTURE_PROBE_READS = 8;
/**
* Detect WebKit's broken `createMediaElementSource` (iOS / WKWebView): the
* `<audio>` element keeps playing on the device's own output and the Web
* Audio graph β€” the robot's feed β€” renders silence. Regressions tracked in
* WebKit bugs 203435 and 260412; there is no JS-side workaround short of not
* using media elements at all, so the honest thing to do is say it.
*/
async function verifyGraphCapture(): Promise<void> {
await new Promise((resolve) => window.setTimeout(resolve, CAPTURE_PROBE_DELAY_MS));
if (!audioUnlocked || !radio) return;
let peak = 0;
for (let i = 0; i < CAPTURE_PROBE_READS; i++) {
peak = Math.max(peak, radio.readPulse().level);
await new Promise((resolve) => window.setTimeout(resolve, 250));
}
if (!radio || !radio.isStationAudible() || peak > 0.001) return;
console.warn(
"[radio] station is playing but the web-audio capture is silent (WebKit createMediaElementSource bug)",
);
setStatus("this browser can't route audio to Reachy - it plays here instead");
}
/** How long to watch the outbound stream before calling it silent. */
const OUTBOUND_PROBE_MS = 5_000;
/**
* Check the robot is really being fed, shortly after unlock.
*
* Everything upstream can look healthy while Reachy stays silent: the audio
* m-line is negotiated whatever ends up on the sender, so neither the SDP nor
* the daemon's own logs tell a live mix apart from a muted one β€” the daemon
* sets up its playback pipeline either way. Outbound RTP is the first place the
* difference is visible, which makes it the only honest thing to report on.
*/
async function verifyRobotAudio(sender: RTCRtpSender): Promise<void> {
const bytesSent = async (): Promise<number> => {
let bytes = 0;
for (const report of (await sender.getStats()).values()) {
if (report.type === "outbound-rtp" && report.kind === "audio") {
bytes = report.bytesSent ?? 0;
}
}
return bytes;
};
const before = await bytesSent();
await new Promise((resolve) => window.setTimeout(resolve, OUTBOUND_PROBE_MS));
if (!audioUnlocked || radio?.getMode() !== "robot") return;
const after = await bytesSent();
if (after > before) return;
console.warn("[radio] no outbound audio to the robot", { before, after });
setStatus("robot not receiving audio");
}
// ───────────────────── Tuning + pose loop ─────────────────────
function startLoop(handle: ConnectedHandle<unknown>): void {
const { reachy } = handle;
let lastSend = 0;
let lastPoll = 0;
let lastHealth = 0;
const t0 = performance.now();
/**
* All robot- and audio-critical work: state polling, tuning resolution,
* station (re)connect, and pose streaming. Driven by the Web Worker heartbeat
* so it keeps running when the tab is hidden (rAF/`setInterval` get throttled).
* Never touches the DOM β€” visuals are handled separately by `renderVisuals`.
*/
const stepLogic = (now: number): void => {
if (audioUnlocked && now - lastPoll >= STATE_POLL_MS) {
lastPoll = now;
try {
reachy.requestState();
} catch {
/* channel closed */
}
const left = reachy.robotState?.antennas?.[1];
if (typeof left === "number") {
physLeft = left;
if (armLive) tuning.readArm(leftToTuningArc(left), now);
}
const right = reachy.robotState?.antennas?.[0];
if (typeof right === "number") {
physRight = right;
// The right antenna is the volume dial: mirror hand movements to the
// gain + on-screen knob unless we're currently commanding it.
if (!volDriving) {
const v = rightToVolume(right);
if (Math.abs(v - volumeFromAngle(volumeAngle)) > 0.01) applyVolume(v);
}
}
}
// Poke the stream health check off the throttled `setInterval` so stalls are
// caught and reconnected even while backgrounded.
if (now - lastHealth >= 2_500) {
lastHealth = now;
radio?.runHealthCheck();
}
const prevIntent = frame.intent;
applyFrame(tuning.update(now));
if (Math.abs(frame.intent - prevIntent) > 1e-4) scheduleSaveTuning();
// The stream follows the *captured* station only. Drifting through static
// between two presets leaves the current mount open instead of tearing it
// down and reconnecting for a station we never landed on.
const station = stations[frame.index];
if (frame.index !== tunedIndex) {
tunedIndex = frame.index;
if (audioUnlocked && station) tuneTo(station.url);
} else if (audioUnlocked && station && radio?.needsReload(station.url)) {
void radio.playStation(station.url);
}
radio?.setTuning(frame.distance, audioUnlocked && frame.index >= 0);
stepVolCommand(now);
if (audioUnlocked && now - lastSend >= POSE_SEND_MS) {
lastSend = now;
streamPose(reachy, (now - t0) / 1000);
}
};
// Visual-only pass. Safe to pause when hidden β€” rAF resumes on its own.
const renderVisuals = (): void => {
updateNeedle();
updateTuningUI();
updateHeroTrace();
updateRainButton();
};
const visualTick = (): void => {
rafId = requestAnimationFrame(visualTick);
renderVisuals();
};
rafId = requestAnimationFrame(visualTick);
// Heartbeat: prefer a Web Worker (not visibility-throttled); fall back to a
// main-thread interval if workers are unavailable.
try {
poseWorker = new Worker(new URL("./pose-heartbeat.worker.ts", import.meta.url), {
type: "module",
});
poseWorker.onmessage = (): void => stepLogic(performance.now());
} catch {
poseWorker = null;
poseFallbackTimer = window.setInterval(() => stepLogic(performance.now()), POSE_SEND_MS);
}
// On returning to the foreground, resync immediately instead of waiting for
// the next scheduled tick (state read + pose/tuning re-assert + repaint).
const onVisibility = (): void => {
if (document.visibilityState !== "visible") return;
try {
reachy.requestState();
} catch {
/* channel closed */
}
stepLogic(performance.now());
renderVisuals();
};
document.addEventListener("visibilitychange", onVisibility);
addInputCleanup(() => document.removeEventListener("visibilitychange", onVisibility));
}
/**
* Head + body bob to the music, plus antenna targets.
*
* We only command an antenna while the UI is actively driving it (its torque is
* enabled then). The antenna that is NOT being driven keeps its last physical
* readback as its target, so we never fight the user's hand nor snap it to 0.
* During pure hand control we leave `antennas` out entirely so both freed
* motors stay under the user's hands. Tap-jumps own the antennas via
* `gotoTarget`.
*/
function streamPose(reachy: Reachy, t: number): void {
if (!audioUnlocked) return;
const amp = frame.gain;
const pitch = amp * 7 * Math.sin(t * 5.0);
const roll = amp * 4 * Math.sin(t * 2.6);
const bodyYaw = amp * 5 * Math.sin(t * 1.3);
const args: { head: number[]; body_yaw: number; antennas?: number[] } = {
head: rpyToMatrix(roll, pitch, 0).flat(),
body_yaw: degToRad(bodyYaw),
};
if (frame.command != null || volDriving) {
const leftCmd = frame.command != null ? tuningArcToLeft(frame.command) : physLeft;
const rightCmd = volDriving ? volumeToRight(volCommand01) : physRight;
args.antennas = [rightCmd, leftCmd];
}
try {
reachy.setTarget(args);
} catch {
/* channel closed between frames */
}
}
/**
* Last value written for each dial property. The render pass runs at 60 Hz but
* the model only moves when something actually happens, so re-writing identical
* values is pure churn β€” and it keeps restarting the needle's CSS transition.
*/
const painted = new Map<string, string>();
/** Write a DOM property only when its value changed since the last frame. */
function paint(key: string, value: string, apply: (value: string) => void): void {
if (painted.get(key) === value) return;
painted.set(key, value);
apply(value);
}
/** Last value actually drawn for each dial position, in that position's own unit. */
const held = new Map<string, number>();
/**
* Hold the drawn value until the new one has moved further than `deadband`.
*
* Even one-euro filtered, the antenna readback keeps shivering by a fraction of
* a degree, and the 7.8x arc amplification turns that into a needle that never
* quite sits still on the real robot. `paint` cannot help: every frame genuinely
* is a new value. Rounding would only move the jitter onto the rounding
* boundary, so the last drawn value has to be sticky instead.
*
* This is presentation only β€” the model, the audio and the motor command all
* still see the unfiltered frame.
*/
function sticky(key: string, value: number, deadband: number): number {
const drawn = held.get(key);
if (drawn !== undefined && Math.abs(value - drawn) < deadband) return drawn;
held.set(key, value);
return value;
}
/**
* Deadbands, small enough to read as "perfectly still" rather than as lag: the
* track is 280Β° of arc over 100%, so 0.25% is about 0.7Β° of arc β€” under two
* pixels on screen, and a tenth of a degree of actual antenna travel.
*/
const NEEDLE_DEADBAND_PCT = 0.25;
const KNOB_DEADBAND_DEG = 0.5;
function updateNeedle(): void {
const needlePct = sticky(
"needle",
clamp(trackPercentFromAngle(frame.intent), 0, 100),
NEEDLE_DEADBAND_PCT,
);
// The ghost tracks the raw arm, so it is the jitteriest thing on screen.
const armPct = sticky(
"ghost",
clamp(trackPercentFromAngle(frame.arm), 0, 100),
NEEDLE_DEADBAND_PCT,
);
const ghost = frame.showGhost && audioUnlocked;
const needleEl = document.getElementById("tuner-needle-target");
const armEl = document.getElementById("tuner-needle-actual");
if (needleEl) {
paint("needle", needlePct.toFixed(2), (v) => (needleEl.style.left = `${v}%`));
}
if (armEl) {
paint("ghost-x", armPct.toFixed(2), (v) => (armEl.style.left = `${v}%`));
paint("ghost-on", String(ghost), () => (armEl.hidden = !ghost));
}
paint("aria", String(Math.round(needlePct)), (v) =>
document.getElementById("tuner-track")?.setAttribute("aria-valuenow", v),
);
const knobDeg = sticky("knob", (frame.intent * 180) / Math.PI, KNOB_DEADBAND_DEG);
paint("knob", knobDeg.toFixed(2), (v) => {
const knob = document.getElementById("knob-face");
if (knob) knob.style.transform = `rotate(${v}deg)`;
});
}
function updateTuningUI(): void {
const kickerEl = document.getElementById("np-kicker");
const nameEl = document.getElementById("np-name");
const genreEl = document.getElementById("np-genre");
const station = stations[frame.index];
const preview = station ?? stations[frame.nearest];
// A single hysteretic source of truth: no more three booleans disagreeing
// with each other one frame out of two.
const tuned = !!station;
const kicker = !audioUnlocked
? "Ready"
: !stations.length
? "Stations"
: tuned
? "Now playing"
: "Scanning";
if (kickerEl) paint("kicker", kicker, (v) => (kickerEl.textContent = v));
const name = !audioUnlocked
? (preview?.name ?? "Radio")
: !stations.length
? "No stations"
: station
? station.name
: "Off dial";
const nameMood = !audioUnlocked
? preview
? ""
: "idle"
: !stations.length
? "idle"
: station
? ""
: "off-dial";
if (nameEl) {
paint("name", name, (v) => (nameEl.textContent = v));
paint("name-mood", nameMood, (v) => {
nameEl.classList.remove("off-dial", "idle");
if (v) nameEl.classList.add(v);
});
}
const blurb = statusOverride
? statusOverride
: streamBuffering && audioUnlocked && tuned
? // No trailing ellipsis: the spinner beside it is the one doing that job.
navigator.onLine
? "buffering"
: "offline"
: !audioUnlocked
? sentenceCase(stationDescription(preview)) || "tap below to start listening"
: !stations.length
? "open Manage to add stations"
: station
? // The track on air when the station publishes one, its tags otherwise.
sentenceCase(nowPlayingTitle ?? stationDescription(station))
: "nudge the tuner to lock in";
if (genreEl) paint("blurb", blurb, (v) => (genreEl.textContent = v));
const active = audioUnlocked && tuned ? frame.index : -1;
paint("active-tick", String(active), () => {
document.querySelectorAll<HTMLElement>(".tuner-label").forEach((el, i) => {
el.classList.toggle("active", i === active);
});
document.querySelectorAll<HTMLElement>(".tuner-station-tick").forEach((el, i) => {
el.classList.toggle("active", i === active);
});
});
}
/**
* Seismograph trace between the icon's antennas, driven by the mixed audio bus.
*
* A window pinches the shape shut at both ends, so it reads as anchored between
* the antennas rather than floating across the illustration.
*/
function updateHeroTrace(): void {
const svg = document.querySelector<SVGElement>(".chassis-trace");
const trace = document.getElementById("hero-trace");
if (!svg || !trace) return;
svg.toggleAttribute("hidden", !audioUnlocked);
if (!audioUnlocked) {
traceHistory.fill(0);
traceSwell = 0;
traceLevelLow = 1;
traceLevelHigh = 0;
return;
}
// Only on a step: between two readings the shape is unchanged, and rebuilding
// the same path on every frame would just be work for an identical string.
const now = performance.now();
if (now - traceLastStep < TRACE_STEP_MS) return;
traceLastStep = now;
traceHistory.shift();
traceHistory.push(readTraceSample());
trace.setAttribute("d", traceOutline());
}
/**
* Height this reading contributes, at the entry end of the trace.
*
* Every term here is a measurement. Nothing in the shape has a wavelength or a
* phase of its own, so there is no part of the drawing the sound does not own.
*/
function readTraceSample(): number {
const pulse = radio?.readPulse();
if (!pulse) {
traceSwell -= traceSwell * TRACE_RELEASE;
return traceSwell;
}
traceLevelLow = Math.min(pulse.level, traceLevelLow + TRACE_RANGE_CREEP);
traceLevelHigh = Math.max(pulse.level, traceLevelHigh - TRACE_RANGE_CREEP);
const span = Math.max(TRACE_RANGE_MIN_SPAN, traceLevelHigh - traceLevelLow);
const relative = Math.max(0, Math.min(1, (pulse.level - traceLevelLow) / span));
const presence = Math.min(1, pulse.level * TRACE_PRESENCE_GAIN);
// The flux is squared, so only a real attack punches through and a busy mix
// does not keep the line permanently raised.
const target =
Math.pow(relative, TRACE_CONTRAST) * TRACE_SWING +
pulse.flux * pulse.flux * TRACE_KICK +
presence * TRACE_PRESENCE_LIFT;
traceSwell += (target - traceSwell) * (target > traceSwell ? TRACE_ATTACK : TRACE_RELEASE);
return traceSwell;
}
/**
* The history as one closed outline: out along the upper crest and back along
* the lower one.
*
* Both collapse onto the baseline at the ends, so the shape shuts on itself and
* a single path carries the two crests and the body between them.
*/
function traceOutline(): string {
const step = (TRACE_X1 - TRACE_X0) / (TRACE_POINTS - 1);
const upper: Array<[number, number]> = [];
const lower: Array<[number, number]> = [];
for (let i = 0; i < TRACE_POINTS; i++) {
// Never below the baseline, so the shape rests on it instead of straddling it.
const height = Math.max(0, traceHistory[i] ?? 0);
const offset = height * traceWindow(i / (TRACE_POINTS - 1));
const x = TRACE_X0 + i * step;
upper.push([x, TRACE_Y - offset]);
lower.push([x, TRACE_Y + offset]);
}
lower.reverse();
return `${smoothPath(upper)} ${smoothSegments(lower)} Z`;
}
/**
* Raised-cosine window closing the shape at both ends, flat in between.
*
* Without it the shape would be cut off mid-swing at the antennas and read as a
* fragment of something larger.
*/
function traceWindow(t: number): number {
// Readings enter at t = 1, and whatever the window takes away there is delay
// on every hit, so that side gets the short ramp.
const span = t < 0.5 ? TRACE_TAPER_OUT : TRACE_TAPER_IN;
const edge = Math.min(t, 1 - t);
if (edge >= span) return 1;
return 0.5 - 0.5 * Math.cos((Math.PI * edge) / span);
}
/**
* Catmull-Rom through the points, emitted as cubic beziers.
*
* A polyline through this many points reads as a row of corners at this size;
* the curve is what makes it look drawn rather than plotted.
*/
function smoothPath(points: Array<[number, number]>): string {
const first = points[0];
if (!first || points.length < 2) return "";
return `M ${first[0].toFixed(1)} ${first[1].toFixed(1)} ${smoothSegments(points)}`;
}
/** The same curve without its opening move, to append to an open path. */
function smoothSegments(points: Array<[number, number]>): string {
if (points.length < 2) return "";
const at = (i: number): [number, number] =>
points[Math.max(0, Math.min(points.length - 1, i))] ?? [0, 0];
let d = "";
for (let i = 0; i < points.length - 1; i++) {
const [p0x, p0y] = at(i - 1);
const [p1x, p1y] = at(i);
const [p2x, p2y] = at(i + 1);
const [p3x, p3y] = at(i + 2);
const c1x = p1x + (p2x - p0x) / 6;
const c1y = p1y + (p2y - p0y) / 6;
const c2x = p2x - (p3x - p1x) / 6;
const c2y = p2y - (p3y - p1y) / 6;
d +=
` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}` +
` ${c2x.toFixed(1)} ${c2y.toFixed(1)}` +
` ${p2x.toFixed(1)} ${p2y.toFixed(1)}`;
}
return d.trimStart();
}
// ───────────────────── Helpers ─────────────────────
function setBuffering(active: boolean): void {
streamBuffering = active;
const spinner = document.getElementById("np-spinner");
if (spinner) spinner.hidden = !active;
document.getElementById("np-blurb")?.setAttribute("aria-busy", active ? "true" : "false");
}
function setStatus(msg: string): void {
statusOverride = msg || null;
}
/**
* One capital at the front and none after it, for text coming off a stream.
*
* Stations publish in whatever case suits them and shouting is common, but
* flattening unconditionally would wreck the titles that are cased properly:
* "Miles Davis - So What" is not something to lowercase. A string already
* mixing cases is carrying real proper nouns, so only the ones with nothing
* lowercase in them get taken down.
*/
function sentenceCase(text: string): string {
const flat = text === text.toUpperCase() ? text.toLowerCase() : text;
return flat.charAt(0).toUpperCase() + flat.slice(1);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
void main().catch((err) => {
console.error("[reachy-radio] boot failed", err);
window.parent.postMessage(
{
source: "reachy-mini",
type: "embed:error",
version: 1,
message: String(err),
fatal: true,
},
window.location.origin,
);
});