Close the three seams in the crossfade

Track changes were audible: a gap in the audio, a clipped ending, and the
outgoing video briefly restarting. Three separate causes, all in the handover.

The pointer race is what restarts the video. finishTransition sends trackEnded
and the server only moves currentId once it has processed it, so for one round
trip state.currentId still names the track that just faded out — which is still
in items. The next tick, at most 250ms later, reads that as "the pointer moved
somewhere we are not playing" and hard-cuts back to it from zero; the real
broadcast then arrives and cuts forward again, restarting the new track too.
The player now advances its own pointer as it sends and reconciles against the
broadcast, keeping its version while the server still names the finished track
and yielding to anything else, which is someone jumping.

The fade outlasted its own track. It was triggered at OVERLAP_SECONDS left but
ran for a fixed OVERLAP_SECONDS, and the tick that fires it is already up to a
quarter second late — getDuration() also reports a length that runs a little
past the last of the audio. So the outgoing track always ran out mid-fade: the
ending is clipped, and YouTube puts up its end screen, which shows the video's
first frame and reads as the track starting over. The fade now runs for what is
actually left, less END_GUARD_SECONDS, so it lands before the track does.

The gap is the incoming deck buffering on the clock. beginCrossfade loaded the
next video and started a wall-clock ramp against it, so the outgoing track was
ducked while the incoming one was still fetching its first bytes. The next
track is now prerolled PREROLL_SECONDS ahead — loaded, played until YouTube
reports it running, then seeked back to 0 and paused, which is what keeps the
fade opening on the first bar rather than four seconds in — and the ramp does
not open until the incoming deck is genuinely playing. START_WAIT_SECONDS caps
that wait so a video that never loads cannot strand a track that is ending.

Not verified against live playback: the browser the automation drives renders
its tab offscreen, permanently visibilityState "hidden", and Chrome will not
start a YouTube video there — the deck loads and reports its duration, but the
playhead never leaves zero. Confirmed only that the page loads and the decks
build. __player.state gains playhead, preroll and handover, and seekToEnd()
now lands before the preroll window so one call exercises the whole handover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
This commit is contained in:
Si
2026-08-14 13:40:36 +02:00
co-authored by Claude Opus 5
parent 554b946235
commit 176ca6868f
3 changed files with 165 additions and 12 deletions
+26
View File
@@ -40,6 +40,32 @@ autoplay, so the first click is what unlocks audio. One click per session.
`PORT` is mirrored to the browser by the generated `/config.js` route in
`server/index.js`, so a new constant needs no second list.
## Crossfades
Two decks, one video each, and a track change is an equal-power fade from the
one to the other. Three details are what keep it from being audible as a seam:
- **The next track is prerolled.** `PREROLL_SECONDS` before the fade is due, the
idle deck loads it, plays until YouTube reports it running, then seeks back to
0 and pauses. A deck told to play at the moment the fade opens would spend its
first second or two fetching, and the fade — which runs on wall-clock time —
would ramp the outgoing track down over that silence. The fade still will not
open until the incoming deck is genuinely playing, so a preroll that did not
take costs a moment of the outgoing track rather than a gap.
- **The fade lands before the end, not on it.** It runs for what is actually
left of the outgoing track minus `END_GUARD_SECONDS`, rather than for a fixed
`OVERLAP_SECONDS` — the tick that triggers it is already a little late, and
`getDuration()` reports a length that runs slightly past the last of the
audio. A fade that outlasts its own track ends in a clipped ending and a flash
of YouTube's end screen, which shows the video's first frame and reads as the
track restarting.
- **The player owns the hand-over until the server confirms it.** The queue
pointer only moves once the server has processed `trackEnded`, so for one
round trip the state still names the track that just finished. The player
advances its own pointer at the same moment it sends, and reconciles when the
broadcast arrives; without that, the jump check reads the stale pointer as a
manual jump and cuts straight back to the track that just ended.
## Backgrounds
Every image file in `assets/` is a background — drop one in, restart, and it
+19
View File
@@ -13,6 +13,25 @@ export const OVERLAP_SECONDS = 5;
// How fast the player switches when the user jumps around manually.
export const HARD_CUT_SECONDS = 0.4;
// How long before the crossfade the next video is loaded and buffered on the
// idle deck. A deck that is still fetching its first bytes plays silence, and
// the fade would ramp straight over that silence — this is what buys the
// incoming track enough of a head start to be audible the moment it is needed.
export const PREROLL_SECONDS = 4;
// Landing margin for the crossfade, in seconds. getDuration() is the length of
// the video, which is not quite the length of the audio: it runs a little past
// the last of the sound, and it is rounded. Finishing the fade this far before
// the reported end keeps the outgoing track from running out underneath its own
// fade — which is heard as a clipped ending and seen as YouTube's end screen
// (the video's thumbnail, i.e. its first frame) flashing up mid-transition.
export const END_GUARD_SECONDS = 0.35;
// How long a crossfade waits for the incoming deck to actually start before it
// gives up and fades anyway. Only reached when the preroll did not take — a
// hard cut, or a video that is slow to load.
export const START_WAIT_SECONDS = 1.5;
// The video frame takes up this fraction of the screen. Keep this below 0.5:
// the corners animation puts a frame-sized image in all four corners, which
// only stays overlap-free while two frames fit side by side.
+120 -12
View File
@@ -2,6 +2,9 @@ import { connect } from '/shared/socket.js';
import {
OVERLAP_SECONDS,
HARD_CUT_SECONDS,
PREROLL_SECONDS,
END_GUARD_SECONDS,
START_WAIT_SECONDS,
AUTO_BACKGROUND,
AUTO_ANIMATION,
LOW_RES_SPEED,
@@ -24,7 +27,9 @@ let state = {
};
let decks = [];
let active = 0;
let transition = null; // { until, duration, from, to } while crossfading
let transition = null; // { startedAt, duration, from, to } while crossfading
let preroll = null; // { itemId, deck, ready } while the next track warms up
let handover = null; // { from, to } while the server has yet to confirm an advance
let started = false;
let animations = null;
@@ -153,6 +158,22 @@ function playOn(index, item, { volume, opacity }) {
deck.setOpacity(opacity);
}
/**
* Warm the idle deck up with the track that is coming next: load it, let it
* play far enough to fill its buffer, then park it back at the start.
*
* A deck told to play the instant the crossfade begins spends its first second
* or two fetching, and the fade — which runs on wall-clock time — ramps the
* outgoing track down over that silence. Parking a buffered deck at 0 instead
* means the incoming track is audible on the first frame of the fade.
*/
function prepareNext(item) {
const to = 1 - active;
preroll = { itemId: item.id, deck: to, ready: false };
playOn(to, item, { volume: 0, opacity: 0 });
decks[to].player.playVideo();
}
/**
* Put the decks down or pick them back up. A crossfade in flight is frozen
* rather than abandoned: the ramp runs on wall-clock time, so the paused
@@ -168,7 +189,11 @@ function applyPaused(paused) {
}
if (transition?.pausedAt) {
transition.startedAt += performance.now() - transition.pausedAt;
// Whichever clock is running: before the ramp opens it is the one waiting
// for the incoming deck, after that it is the ramp itself.
const stalled = performance.now() - transition.pausedAt;
if (transition.startedAt === null) transition.waitingSince += stalled;
else transition.startedAt += stalled;
transition.pausedAt = null;
}
@@ -188,6 +213,17 @@ function onDeckState(index, code) {
return;
}
// The prerolled deck has buffered enough to be playing; that is all the
// preroll was after. Park it back at the top so the fade opens on the first
// bar of the song rather than four seconds into it — resuming from here costs
// nothing, the data is already in hand.
if (preroll && !preroll.ready && index === preroll.deck && code === STATE.PLAYING) {
preroll.ready = true;
decks[index].player.seekTo(0, true);
decks[index].player.pauseVideo();
return;
}
if (index !== active || transition) return;
// Safety net: a track that ended without our tick catching it (very short
@@ -223,6 +259,11 @@ function tick() {
// Queue emptied out from under us — stop rather than loop the video the
// user just deleted.
if (state.items.length === 0) {
if (preroll) {
decks[preroll.deck].player.stopVideo();
decks[preroll.deck].itemId = null;
preroll = null;
}
if (deck.itemId) {
deck.player.stopVideo();
deck.setOpacity(0);
@@ -252,11 +293,24 @@ function tick() {
const currentTime = deck.player.getCurrentTime?.() ?? 0;
if (!duration) return;
if (duration - currentTime <= OVERLAP_SECONDS) {
const next = nextItem();
// A single-item queue loops instead of crossfading with itself.
if (!next || next.id === deck.itemId) return;
beginCrossfade(next, OVERLAP_SECONDS, { advance: true });
const remaining = duration - currentTime;
if (remaining > OVERLAP_SECONDS + PREROLL_SECONDS) return;
const next = nextItem();
// A single-item queue loops instead of crossfading with itself.
if (!next || next.id === deck.itemId) return;
// Warm the next track up early — and again if the queue was reordered under
// us and the track that is coming is no longer the one on the idle deck.
if (preroll?.itemId !== next.id) prepareNext(next);
if (remaining <= OVERLAP_SECONDS) {
// Fade over what is actually left rather than over the nominal overlap: the
// tick that fires this is already a moment late, and a fade that outlasts
// its own track ends in silence. The floor keeps a throttled tab, which can
// land here with no time left at all, from cutting instantly.
const window = Math.max(remaining - END_GUARD_SECONDS, HARD_CUT_SECONDS);
beginCrossfade(next, window, { advance: true });
}
}
@@ -264,11 +318,26 @@ function beginCrossfade(item, seconds, { advance }) {
const from = active;
const to = 1 - active;
playOn(to, item, { volume: 0, opacity: 0 });
// Reuse the prerolled deck if it is holding this very track, buffered and
// parked at 0. Anything else — a hard cut, a jump — loads from cold.
if (preroll?.itemId !== item.id || preroll.deck !== to) {
playOn(to, item, { volume: 0, opacity: 0 });
} else if (!preroll.ready && (decks[to].player.getCurrentTime?.() ?? 0) > 0.5) {
// Warmed but never parked, because a pause landed inside the preroll. It is
// some seconds into the song; the fade still has to open on the first bar.
// (A preroll that has simply not started yet is already at 0 — leave it be,
// seeking a player that is still loading only sets the load back.)
decks[to].player.seekTo(0, true);
}
preroll = null;
decks[to].player.playVideo();
transition = {
startedAt: performance.now(),
// Opened by stepTransition once the incoming deck is actually running: a
// ramp that starts against a deck still spinning up fades the party into
// silence for as long as the load takes.
startedAt: null,
waitingSince: performance.now(),
duration: seconds * 1000,
from,
to,
@@ -287,10 +356,22 @@ function beginCrossfade(item, seconds, { advance }) {
function stepTransition() {
if (!transition || transition.pausedAt) return false;
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
const outgoing = decks[transition.from];
const incoming = decks[transition.to];
// Hold the outgoing track at full volume until the incoming one is really
// playing. Normally the preroll has it parked and ready, so this holds for a
// frame or two at most; the cap is there so a video that never loads cannot
// strand the party on a track that is about to end.
if (transition.startedAt === null) {
const playing = incoming.player.getPlayerState?.() === STATE.PLAYING;
const waited = performance.now() - transition.waitingSince;
if (!playing && waited < START_WAIT_SECONDS * 1000) return true;
transition.startedAt = performance.now();
}
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
// Equal-power curve: the combined loudness stays flat through the middle
// of the mix instead of dipping.
outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100));
@@ -325,7 +406,16 @@ function finishTransition() {
animations?.next();
backgrounds.next();
if (advance) socket.send({ type: 'trackEnded', id: outgoingId });
if (advance) {
// Move the pointer locally, ahead of the server's answer. The server only
// advances it when it has processed `trackEnded`, and until that broadcast
// arrives `state.currentId` still names the track we just faded out — which
// the jump check in tick() reads as "someone moved the pointer" and honours
// by cutting straight back to it, restarting the track that just ended.
handover = { from: outgoingId, to: decks[to].itemId };
state.currentId = handover.to;
socket.send({ type: 'trackEnded', id: outgoingId });
}
}
function reportProgress() {
@@ -405,6 +495,16 @@ function onMessage(msg) {
const previousPaused = state.paused;
const previousLowRes = state.lowRes;
state = msg.state;
// Keep our own hand-over until the server has caught up with it. A broadcast
// sent before `trackEnded` landed — or triggered by something else entirely,
// like someone adding a video mid-fade — still carries the old pointer.
if (handover) {
if (state.currentId === handover.to) handover = null; // confirmed
else if (state.currentId === handover.from) state.currentId = handover.to;
else handover = null; // the pointer went somewhere else; that beats ours
}
if (state.background !== previousBackground) backgrounds.setMode(state.background);
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
// No restart: the running animation picks the new speed up on its next frame.
@@ -441,7 +541,8 @@ updateGate();
// Debug helper used when verifying the crossfade without waiting out a track.
window.__player = {
seekToEnd(secondsBefore = OVERLAP_SECONDS + 2) {
// Lands before the preroll window, so the hand-over is exercised in full.
seekToEnd(secondsBefore = OVERLAP_SECONDS + PREROLL_SECONDS + 2) {
const deck = decks[active];
const duration = deck?.player.getDuration?.() ?? 0;
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
@@ -453,10 +554,17 @@ window.__player = {
return backgrounds.next();
},
get state() {
const deck = decks[active];
return {
state,
active,
playhead: {
currentTime: deck?.player.getCurrentTime?.() ?? null,
duration: deck?.player.getDuration?.() ?? null,
},
transition,
preroll,
handover,
decks: decks.map((d) => d.itemId),
animation: animations?.name ?? null,
background: backgrounds.key,