player bar: the track-change morph stops being a coin flip

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.
This commit is contained in:
KeelrLeek committed 2026-08-03 17:07:33 -05:00
1 parent 0bbdb1bb0e
commit e2cfed081c
2 files changed
+39 -8

No files matched your search

+31 -6
View File
@@ -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;
}
}