// 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); }); }