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.
101 lines
5.2 KiB
JavaScript
101 lines
5.2 KiB
JavaScript
// The ONE waveform shaping, shared by the player bar's canvas and the Now
|
||
// Playing rail's DOM bars. Both fold the same source array with the same
|
||
// statistic and stretch it by the same rule, so the two lanes in the window
|
||
// are the same instrument — change a number here and both surfaces move.
|
||
//
|
||
// Why this exists (measured, 2026-08-03, on a real decode of "Only Shallow" —
|
||
// 27.7MB FLAC, 11,093 RMS windows; the harness is
|
||
// `bucketing_strategies_on_a_real_track` in crates/player/src/waveform.rs and
|
||
// the approved before/after is research/playbar-redesign/waveform-shaping.html):
|
||
//
|
||
// The old rule — mean-fold, then `min(0.88, v * 1.18) ** 0.8` — was a fixed
|
||
// gain into a hard clamp. On that track the MEDIAN source value crossed the
|
||
// clamp, so 53.8% of the lane rendered at literally one height, and the rail
|
||
// hid the ruler under per-bar noise (deleted the same day). This rule flat-tops
|
||
// 5.0% (the designed outlier clip), σ 0.161 → 0.269, IQR 0.084 → 0.230.
|
||
//
|
||
// The steps, and what each buys:
|
||
// 1. Fold with a mean+peak BLEND, leaning peak: a pure mean averages the
|
||
// loudest ~128ms window in a bar's span away, which is precisely the
|
||
// transient a scrub lane exists to show.
|
||
// 2. Stretch the folded values across the TRACK'S OWN range — its quiet
|
||
// percentile to 0, its loud percentile to 1. A loud master no longer pins
|
||
// to the ceiling and a quiet one is no longer a hairline, because the scale
|
||
// comes from the record, not from a constant. The few bars above the
|
||
// ceiling percentile clip to full height by design: outlier control, ~3%,
|
||
// not half the lane. This mapping is scale-invariant, so the Rust side's
|
||
// normalize-by-loudest-bucket stops mattering.
|
||
// 3. A small gamma and a floor: the gamma keeps the mid-range readable, the
|
||
// floor keeps a silence from vanishing into the lane background.
|
||
|
||
/** Bar geometry both lanes draw on: a 2px bar on a 4px gap. Pitch 6 chosen off
|
||
* research/nowplaying-fans-waveform/mockup.html (W1). */
|
||
export const BAR_PITCH = 6;
|
||
export const BAR_W = 2;
|
||
|
||
// The lane's motion, tuned by Kyle on
|
||
// research/playbar-redesign/waveform-loading2.html (2026-08-03). Two motions
|
||
// exist: the LOADING SWELL (one crest crossing the resting dots while a
|
||
// waveform is being computed) and the STREAMING RISE (bars standing up
|
||
// left→right as the download arrives — released on a fixed rhythm, so a fast
|
||
// transfer cannot machine-gun the lane). The Now Playing rail mirrors both in
|
||
// CSS; its keyframe/transition literals pair with these numbers and must be
|
||
// changed together (see NowPlaying.svelte's lane styles).
|
||
export const SWELL_SWEEP_MS = 2000; // one crossing of the lane
|
||
export const SWELL_REST_MS = 500; // calm between crossings
|
||
export const SWELL_AMP_PX = 7; // crest lift, playbar px (rail scales ×1.6)
|
||
export const SWELL_WIDTH_BARS = 54; // gaussian width of the crest
|
||
export const RISE_MS = 2000; // one bar, floor → full height
|
||
export const RISE_CADENCE_MS = 40; // gap between one bar starting and the next
|
||
/** The ARRIVAL morph — chosen on waveform-arrive.html (M2, direct morph,
|
||
* 2026-08-03): when a COMPLETE waveform lands (local track, cached stream,
|
||
* the final decode replacing a stream's progressive peaks), every bar eases
|
||
* from whatever the lane currently shows — resting dots, a mid-sweep swell,
|
||
* the previous shape — straight into the new one, all at once, one motion.
|
||
* The streaming rise keeps its left-to-right story; this is only coverage 1. */
|
||
export const ARRIVE_MS = 1000;
|
||
/** 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;
|
||
|
||
/** Mean+peak blend per display bar, leaning peak — see step 1 above. */
|
||
const BLEND_MEAN = 0.35;
|
||
const BLEND_PEAK = 0.65;
|
||
/** The track's own range: p05 maps to the floor, p97 to full height. */
|
||
const STRETCH_LO = 0.05;
|
||
const STRETCH_HI = 0.97;
|
||
/** Mild gamma; <1 lifts the mid-range without crushing the top. */
|
||
const CURVE = 0.85;
|
||
/** No bar renders below 12% of the lane, so silence still reads as a lane. */
|
||
const FLOOR = 0.12;
|
||
|
||
/**
|
||
* Fold `peaks` (the raw array from the `waveform` command) into `count`
|
||
* display levels in 0..1, shaped as above. Returns [] when there is nothing
|
||
* to show, matching the callers' "no waveform yet" branches.
|
||
*/
|
||
export function waveLevels(peaks, count) {
|
||
if (!peaks || peaks.length === 0 || count <= 0) return [];
|
||
const folded = new Array(count);
|
||
for (let i = 0; i < count; i++) {
|
||
const from = Math.floor((i / count) * peaks.length);
|
||
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * peaks.length));
|
||
let sum = 0;
|
||
let max = 0;
|
||
for (let j = from; j < to; j++) {
|
||
const v = peaks[j] ?? 0;
|
||
sum += v;
|
||
if (v > max) max = v;
|
||
}
|
||
folded[i] = (sum / (to - from)) * BLEND_MEAN + max * BLEND_PEAK;
|
||
}
|
||
const sorted = [...folded].sort((a, b) => a - b);
|
||
const q = (f) => sorted[Math.min(sorted.length - 1, Math.floor(f * (sorted.length - 1)))];
|
||
const lo = q(STRETCH_LO);
|
||
const span = Math.max(1e-6, q(STRETCH_HI) - lo);
|
||
return folded.map((v) => {
|
||
const stretched = Math.max(0, Math.min(1, (v - lo) / span));
|
||
return FLOOR + Math.pow(stretched, CURVE) * (1 - FLOOR);
|
||
});
|
||
}
|