It played in Chrome and was silent on every Apple device. Two false signals,
compounding.
Neither the play() promise nor a synchronous `paused` check tells you whether
audio is playing. Chrome REJECTS the promise when autoplay is blocked, which is
the behaviour the obvious implementation is written against. WebKit RESOLVES
it, reports `paused === false` for an instant, and quietly pauses the element a
moment later. So the code concluded it had started, faded the volume up on a
silent element, and — because it thought it had succeeded — never armed the
gesture listener that was the entire fallback. Music could then never start at
all, no matter how many times the visitor clicked.
Traced by hooking HTMLMediaElement.prototype.play and addEventListener before
the app booted: exactly one play() call, "resolved paused=false", and no
pointerdown listener ever registered on window.
Two changes. Playing state is now driven by the element's own `playing` and
`pause` events, which are the only honest source. And the gesture listener is
armed UNCONDITIONALLY rather than only on a detected failure — play() on an
already-playing element is a no-op, so the redundant case costs nothing and the
broken case is fixed. The listeners are capture-phase so a component calling
stopPropagation cannot swallow them, and cover touchend and keydown as well.
Verified 12/12: Chrome and WebKit x desktop and iPhone x /, /learn and /margin
all reach t>2.5s at volume 0.14, and mute still fades and persists in both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE BUG. An anonymous visitor to / — the sign-in page, which is what the link
in an email opens — got music with ZERO mute controls. The provider moved above
the router so it covers the signed-out screens, but those render outside Shell
and therefore have no app header to host the toggle. Audio a visitor cannot
switch off is the worst version of this feature. Every unauthenticated screen
now carries the control pinned bottom-right; Learn keeps the one in its own
chrome rather than getting a second.
FADES. Volume ramps 0 -> 0.14 over 1.1s on start and back down over 0.42s on
mute, easeOutQuad so a mute feels prompt while a start feels like the room was
already there. Snapping to full volume on the first click reads as a glitch.
Measured: 0.076 at +0.4s, 0.14 at +2.2s, 0.025 at +0.25s after mute, paused by
+0.85s.
NO CHANGE TO THE TRACKS, and this reverses what I said earlier. I claimed
pig-tech would splice audibly every 28 seconds and offered to crossfade the
loop. That came from comparing 0.4-second mean levels, which measures musical
content rather than continuity. Measured properly — the wrap discontinuity
against each track's own 99.9th-percentile sample delta — all three already
loop cleanly (0.03-0.10x, i.e. quieter than their own ordinary transients), and
a folded crossfade made them WORSE (0.09-1.32x). The originals ship unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three ~28s tracks Karti generated, re-encoded from 160kbps to 96kbps and
pulled from -15 LUFS to -26. They were mastered at foreground level; the
failure mode of background music in a tool someone has open for eight hours
is not "too quiet". Playback volume is a further 0.14 on top.
AUTOPLAY DOES NOT MEAN AUTOPLAY. Every current browser refuses audio with
sound until the page has had a real user gesture — Chrome sometimes relents
for a site with a high Media Engagement Index, Safari essentially never does
on a first visit. So `play()` rejects on mount, and the naive version of this
looks like a bug: the control says playing and nothing is audible. This tries
immediately, and on refusal arms a one-shot listener and starts on the first
click or keypress. Verified in Chrome: paused on load, playing 2.2s after the
first click. That also happens to be the kind behaviour for someone who opened
six tabs at once.
Mounted in Shell, NOT in App. The anonymous Learn page renders outside Shell,
and a share-code visitor opening a link someone sent them should not get
unexpected audio — that is the one context where it reads as a fault rather
than as character. Asserted by there being no <audio> element on that page at
all.
Preference is localStorage and deliberately not mirrored to the server, on the
same reasoning as the sidebar: whether you want music depends on whether you
are wearing headphones, not on who you are.
Also pauses on tab hide, because music from a tab nobody is looking at is the
thing people hunt through twenty tabs to kill.
The element is rendered rather than `new Audio()` so it is inspectable in
devtools and in a test; `preload="none"` means a user who mutes it never
downloads a track.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>