player bar: the name sets the width, and the waveform stops flattering the clamp

The source chips are gone — the rail already tells that story — and with
them the measured slot, its effects, and the bar's only container query.
The now-playing cluster hugs the song name with no cap, giving ground only
once the scrubber hits its 260px floor; the artist's 55% ceiling applies
only when an album sits beside it, so a lone loading artist no longer
renders as two letters in a twelve-pixel box.

The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms
per bucket — at 400, every display bar was ~2s of audio averaged twice),
and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp
rule, which flat-topped 53.8% of the bars on a real decode and was propped
up by per-bar noise in the rail — deleted; the lane shows the record, not a
hash of the bar index. Bars fold with a mean+peak blend and stretch across
the track's own p05–p97, so a loud master stops pinning to the ceiling and
a quiet one stops vanishing.

The playbar copies the rail's fade verbatim: whole bars blend accent→grey
with cuts landing in the 4px gap, and the one bar under the playhead
crossfades toward whatever its neighbours are — the wash while hovering,
the muted grey otherwise. The redraw gate watches that blend instead of a
0.75px playhead move, so both lanes now step on every position event.

Loading is drawn, not implied: the solid fallback bar is gone and the lane
is always the bars, always seekable. Empty, it rests as the rail's dots; a
swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a
streaming track's bars stand up left→right on a 40ms cadence, each rising
over 2s. The rise frontier is the waveform's OWN coverage, reported by
stream_waveform beside the peaks — the old contract zero-padded partial
peaks to full length and let the UI fold them against a fresher byte
count, which stretched the whole shape between refreshes. Where the bars
end is how much of the track has actually loaded; cached and local tracks
arrive at coverage 1 and pop in complete. The first partial is also
requested eagerly (fresh refresh clock per track, 700ms retries until
peaks exist), closing most of the audio-before-lane gap.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:08:26 -05:00
1 parent 3aaec30dc6
commit b33d403634
9 files changed
+740 -323

No files matched your search

+62 -8
View File
@@ -3391,6 +3391,7 @@ export async function refreshSourcePrefs() {
// settings unavailable (fresh install / backend restart) → defaults
}
}
void refreshSourcePrefs();
/** Whether a peer file satisfies the user's quality window for this mode.
@@ -4597,6 +4598,16 @@ export const player = $state({
repeat: "off", // off | all | one
// waveform peaks (0..1) for the current local track, [] while loading
waveform: [],
// How much of the TRACK `waveform` actually describes, 0..1 — reported by
// the backend with the peaks themselves, so the two can never disagree.
// 1 for local files and complete streams; a mid-transfer decode covers
// exactly this prefix and the lanes fold it across exactly this fraction.
// Where the standing bars end IS how much of the track has loaded.
waveformCoverage: 1,
// True while a waveform request for the CURRENT track is still working —
// what separates "computing, show the loading swell" from "there is no
// waveform, rest as dots". Cleared on success and on final failure.
wavePending: false,
// Which catalog entity is playing (drives now-playing badges).
catalogRef: { albumId: null, trackId: null },
// True for a track restored from the last session whose AUDIO did not
@@ -4999,7 +5010,12 @@ export function initEvents() {
} else if (
ui.windowActive
&& cur?.stream
&& performance.now() - waveRefreshAt > 3000
// Until the FIRST peaks land, retry fast — the earliest decodes
// race the transfer's opening seconds and often see too little
// file to succeed, and audio audibly playing over a still-empty
// lane was the gap being closed here. Once bars exist, the
// geometric decode thresholds make 3s the right cadence.
&& performance.now() - waveRefreshAt > (player.waveform.length ? 3000 : 700)
) {
// Still downloading: refresh the partial waveform so it grows
// with the transfer.
@@ -5207,6 +5223,10 @@ function playQueueIndex(i) {
// Remote track streamed from a peer while it downloads to cache.
player.streamReceived = 0;
player.streamTotal = item.stream.size;
// A fresh track must not inherit the previous one's refresh clock —
// that throttled the FIRST partial waveform by up to three seconds of
// audible-but-laneless playback.
waveRefreshAt = 0;
resetStreamRate();
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
appendedNextPath = null;
@@ -5453,21 +5473,35 @@ async function maybeExtendAlbumQueue() {
// ---- waveform ----
const WAVE_BUCKETS = 400;
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
// by the time the lanes folded buckets into display bars every bar was ~2s
// averaged twice, and the transients the lane exists to show were gone before
// shaping ever ran (measured in waveshape.js's header). 2000 is ~128ms per
// bucket on a typical track — finer than any lane's bar pitch — and the
// command's ceiling is 4096, so this is only a constant.
const WAVE_BUCKETS = 2000;
let waveReqPath = null;
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
let waveRefreshAt = 0;
async function loadWaveform(path) {
player.waveform = [];
player.wavePending = !!path;
waveReqPath = path;
if (!path) return;
try {
const peaks = await api.waveform(path, WAVE_BUCKETS);
// Ignore if the track changed while we were computing.
if (waveReqPath === path) player.waveform = peaks;
if (waveReqPath === path) {
player.waveform = peaks;
player.waveformCoverage = 1;
}
} catch {
if (waveReqPath === path) player.waveform = [];
} finally {
// Either way the request is over; a failed local decode rests as dots
// rather than swelling forever at a waveform that will never come.
if (waveReqPath === path) player.wavePending = false;
}
}
@@ -5480,7 +5514,13 @@ let queuedStreamWave = null;
async function loadStreamWaveform(item, final = false) {
const key = `stream:${item.stream.username}|${item.path}`;
if (waveReqPath !== key) player.waveform = [];
if (waveReqPath !== key) {
player.waveform = [];
// Pending until the FIRST partial decode lands; after that the lane
// has bars and the streaming rise owns the story. A mid-stream refresh
// that fails just keeps the bars it already has.
player.wavePending = true;
}
waveReqPath = key;
// A late partial FLAC decode can take close to the three-second refresh
// interval. Coalesce all overlap into the newest request, retaining a
@@ -5498,7 +5538,12 @@ async function loadStreamWaveform(item, final = false) {
next = null;
const seq = ++waveSeq;
try {
const peaks = await api.streamWaveform(
// { coverage, peaks }: the backend reports how much of the
// track the peaks describe, measured from the SAME file read
// that produced them — the lanes' rise frontier is anchored to
// this, never to the live byte counter, so the drawn shape
// cannot stretch between refreshes.
const slice = await api.streamWaveform(
request.item.stream.username,
request.item.path,
request.item.stream.size,
@@ -5507,12 +5552,19 @@ async function loadStreamWaveform(item, final = false) {
if (
waveReqPath === request.key
&& seq === waveSeq
&& peaks.length > 0
&& slice.peaks.length > 0
) {
player.waveform = peaks;
player.waveform = slice.peaks;
player.waveformCoverage = Math.max(0, Math.min(1, slice.coverage));
player.wavePending = false;
}
} catch {
// stays empty — the buffer/progress bar renders instead
// Peaks stay as they were. A failed FINAL decode is the end of
// the line though — stop the loading swell so the lane rests
// as dots instead of promising a waveform that will not come.
if (request.final && waveReqPath === request.key) {
player.wavePending = false;
}
}
const queued = queuedStreamWave;
queuedStreamWave = null;
@@ -5598,6 +5650,8 @@ function stopPlayback() {
player.queue = [];
player.queueIndex = -1;
player.waveform = [];
player.waveformCoverage = 1;
player.wavePending = false;
player.catalogRef = { albumId: null, trackId: null };
latestPlayerPosition = 0;
latestPlayerDuration = null;
+93
View File
@@ -0,0 +1,93 @@
// 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
/** 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);
});
}