From e2cfed081ca181a552d4796d9f07168301412c2e Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:07:33 -0500 Subject: [PATCH] player bar: the track-change morph stops being a coin flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fragilities each collapsed the shape→shape morph back to a grow-from-dots, which is why it only sometimes worked. The hold was 600ms against an arrival whose timing is DECODE time: the same track costs ~5ms once the session holds its waveform in memory but hundreds of ms to over a second on the first cold decode. 2000ms covers the cold case with room; fresh-stream searches take well past that, so the loading story still gets the lane. And the hold bailed on a bar-count mismatch — but the lane's width follows the song TITLE now, so switching between a long and a short name resizes the lane mid-gap and the guard threw the held shape away. Held and morphed heights are refitted to the new count instead (nearest neighbour — it is a moving start point, on screen for under a second). Four consecutive cached switches measured after the fix: minimum lane height 15.1 on every one (dots are ~2, the old dip hit ~5). Gapless playback is untouched — the hold and morph are pure painting, and the sink-append path never waits on the lane. --- src/components/PlayerBar.svelte | 37 +++++++++++++++++++++++++++------ src/lib/waveshape.js | 10 +++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index 608501a..9609c15 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -246,6 +246,23 @@ return pending; } + /** Resample a heights array onto a different bar count. The lane's WIDTH + * follows the song title now (the cluster hugs the name), so a track + * change routinely changes the bar count mid-hold — and bailing on the + * mismatch threw the held shape away and turned the morph into a coin + * flip. Nearest-neighbour is plenty: the result is a moving start point, + * on screen for under a second. */ + function fitHeights(src, count) { + if (!src?.length) return null; + if (src.length === count) return src; + const out = new Array(count); + const top = src.length - 1; + for (let i = 0; i < count; i++) { + out[i] = src[Math.min(top, Math.round((i / Math.max(1, count - 1)) * top))] ?? 2; + } + return out; + } + /** The fade, one bar at a time — shared by the rise and the arrival * morph, whose heights differ every frame and cannot use the grouped * clip-fills of the settled painter. */ @@ -492,11 +509,17 @@ holdUntil = now + ARRIVE_HOLD_MS; hadPeaks = false; } - if (player.wavePending && lastDrawnHeights?.length === count && now < holdUntil) { + const held = player.wavePending && now < holdUntil + ? fitHeights(lastDrawnHeights, count) + : null; + if (held) { ctx.clearRect(0, 0, W, H); for (let i = 0; i < count; i++) { - paintBar(ctx, i * BAR_PITCH, mid, lastDrawnHeights[i], UNPLAYED); + paintBar(ctx, i * BAR_PITCH, mid, held[i], UNPLAYED); } + // Write the fitted shape back so a mid-hold resize composes + // and the arrival morph starts from exactly what is showing. + lastDrawnHeights = held; lastPlayedBars = Number.NEGATIVE_INFINITY; scheduleAnim(true); return; @@ -600,10 +623,12 @@ // A NEW complete waveform arrives on the morph — from the dots, // from the swell mid-sweep, from the previous track's shape, or // from the progressive peaks the final stream decode replaces. - // Only on a peaks swap at a matching bar count: a pure resize - // re-lays the same shape and stays instant. - if (peaksSwapped && lastDrawnHeights?.length === count) { - morphFrom = [...lastDrawnHeights]; + // Only on a peaks swap: a pure resize re-lays the same shape and + // stays instant. A count mismatch is refitted, not rejected — the + // lane's width follows the title, so track changes change counts. + if (peaksSwapped && lastDrawnHeights?.length) { + const from = fitHeights(lastDrawnHeights, count); + morphFrom = from === lastDrawnHeights ? [...from] : from; morphStart = now; } } diff --git a/src/lib/waveshape.js b/src/lib/waveshape.js index 169a494..139ad7a 100644 --- a/src/lib/waveshape.js +++ b/src/lib/waveshape.js @@ -58,8 +58,14 @@ export const ARRIVE_MS = 1000; * arrival — a local file, a cached stream — lands inside this window and * morphs shape→shape, one waveform transforming into the other; a slow load * falls to the dots-and-swell story when the hold expires. Without the hold, - * the cleared lane always morphed up from the dots. */ -export const ARRIVE_HOLD_MS = 600; + * the cleared lane always morphed up from the dots. + * + * 2000, up from 600: an arrival's timing is decode time, and the same track + * costs ~5ms once the session has its waveform in memory but hundreds of ms + * to over a second on the first, cold decode — a 600ms hold made the morph + * a coin flip on exactly the tracks it was built for. Fresh-stream searches + * take well past 2s, so the loading story still gets the lane. */ +export const ARRIVE_HOLD_MS = 2000; /** Resting/queued bars render the muted grey at this opacity — the rail's * pending dim, shared so the two lanes' dots match. */ export const PENDING_ALPHA = 0.45;