On iOS / WKWebView the media element is never rerouted into the Web
Audio graph (WebKit bugs 203435, 260412): the phone keeps playing the
station on its own output while the robot's MediaStreamDestination feed
renders silence. Probe the mixed bus a few seconds after unlock and
surface an honest status instead of leaving the user to wonder why the
sound comes out of the wrong speaker.
Co-authored-by: Cursor <cursoragent@cursor.com>
fix(audio): prime media elements in the unlock tap so WebKit allows the late play()
The unlock flow spends seconds between the tap and el.play() (context
resume, sender attach, stream connect with an 18s data timeout). WebKit
gates HTMLMediaElement playback on a live user activation per element,
so by then play() rejects with NotAllowedError and the engine burns its
retry budget: on the mobile app's WKWebView the spinner looks stuck.
Calling play() on both elements synchronously in the tap handler lifts
the restriction while the gesture is live.
Also stop trusting AudioContext.resume() to ever settle - WebKit can
leave it pending forever - by racing it against a 3s timeout.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(ui): use icon in unlock modal with Reachy Radio title
The sender was looked up by sender.track, but the SDK seeds it with a
silent placeholder and the sender we are about to replaceTrack() on is
precisely the one whose track is still null. Requiring a track to
already be there made the lookup miss, and a miss was not recoverable:
setSender(null) latches the mix onto the local speakers for the rest of
the session. Match through the transceiver instead, and take a second
look once the graph exists so a session that came up without a sender
still gets a chance to recover.
Also probe outbound RTP shortly after unlock and report when nothing is
flowing. The audio m-line is negotiated whatever ends up on the sender,
so neither the SDP nor the daemon's logs tell a live mix from a muted
one; bytes on the wire are the first honest signal.
Co-authored-by: Cursor <cursoragent@cursor.com>
fix(tuning): let the needle catch up with the antenna
Hand tuning left the needle trailing the antenna by a gap it never made
up, and a saturated offset put the far end of the arc out of reach. Two
causes, both in the pin:
- The droop grace window was armed on every pin, including the ones the
antenna merely comes to rest in mid-tuning. Since stillMs is enough to
pin, a calm hand spent the whole session inside a maxOffset-sized dead
zone (3.6 deg of real antenna travel), and its nudges went into the
offset instead of the needle. Only a motor release can be followed by a
droop, so only those pins arm the window now.
- The offset decayed only above moveSpeed and only past wakeRad from the
last release. A slow hand clears neither, so a saturated offset
survived a whole sweep and shifted the entire dial. It now washes out
whenever the hand has the dial and the needle is not pinned.
Measured on slow nudges (1.5 deg of antenna each, 600 ms apart): the gap
drops from 13-28 deg of arc to a steady 2.1 deg, which is the filter lag…
fix(audio): keep a silent tap on the output so WebKit renders the graph
In robot mode the MediaStreamDestination is the graph's only consumer.
Chrome renders it anyway, but WebKit starts no audio unit for a context
with nothing on its hardware output, so every render quantum came out as
zeros and the robot received silence - the radio was mute whenever the
mobile app embedded us in a WKWebView.
A permanently connected 0-gain branch to ctx.destination keeps the output
live without anything being audible on the device.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(theme): make the dial and knob panels follow dark mode
The --inset-* tokens were light in both themes, so the dial stayed a white
panel on a dark chassis. In dark they now sit just below --surface, which
reads as recessed into the faceplate rather than laid on top of it.
Nothing outside the dial and the knobs reads these tokens, so the three
values carry the whole change.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat: rework the faceplate head, dial and settings modal
The head is rebuilt around the app icon: it straddles the top edge of the
chassis, the now-playing block is centred under it, and the two icon buttons
take the corners so the title keeps the whole line. The VU meter that used to
sit beside the title is gone, replaced by a trace drawn between Reachy's own
antennas in the illustration, driven by the audio pulse.
On the dial, station names moved inside the track, over the ticks, haloed in
the panel's own colour so they stay readable in either theme. They are trimmed
to their slot in JS, ellipsis included, because a CSS ellipsis is its own text
run and its halo sat on top of the last letter. Fine ticks are spaced from the
station count so they always land on a station.
The rainy-mood button now follows what is audible rather than what was asked:
the cloud and the drops only appear once the bed actually plays, and a spinner
stands in while it connects, so the mood no longer looks on before it is.
Overlay and dropdown behaviour moves out …
feat(stations): space stations around the dial and cap the list at six
stationPositions used to pin the outer two stations to the ends of the arc,
leaving no travel past them. Each station now owns an equal slot and sits at
its centre, so the margin at either end scales with the count and the spacing
stays even.
The list is capped at six in sanitize, so it holds for a stored list as much
as for anything added later: past that the slots get too narrow to label, and
the antenna has to resolve stations closer together than it can be pointed at.
FM4 is swapped for the BBC World Service, with a migration so saved lists
follow, and the two longest default names are shortened to fit their slot.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(audio): read the mixed bus as a single pulse
Replaces getLevels, which fed the VU meter, with readPulse: one RMS level and
one spectral flux value per call, from buffers reused across frames rather
than allocated on each one.
The analyser had to be retuned for it to mean anything. fftSize goes from 64
to 1024 because the node only hands back its last window, so at 48kHz the old
one saw 5ms out of every 17 and a drum hit landing in the gap simply never
existed. smoothingTimeConstant goes to 0 since flux compares two consecutive
spectra and any blending makes them partly the same buffer. The decibel window
widens to -90..-10 because broadcast material sits above the default -30 and
pinned every band at 255.
Flux is measured against its own running average so static, which is nothing
but spectral change, no longer reads as a permanent onset.
The rainy mood also gets a louder cap and a fade slow enough to hear.
Co-authored-by: Cursor <cursoragent@cursor.com>
refactor: move the faceplate stylesheet out of index.html
The whole app lived in a 1000-line <style> block in the document head, which
put it out of reach of tooling and made the entry point unreadable. It now
sits in src/app.css, imported from dispatch.ts right after theme.css.
The order matters and is now the loaded one: app.css builds on the kit's
tokens and primitives, so rules of equal specificity in it win, where the
inline block used to lose. test/hero.html imports the two stylesheets the
same way instead of scraping them out of index.html, and its own button
rule is scoped so it stops repainting the previewed head.
Co-authored-by: Cursor <cursoragent@cursor.com>
style: scale up the faceplate illustration and head type
The icon goes from 88 to 104px and the kicker, station name and blurb each move
up a step, so the head carries the card instead of sitting quietly on it. The
chassis offset and head padding follow the taller icon.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat: centre the faceplate head under a half-out app icon
The head was a left-aligned block with the two buttons crowded on the right.
It now reads as a cover: the app icon straddles the top edge of the chassis the
way it straddles the unlock card, with the station, its title and its blurb
centred underneath.
Centring the title needs the full width, so the rainy-mood and settings buttons
move to the top corners, framing the icon. The head markup moves to its own
module, which also lets test/hero.html render it against the real stylesheet
(the app itself only mounts with a robot session).
Co-authored-by: Cursor <cursoragent@cursor.com>
fix: only spin the rainy-mood toggle when the stream actually lags
Showing the spinner on every switch cost more than it said: it replaces the
icon, so the sun/cloud morph ran behind it and was never seen, and on a cold
mount it kept spinning for seconds over a bed the user can simply hear arrive.
It now appears only once the bed has been wanted and silent past a grace
period, which a healthy mount never reaches. The same rule covers a stream that
drops mid-listen, so the feedback for a struggling connection is kept.
Co-authored-by: Cursor <cursoragent@cursor.com>
fix: drop the rainy-mood spinner once the fade starts
The spinner was held until the stream produced sound, which on a cold mount
meant seconds of spinning for an ambient bed nobody is waiting on. It now
covers the fade window only.
getRainLoading() existed solely to feed that spinner, so it becomes
isRainPlaying(): the remaining caller wants to know whether the bed is audible,
not whether a spinner is due.
Co-authored-by: Cursor <cursoragent@cursor.com>
fix: stop the rain bed reopening its stream on every frame
The tuning path calls setTuning() about 20 times a second and each call asked
the rain engine to open. open() only short-circuited once the stream was
already playing, so every tick bumped the generation counter, orphaned the
connection in flight and started another one: 242 connections over 12s,
readyState stuck at 0, and a rainy-mood spinner that never stopped.
open() now also short-circuits while an attempt for the same URL is in flight,
and the rain path goes through needsReload() like the station path already
does, so a pending retry keeps its backoff instead of being cancelled on the
spot.
close() clears `opening` as well. Bumping the generation orphans the in-flight
attempt, which then returns without clearing the flag, so cutting the bed
mid-connection left the engine looking busy forever and switching it back on
never reopened.
test/rain-reopen.html drives the real RadioAudio at the same tick rate and
counts the connections actually opened, for both the steady state and the…
fix: keep the antenna ghost until the two needles nearly meet
The ghost was hidden as soon as the gap fell under 5 deg of arc, which is still
about 11px on the dial, so it vanished while a gap was plainly visible. Its whole
job is to show that gap, so it now waits until 1.5 deg, roughly 3px, leaving a
wide hysteresis against the 10 deg that makes it appear.
Co-authored-by: Cursor <cursoragent@cursor.com>
fix: decode HTML entities in station and track metadata
Mounts that publish now-playing straight out of a web CMS send escaped text, so
the dial was showing `'50S & '60S` verbatim -- we render through
`textContent`, which escapes nothing back.
Decoded at the two funnels every displayed string already passes through: `clean`
for titles and artists, `cleanTags` for genres, plus the station name and country
coming off the directory. Numeric, hex and named entities, including the Latin-1
letters that PHP's `htmlentities()` produces by default, which matters for the
French and Austrian stations in the bundle. Lookup is case-sensitive, since
`É` and `é` are different letters. Double escaping is handled with
a bounded number of passes.
Decoded by hand rather than via `innerHTML`, so text from a third-party mount
never becomes DOM even briefly.
Co-authored-by: Cursor <cursoragent@cursor.com>
fix: let the needle follow the finger without a transition lag
The needle carries `transition: left 0.06s linear`, which is fine for its own
moves but wrong under a drag: the position is rewritten every frame, so the
transition restarts from wherever it had got to and never completes. Each frame
covers only dt/T of the remaining distance, about 28% at 60 Hz, which leaves the
needle a speed-dependent distance behind the finger -- 13px at 300px/s, 26px at
600px/s, 52px at 1200px/s, and ~183ms to close a 30px gap once the finger stops.
It is worse at 120 Hz, since shorter frames restart the transition earlier.
Suspend the transition for the duration of the drag. Verified in the browser that
the rule wins on specificity: computed `transition` goes to `none` while dragging
and back to `left 0.06s linear` afterwards.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat: widen the static-free zone to two thirds of each station slot
The zone that plays clean goes from 12 to 18 deg of arc and the release point
from 20 to 26 deg, keeping the crossfade between them exactly as wide as before:
entering a station still fades in over 1 deg of antenna travel rather than
switching. Across the gap between two stations that now reads as 64% clean at
full volume, 14% fading, 21% pure static -- against 43/14/43 before. Static
between stations stays reachable, at 1.5 deg of antenna travel.
Release stays below half the station spacing (28 deg), so neighbouring bands
still cannot overlap.
The model's `gain` needed the same envelope to follow. It drives the bar
visualizer and the amplitude of the robot's dance, and it was a plain linear
ramp over the release radius, so with a plateau this wide the robot would have
idled through a station playing at full volume. It now mirrors the audio
envelope, plateau included, which also removes a second curve that described the
same thing differently.
Co-authored-by: Cursor <cursoragent@cur…
refactor: stop seeking stations on the user's behalf
Dropping the arc warp left a second, stronger attraction behind: about two
seconds after the hand let go, `park()` engaged the motor and physically drove
the antenna onto the nearest tick whenever the needle was within 12 deg. That is
the pull that still felt magnetic, and it is now gone, along with the snap that
`endPointer` did to the nearest station on release.
Releasing the pointer still holds the motor briefly, but aimed at the angle the
user pointed at rather than at a station: the arm lags well behind a fast drag,
so handing it back on the spot stranded it mid-travel and could settle nearer a
neighbouring station than the one asked for.
Arrow keys keep driving the antenna across, being a request for a station rather
than for an angle. That leaves `settle` as the only path that moves the needle on
its own, and only ever on an explicit command.
With parking gone `settleArmed`, `settleEps` and the parking half of the pin's
two-phase flag had no readers left; the flag is now name…
refactor: drop station magnetism, keep the arc linear
Magnetism warped the arc around each station so the needle fell into a detent.
It is gone entirely: a degree of arc now means the same thing everywhere, and a
station is a place on the dial rather than a well that pulls the needle in.
With no warp left, `raw` and `intent` were always equal, so the model is down to
two angles instead of three. The magnet radius also doubled as "close enough to
be on a station" for the two parking decisions; that becomes `captureRad`
(capped to half the station spacing), which ties parking to the same threshold
as capture and as the static-free zone.
Removing it does not cost stability: with the arm shivering by +/-0.3 deg the
needle still drifts 0.0000 deg over 3 s, because it is the pin -- not the
geometry -- that holds it at rest. Capture hysteresis is unchanged, entering at
12 deg and releasing at 20 deg.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat: widen the static-free zone and steady the needle at rest
Two fixes aimed at how the dial behaves on the real robot.
The static-free zone around a station was 5 deg, so the crackle crept back in
almost as soon as the needle left the tick. Widen it to 12 deg and align it
with the model's captureRad: for as long as a station is captured, it plays
clean. Station and static are now a strict crossfade over the remaining
12-20 deg, with a flat plateau inside the sweetspot -- fading the volume across
the whole bandwidth left the edge of the zone clean but quiet, which reads as a
distant station rather than a tuned one.
The antenna readback keeps shivering by about a tenth of a percent of the arc
even after the one-euro filter, and the 7.8x arc amplification makes that
visible as a needle that never sits still. Hold each drawn dial position until
it has moved more than a small deadband. Presentation only: the model, the
audio and the motor command still see the unfiltered frame.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat: robust streaming, now-playing titles and a dedicated tuning model
Three parts of the app that were tangled into embed.ts now stand on their
own, each testable from a bench page under test/.
- stream-engine.ts: one <audio> element kept alive, HLS via hls.js (light
build, dynamically imported) for .m3u8 and a hardened native path for
everything else. Reconnects only once the buffer has run dry, so a
network blip costs no audible gap.
- now-playing.ts: resolves the track on air from side-channel endpoints
rather than in-band ICY metadata, which no browser can read cross-origin.
- tuning.ts: the antenna's 36° of travel stretched over a 280° arc means
8x the encoder noise and 8x the mechanical sag. A one-euro filter,
station magnetism, capture hysteresis and a bounded UI/antenna offset
turn that into a needle that holds still and a station that does not
flicker. The needle is pinned whenever the antenna is at rest, so
nothing on the dial moves when nothing is moving.
Stations carry tags and country instead of a single genre string.
Co…