Files
SoulStream/src/lib/waveshape.js
T
KeelrLeek 1765de65cf 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.
2026-08-03 16:46:37 -05:00

101 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
}