From 30171767f7c30a154653f638cf9899d0b29cbb0c Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 13 Aug 2026 19:59:45 -0700 Subject: [PATCH] Refine the music: fades, and a mute control on every public screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/App.tsx | 55 +++++++++++++++++++++++++++++--------- apps/web/src/lib/audio.tsx | 48 ++++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b3b95c4..9c39caf 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,6 +11,7 @@ import { IdentityProvider, useIdentityQuery } from '@/lib/identity'; import { LayoutProvider } from '@/lib/layout'; import { PiggyContextProvider } from '@/lib/piggy-context'; import { PlatformAudioProvider } from '@/lib/audio'; +import { AudioControl } from '@/components/AudioControl'; import { Shell } from '@/components/Shell'; import { SignIn } from '@/pages/SignIn'; import { CreateProfile } from '@/pages/CreateProfile'; @@ -106,6 +107,28 @@ export function App() { ); } +/** + * The signed-out screens render outside Shell, so they get no app header — and + * therefore no way to stop the music. Audio a visitor cannot switch off is the + * worst version of this feature, so every unauthenticated screen gets the + * control pinned in a corner. + * + * Learn is not wrapped: it brings its own chrome and already hosts one. + */ +function SignedOut({ children }: { children: React.ReactNode }) { + return ( + <> + {children} +
+ +
+ + ); +} + /** * Decides what to show based on *why* a request failed. * @@ -165,24 +188,32 @@ function AuthGate({ config }: { config: PublicConfig }) { ); } - return showRegister ? ( - setShowRegister(false)} - onRegistered={() => { - setShowRegister(false); - void refetch(); - }} - /> - ) : ( - setShowRegister(true)} /> + return ( + + {showRegister ? ( + setShowRegister(false)} + onRegistered={() => { + setShowRegister(false); + void refetch(); + }} + /> + ) : ( + setShowRegister(true)} /> + )} + ); } // Authenticated but not a member. This is a step in the flow, not an // error — sending them back to a login screen they have already completed // would be a loop with no exit. if (error.needsProfile) { - return void refetch()} />; + return ( + + void refetch()} /> + + ); } } diff --git a/apps/web/src/lib/audio.tsx b/apps/web/src/lib/audio.tsx index 9f06f9b..aacfb26 100644 --- a/apps/web/src/lib/audio.tsx +++ b/apps/web/src/lib/audio.tsx @@ -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]);