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>
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: 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: 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>
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…