player bar: a complete waveform arrives as one motion instead of popping

Chosen on waveform-arrive.html (M2, direct morph, 1000ms): when a track
has its whole waveform at once — a local file, a fully-cached stream, or
the final decode replacing a stream's progressive peaks — every bar eases
from whatever the lane currently shows straight into the new shape, all
together. From the resting dots that reads as a uniform grow; from a
mid-sweep swell or the previous track's bars it is literally one waveform
transforming into another. The streaming rise keeps its left-to-right
story untouched; the morph fires only on a peaks swap at coverage 1, and
never on a resize, which re-lays the same shape and stays instant.

The canvas keeps a record of what it actually painted last frame — dots,
swell, rise, morph or settled — so the morph always starts from the truth
on screen, and the fade colours (accent, boundary blend, hover wash) stay
live through it. The rail mirrors the motion in CSS: an `arriving` class
carries the same 1000ms house-ease height transition, placed after the
rise rule so a stream's final decode arrives on this ease too.

Measured live on a cached start: bar heights walked 5.2 → 8.2 → 10.6 →
12.3 → 13.6 → 14.6 → 15.0 and settled at 15.1 at the 1000ms mark — the
ease-out curve, frame by frame.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:46:37 -05:00
1 parent 4c9a6e312a
commit 1765de65cf
3 files changed
+119 -11

No files matched your search

+79 -11
View File
@@ -26,6 +26,7 @@
SWELL_WIDTH_BARS,
RISE_MS,
RISE_CADENCE_MS,
ARRIVE_MS,
PENDING_ALPHA,
} from "../lib/waveshape.js";
import Icon from "./Icon.svelte";
@@ -185,6 +186,14 @@
let riseLevels = [];
let riseLevelsPeaks = null;
let riseLevelsCount = 0;
// The arrival morph (M2 on waveform-arrive.html): when a COMPLETE
// waveform lands, every bar eases from whatever the lane painted last —
// resting dots, a mid-sweep swell, the previous shape — straight into the
// new one over ARRIVE_MS. `lastDrawnHeights` is kept current by every
// painter so the morph always starts from the truth on screen.
let lastDrawnHeights = null;
let morphFrom = null;
let morphStart = 0;
let animRaf = null;
function paintBar(ctx, x, mid, h, color) {
@@ -217,6 +226,7 @@
const period = SWELL_SWEEP_MS + SWELL_REST_MS;
const u = (now % period) / SWELL_SWEEP_MS; // > 1 during the rest beat
const centre = u * (count + SWELL_WIDTH_BARS * 2) - SWELL_WIDTH_BARS;
const heights = new Array(count);
for (let i = 0; i < count; i++) {
let env = 0;
if (pending && u <= 1) {
@@ -224,11 +234,25 @@
env = Math.exp(-d * d);
}
const alpha = PENDING_ALPHA + (0.85 - PENDING_ALPHA) * env;
paintBar(ctx, i * BAR_PITCH, mid, 2 + env * SWELL_AMP_PX * 2, `rgba(122,122,122,${alpha})`);
heights[i] = 2 + env * SWELL_AMP_PX * 2;
paintBar(ctx, i * BAR_PITCH, mid, heights[i], `rgba(122,122,122,${alpha})`);
}
lastDrawnHeights = heights;
return pending;
}
/** 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. */
function barColor(i, full, playedBars, washing, washEndBar) {
if (i < full) return theme.accent;
if (i === full && playedBars - full > 0) {
return blendAccent(theme.accent, playedBars - full, washing);
}
if (washing && i > full && i <= washEndBar) return `rgb(${WASHED_RGB.join(",")})`;
return UNPLAYED;
}
/** The streaming rise. Released bars ease from the floor to their height;
* queued and beyond-frontier bars are pending dots. The colour pass is
* the full painter's fade — accent, boundary blend, wash, muted — done
@@ -247,9 +271,11 @@
const washing = hoverX > playX;
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
const full = Math.floor(playedBars);
const heights = new Array(count);
for (let i = 0; i < count; i++) {
const x = i * BAR_PITCH;
if (i >= riseReleased) {
heights[i] = 2;
paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`);
continue;
}
@@ -257,17 +283,10 @@
const eased = 1 - Math.pow(1 - grow, 3);
const target = Math.max(2, (riseLevels[i] ?? 0.12) * H);
const h = 2 + (target - 2) * eased;
let color;
if (i < full) color = theme.accent;
else if (i === full && playedBars - full > 0) {
color = blendAccent(theme.accent, playedBars - full, washing);
} else if (washing && i > full && i <= washEndBar) {
color = `rgb(${WASHED_RGB.join(",")})`;
} else {
color = UNPLAYED;
}
paintBar(ctx, x, mid, h, color);
heights[i] = h;
paintBar(ctx, x, mid, h, barColor(i, full, playedBars, washing, washEndBar));
}
lastDrawnHeights = heights;
if (hoverX >= 0) {
const LINE_H = 18;
ctx.fillStyle = theme.accentHover;
@@ -458,6 +477,7 @@
// while a waveform request is actually working.
if (!peaks || peaks.length === 0) {
riseKey = null;
morphFrom = null;
const moving = drawUnloaded(ctx, W, H, mid, count, now);
lastPlayedBars = Number.NEGATIVE_INFINITY;
scheduleAnim(moving);
@@ -512,6 +532,7 @@
const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0;
const growing = riseReleased > 0 && now - newest < RISE_MS;
if (waveCov < 1 || riseReleased < count || growing) {
morphFrom = null; // the rise owns the lane's motion
drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow);
lastPlayedBars = playedBarsNow;
lastHoverDrawn = hoverX;
@@ -534,6 +555,7 @@
wavePathWidth !== W ||
wavePathHeight !== H;
if (geometryChanged) {
const peaksSwapped = wavePathPeaks !== peaks;
// Same pitch AND same shaping as the Now Playing rail — both come
// from waveshape.js — so the two lanes in the window read as one
// instrument and neither can drift from the other. Mirrored about
@@ -551,6 +573,15 @@
wavePathPeaks = peaks;
wavePathWidth = W;
wavePathHeight = H;
// 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];
morphStart = now;
}
}
const playFrac = player.duration
@@ -558,6 +589,42 @@
: 0;
const playX = playFrac * W;
const playedBars = playFrac * waveCount;
// Mid-arrival: every bar eases from where it was to where the new
// waveform puts it, all together, with the full fade colours live.
if (morphFrom) {
const t = (now - morphStart) / ARRIVE_MS;
if (t >= 1) {
morphFrom = null;
} else {
const k = 1 - Math.pow(1 - t, 3);
const full = Math.floor(playedBars);
const washing = hoverX > playX;
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
ctx.clearRect(0, 0, W, H);
const heights = new Array(waveCount);
for (let i = 0; i < waveCount; i++) {
const from = morphFrom[i] ?? 2;
const h = from + (waveHeights[i] - from) * k;
heights[i] = h;
paintBar(ctx, i * BAR_PITCH, mid, h, barColor(i, full, playedBars, washing, washEndBar));
}
lastDrawnHeights = heights;
if (hoverX >= 0) {
const LINE_H = 18;
ctx.fillStyle = theme.accentHover;
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
}
lastPlayedBars = playedBars;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
scheduleAnim(true);
return;
}
}
const visualsChanged =
geometryChanged ||
hoverX !== lastHoverDrawn ||
@@ -623,6 +690,7 @@
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
}
lastDrawnHeights = waveHeights;
lastPlayedBars = playedBars;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;