Refine the music: fades, and a mute control on every public screen
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:
+43
-12
@@ -11,6 +11,7 @@ import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
|
|||||||
import { LayoutProvider } from '@/lib/layout';
|
import { LayoutProvider } from '@/lib/layout';
|
||||||
import { PiggyContextProvider } from '@/lib/piggy-context';
|
import { PiggyContextProvider } from '@/lib/piggy-context';
|
||||||
import { PlatformAudioProvider } from '@/lib/audio';
|
import { PlatformAudioProvider } from '@/lib/audio';
|
||||||
|
import { AudioControl } from '@/components/AudioControl';
|
||||||
import { Shell } from '@/components/Shell';
|
import { Shell } from '@/components/Shell';
|
||||||
import { SignIn } from '@/pages/SignIn';
|
import { SignIn } from '@/pages/SignIn';
|
||||||
import { CreateProfile } from '@/pages/CreateProfile';
|
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}
|
||||||
|
<div
|
||||||
|
className="fixed right-3 z-50 rounded-full border border-border bg-surface/85 shadow-sm backdrop-blur"
|
||||||
|
style={{ bottom: 'calc(0.75rem + var(--safe-bottom))' }}
|
||||||
|
>
|
||||||
|
<AudioControl />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decides what to show based on *why* a request failed.
|
* Decides what to show based on *why* a request failed.
|
||||||
*
|
*
|
||||||
@@ -165,24 +188,32 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
|||||||
</RoutePage>
|
</RoutePage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return showRegister ? (
|
return (
|
||||||
<Register
|
<SignedOut>
|
||||||
config={config}
|
{showRegister ? (
|
||||||
onBack={() => setShowRegister(false)}
|
<Register
|
||||||
onRegistered={() => {
|
config={config}
|
||||||
setShowRegister(false);
|
onBack={() => setShowRegister(false)}
|
||||||
void refetch();
|
onRegistered={() => {
|
||||||
}}
|
setShowRegister(false);
|
||||||
/>
|
void refetch();
|
||||||
) : (
|
}}
|
||||||
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
|
/>
|
||||||
|
) : (
|
||||||
|
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
|
||||||
|
)}
|
||||||
|
</SignedOut>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Authenticated but not a member. This is a step in the flow, not an
|
// 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
|
// error — sending them back to a login screen they have already completed
|
||||||
// would be a loop with no exit.
|
// would be a loop with no exit.
|
||||||
if (error.needsProfile) {
|
if (error.needsProfile) {
|
||||||
return <CreateProfile config={config} onCreated={() => void refetch()} />;
|
return (
|
||||||
|
<SignedOut>
|
||||||
|
<CreateProfile config={config} onCreated={() => void refetch()} />
|
||||||
|
</SignedOut>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,33 @@ const TRACK_KEY = 'pig.audio.track';
|
|||||||
*/
|
*/
|
||||||
const VOLUME = 0.14;
|
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 {
|
function storedTrack(): PlatformTrackId {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(TRACK_KEY);
|
const raw = localStorage.getItem(TRACK_KEY);
|
||||||
@@ -96,20 +123,32 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
|
||||||
if (!enabled) {
|
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);
|
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 cancelled = false;
|
||||||
let armed: (() => void) | null = null;
|
let armed: (() => void) | null = null;
|
||||||
|
|
||||||
|
let cancelRamp: (() => void) | null = null;
|
||||||
const start = () =>
|
const start = () =>
|
||||||
audio
|
audio
|
||||||
.play()
|
.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) setPlaying(true);
|
if (cancelled) return;
|
||||||
|
setPlaying(true);
|
||||||
|
cancelRamp?.();
|
||||||
|
cancelRamp = ramp(audio, VOLUME, FADE_IN_MS);
|
||||||
})
|
})
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
@@ -134,6 +173,7 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
armed?.();
|
armed?.();
|
||||||
|
cancelRamp?.();
|
||||||
};
|
};
|
||||||
}, [enabled, source.src]);
|
}, [enabled, source.src]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user