Refine the music: fades, and a mute control on every public screen
CI / verify (push) Successful in 3m43s
CI / publish (push) Has been skipped

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>
This commit is contained in:
2026-08-13 19:59:45 -07:00
parent afce0dda28
commit 30171767f7
2 changed files with 87 additions and 16 deletions
+44 -4
View File
@@ -52,6 +52,33 @@ const TRACK_KEY = 'pig.audio.track';
*/
const VOLUME = 0.14;
/**
* Ramp length. Music that snaps to full volume on the first click reads as a
* glitch; a second of ramp reads as the room having been there all along.
* Short enough that muting feels immediate.
*/
const FADE_IN_MS = 1100;
const FADE_OUT_MS = 420;
/**
* Ease the element's volume, resolving when it arrives. Returns a canceller so
* a fade that is superseded (mute pressed mid-fade-in) stops rather than
* fighting the new one.
*/
function ramp(audio: HTMLAudioElement, to: number, ms: number): () => void {
const from = audio.volume;
const started = performance.now();
let frame = 0;
const step = () => {
const t = Math.min(1, (performance.now() - started) / ms);
// easeOutQuad: most of the travel early, so a fade-out feels prompt.
audio.volume = Math.max(0, Math.min(1, from + (to - from) * (1 - (1 - t) * (1 - t))));
if (t < 1) frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}
function storedTrack(): PlatformTrackId {
try {
const raw = localStorage.getItem(TRACK_KEY);
@@ -96,20 +123,32 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) {
if (!audio) return;
if (!enabled) {
audio.pause();
const cancel = ramp(audio, 0, FADE_OUT_MS);
const stop = window.setTimeout(() => {
audio.pause();
audio.currentTime = 0;
}, FADE_OUT_MS);
setPlaying(false);
return;
return () => {
cancel();
window.clearTimeout(stop);
};
}
audio.volume = VOLUME;
// Start silent so the ramp has somewhere to come from.
audio.volume = 0;
let cancelled = false;
let armed: (() => void) | null = null;
let cancelRamp: (() => void) | null = null;
const start = () =>
audio
.play()
.then(() => {
if (!cancelled) setPlaying(true);
if (cancelled) return;
setPlaying(true);
cancelRamp?.();
cancelRamp = ramp(audio, VOLUME, FADE_IN_MS);
})
.catch(() => false);
@@ -134,6 +173,7 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
armed?.();
cancelRamp?.();
};
}, [enabled, source.src]);