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

+102 -35
View File
@@ -32,6 +32,13 @@
import { floatScroll } from "../lib/scrollbar.js";
import { splitDescription } from "../lib/editions.js";
import { accentFromImage } from "../lib/color.js";
import {
BAR_PITCH,
BAR_W,
waveLevels,
RISE_CADENCE_MS,
PENDING_ALPHA,
} from "../lib/waveshape.js";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
@@ -308,37 +315,29 @@
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
// Average dense source peaks into equally sized display bars. Using an
// average (rather than picking one peak) stops isolated transients from
// making random bars hit the ceiling.
//
// The COUNT is derived from the panel's width so the lane reads at one
// density everywhere. It used to be a flat 78 bars on a 2px pitch — a
// 310px block that overflowed the 272px panel and left a third of the
// 560px one empty, which is precisely what looked wrong at both ends.
// One bar per BAR_PITCH px instead: a 2px bar on a 4px gap.
// Pitch 6 chosen off research/nowplaying-fans-waveform/mockup.html (W1).
const BAR_PITCH = 6;
const BAR_W = 2;
// One bar per BAR_PITCH px instead; the fold and shaping live in
// waveshape.js, shared with the player bar so the two lanes cannot drift.
// .np-scroll's horizontal padding, both sides — the lane is what is left.
const LANE_PAD = 28;
const BARS = $derived(
Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)),
);
const bars = $derived.by(() => {
const w = player.waveform;
if (!w || w.length === 0) return [];
const out = [];
for (let i = 0; i < BARS; i++) {
const from = Math.floor((i / BARS) * w.length);
const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length));
let sum = 0;
for (let j = from; j < to; j++) sum += w[j] ?? 0;
out.push(sum / (to - from));
}
return out;
});
const playedBars = $derived((playedPct / 100) * bars.length);
// The rise frontier is the WAVEFORM's own coverage — the audio actually
// decoded, reported by the backend with the peaks — never the live byte
// counter, whose fresher number stretched the drawn shape between
// refreshes. A partial waveform folds across exactly the covered fraction
// of the lane; the rest holds its place as pending dots (nulls below).
const railCoverage = $derived(Math.max(0, Math.min(1, player.waveformCoverage)));
const railCovered = $derived(Math.max(1, Math.floor(BARS * railCoverage)));
const bars = $derived.by(() =>
waveLevels(player.waveform, railCoverage < 1 ? railCovered : BARS),
);
// Bar i always maps to slice i/BARS of the whole track, covered or not.
const playedBars = $derived((playedPct / 100) * BARS);
/** The lane is 38px of the panel's vertical rhythm whether or not peaks
* have arrived yet. `player.waveform` is emptied the instant a track
@@ -351,18 +350,61 @@
* elements rather than replacing them. */
const FLAT_BARS = $derived(Array(BARS).fill(0));
const wavePending = $derived(bars.length === 0);
const waveBars = $derived(wavePending ? FLAT_BARS : bars);
const waveBars = $derived.by(() => {
if (wavePending) return FLAT_BARS;
if (bars.length >= BARS) return bars;
return [...bars, ...Array(BARS - bars.length).fill(null)];
});
function barHeight(value, index) {
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
// Stable per-bar grain keeps a dense loud passage from flattening
// into a ruler of identical maximum-height bars. It is deterministic
// (no visual flicker as the playhead moves) and only matters near the
// cap, where source peaks otherwise converge on the same value.
const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;
const peakFactor = Math.max(0, (capped - 0.58) / 0.3);
const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor));
return 14 + level * 72; // preserve headroom; never peak against the top
// Streaming rise — the playbar's S2, mirrored: bars stand up on the shared
// RISE_CADENCE_MS rhythm behind the download instead of at whatever rate
// bytes arrive; the CSS `rising` transition is the rail's half of RISE_MS.
// Reset when the queue item changes.
let railRevealed = $state(0);
let railRiseKey = null;
$effect(() => {
const key = currentItem ? `${currentItem.path ?? currentItem.title}` : null;
if (key !== railRiseKey) {
railRiseKey = key;
railRevealed = 0;
}
});
// Rising while the waveform is still partial, and until the queue stands
// the last chunk up. A cached replay or a local file arrives with
// coverage 1 on its FIRST decode and never rises — it pops in complete.
const railRising = $derived(
player.track?.source === "stream" &&
(railCoverage < 1 || (railRevealed > 0 && railRevealed < railCovered)),
);
const railRevealDone = $derived(!railRising || railRevealed >= railCovered);
$effect(() => {
if (railRevealDone) return;
const timer = setInterval(() => {
// Untracked reads: the interval IS the clock, not the reactivity.
// Played bars are never hidden behind the queue.
railRevealed = Math.min(
Math.max(railRevealed + 1, Math.ceil(playedBars)),
railCovered,
);
}, RISE_CADENCE_MS);
return () => clearInterval(timer);
});
function railBarHeight(v, i) {
if (wavePending || v == null) return 0;
if (railRising && i >= railRevealed) return 0;
return barHeight(v);
}
function railBarDim(v, i) {
return wavePending || v == null || (railRising && i >= railRevealed);
}
/* `bars` is already shaped 0..1 by waveshape.js (which also explains, with
measurements, why the old gain-and-clamp rule here rendered half the
lane at one height and had to be propped up with per-bar noise). This
only maps that level onto the rail's vertical rhythm: a 14% baseline,
86% ceiling — headroom above the bars is part of the panel's look. */
function barHeight(level) {
return 14 + level * 72;
}
function barFill(index) {
return Math.max(0, Math.min(1, playedBars - index));
@@ -1038,7 +1080,9 @@
class="wave"
class:scrubbing={waveDragging}
class:pending={wavePending}
style="--bar-gap: {BAR_PITCH - BAR_W}px"
class:loading={wavePending && player.wavePending}
class:rising={railRising}
style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}"
role="slider"
tabindex="0"
aria-label="Seek"
@@ -1053,7 +1097,9 @@
>
{#each waveBars as v, i}
<i
style="height:{wavePending ? 0 : barHeight(v, i)}%; --fill:{wavePending ? 0 : barFill(i)}"
style="--i:{i}; height:{railBarHeight(v, i)}%; --fill:{railBarDim(v, i)
? 0
: barFill(i)}; opacity:{railBarDim(v, i) ? PENDING_ALPHA : 1}"
></i>
{/each}
</div>
@@ -1911,6 +1957,27 @@
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
min-height: 2px;
}
/* Loading swell — the playbar's L2, phase-staggered per bar so one crest
travels the lane left→right; the negative delay starts the pattern
mid-flight instead of all-flat. Timing pairs with waveshape.js: 2000ms
sweep + 500ms rest = the 2500ms period, and the 30% crest is the 7px
playbar lift at the rail's 1.6× scale. CSS cannot read those constants —
change them together. */
.wave.loading i {
animation: rail-swell 2500ms linear infinite;
animation-delay: calc(-2500ms + 2000ms * var(--i) / var(--bars, 60));
}
@keyframes rail-swell {
0%, 26%, 100% { height: 2px; opacity: 0.45; }
13% { height: 30%; opacity: 0.85; }
}
/* Streaming rise — the playbar's S2: a released bar stands up over the
same RISE_MS (2000ms), on the house ease. */
.wave.rising i {
transition:
height 2000ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 400ms linear;
}
.times {
display: flex;
justify-content: space-between;
+337 -269
View File
@@ -16,18 +16,24 @@
resumePlayback,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import {
BAR_PITCH,
BAR_W,
waveLevels,
SWELL_SWEEP_MS,
SWELL_REST_MS,
SWELL_AMP_PX,
SWELL_WIDTH_BARS,
RISE_MS,
RISE_CADENCE_MS,
PENDING_ALPHA,
} from "../lib/waveshape.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
const pct = $derived(
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
const bufferPct = $derived(
player.streamTotal > 0
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
: 0,
);
const hasWave = $derived(player.waveform.length > 0);
const idle = $derived(player.status === "idle");
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// The native scratch engine needs a complete local file. A network stream
@@ -59,101 +65,12 @@
: "volume",
);
// ---- the source line ---------------------------------------------------
// Every value below already exists in state — these are the same fields
// NowPlaying reads for its status line, so this needed no new command.
// The derivations are deliberately identical to that component's, because
// two surfaces disagreeing about whether a track is "cached" would be worse
// than either of them being wrong.
const isStream = $derived(!!currentItem?.stream);
const quality = $derived(currentItem?.quality ?? null);
/** Same set NowPlaying uses, so the green dot means the same thing here. */
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
const transferring = $derived(
isStream && player.streamTotal > 0 && player.streamReceived < player.streamTotal,
);
const cachedStream = $derived(
isStream && player.streamTotal > 0 && player.streamReceived >= player.streamTotal,
);
/** What the line is ABOUT, as opposed to what it currently reads. The slot
* is re-measured when THIS changes and never when a percentage ticks. */
const srcState = $derived(
!isStream
? "library"
: cachedStream
? "cached"
: transferring
? "transferring"
: "connecting",
);
const peerName = $derived(currentItem?.stream?.username ?? null);
const rateText = $derived(
player.streamSpeed > 0 ? `${(player.streamSpeed / 1048576).toFixed(1)} MB/s` : null,
);
const etaText = $derived.by(() => {
if (!transferring || player.streamSpeed <= 0) return null;
const secs = (player.streamTotal - player.streamReceived) / player.streamSpeed;
if (!isFinite(secs) || secs < 1) return null;
return secs >= 60
? `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s left`
: `${Math.round(secs)}s left`;
});
const sizeText = (bytes) => (bytes ? `${(bytes / 1048576).toFixed(1)} MB` : null);
const qualityChip = $derived.by(() => {
if (!quality?.format) return null;
return quality.bitrate
? `${quality.format} ${Math.round(quality.bitrate)}k`
: quality.format;
});
/** A library track reports NOTHING: it has no peer and no transfer, and
* zeroes would read as a stalled one. Same call NowPlaying makes. */
const sourceParts = $derived.by(() => {
if (!isStream) return [];
if (cachedStream) return ["Cached", sizeText(player.streamTotal)].filter(Boolean);
if (transferring) {
return [peerName, `${Math.round(bufferPct)}%`, rateText, etaText].filter(Boolean);
}
return peerName ? [`Streaming from ${peerName}`] : [];
});
// The slot reserves a width rather than hugging its text: while a transfer
// runs, the percentage, rate and ETA change every position event, and a
// slot that tracked them would slide the transport and the whole scrubber
// sideways continuously. It GROWS the instant the text needs more room and
// SHRINKS only when the state changes, so nothing ever clips and nothing
// twitches. Written straight to the node: routing it through $state would
// make the effect that measures it depend on its own output.
let srcSlotEl = $state(null);
let srcLineEl = $state(null);
const SLOT_CAP = 340;
const SLOT_PAD_L = 10;
const SLOT_PAD_R = 8;
function sizeSourceSlot(reset = false) {
if (!srcSlotEl) return;
const natural = srcLineEl ? Math.ceil(srcLineEl.getBoundingClientRect().width) : 0;
const want = natural ? Math.min(natural + SLOT_PAD_L + SLOT_PAD_R, SLOT_CAP) : 0;
const current = parseFloat(srcSlotEl.style.width) || 0;
if (!reset && want <= current) return;
srcSlotEl.style.width = `${want}px`;
srcSlotEl.style.paddingLeft = want ? `${SLOT_PAD_L}px` : "0px";
srcSlotEl.style.paddingRight = want ? `${SLOT_PAD_R}px` : "0px";
}
$effect(() => {
srcState;
qualityChip;
sizeSourceSlot(true);
});
$effect(() => {
sourceParts;
sizeSourceSlot(false);
});
// The slot's width is written imperatively, so a window resize — which can
// cross the @container threshold that hides it — has to re-measure.
$effect(() => {
const onResize = () => sizeSourceSlot(true);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
});
// The source line — peer, percentage, rate, ETA, size, format — used to
// live here in a slot whose width was measured and written by hand. It is
// gone: the Now Playing rail already carries every one of those fields, and
// reserving room for them here made the transport's position a function of
// how much a peer had sent. Nothing reads `player.streamSpeed`,
// `streamTotal` or `currentItem.quality` in this component any more.
// ---- the overflow menu -------------------------------------------------
let menuOpen = $state(false);
@@ -215,13 +132,149 @@
);
// Canvas colors; played/hover follow the adaptive accent theme.
const UNPLAYED = "rgba(255,255,255,0.24)";
/** The rail's unplayed colour — `--text-muted`, copied verbatim so both
* lanes fade to the same grey. A canvas fill cannot use the CSS var;
* if the token ever changes, change this with it. */
const UNPLAYED = "#7a7a7a";
const PREVIEW = "rgba(255,255,255,0.66)";
/** Bar geometry, identical to NowPlaying's BAR_PITCH / BAR_W. */
const BAR_PITCH = 6;
const BAR_W = 2;
const BAR_GAP = BAR_PITCH - BAR_W;
// The rail's `color-mix(in srgb, accent × fill%, --text-muted)` for the one
// bar the playhead is inside. `theme.accent` is always "#rrggbb" (state's
// rgbToHex emits nothing else), parsed once per accent value — it only
// changes while a crossfade lerps.
//
// The bar's crossfade target must match its NEIGHBOURS, not a constant:
// normally it fades accent → the rail's grey, but while the hover wash is
// up, every bar to its right is wash-white — fading toward grey then left
// one dark notch stranded between the accent and the wash. So under a wash
// it fades accent → the wash's own resolved colour instead.
const MUTED_RGB = [122, 122, 122];
/** PREVIEW (66% white) composited over an UNPLAYED bar, precomputed:
* 0.66·255 + 0.34·122 — what a washed bar actually renders as. */
const WASHED_RGB = [210, 210, 210];
let blendParsedFor = "";
let blendRgb = MUTED_RGB;
function blendAccent(accent, fill, towardWash) {
if (accent !== blendParsedFor) {
const n = parseInt(accent.slice(1), 16);
blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
blendParsedFor = accent;
}
const toward = towardWash ? WASHED_RGB : MUTED_RGB;
const t = 1 - fill;
const r = Math.round(blendRgb[0] + (toward[0] - blendRgb[0]) * t);
const g = Math.round(blendRgb[1] + (toward[1] - blendRgb[1]) * t);
const b = Math.round(blendRgb[2] + (toward[2] - blendRgb[2]) * t);
return `rgb(${r},${g},${b})`;
}
// ---- loading / streaming motion ---------------------------------------
// The lane is ALWAYS the bar waveform now. With nothing to show it rests
// as dim dots (the rail's pending look, mirrored); while a waveform is
// being computed the loading SWELL crosses those dots; while a stream
// downloads, bars stand up left→right on a fixed rhythm (the RISE) —
// released one per RISE_CADENCE_MS behind the transfer, each easing up
// over RISE_MS, so a fast peer cannot machine-gun the lane. Numbers and
// provenance live in waveshape.js. The solid fallback track this replaces
// also carried the buffer fill; that story is the rise frontier now.
let riseKey = null; // which queue item the rise bookkeeping belongs to
let riseReleased = 0; // bars allowed to stand so far
let riseLastRelease = 0; // clock of the newest release, for the cadence
let riseBorn = []; // per-bar release clock, for the ease
let riseLevels = [];
let riseLevelsPeaks = null;
let riseLevelsCount = 0;
let animRaf = null;
function paintBar(ctx, x, mid, h, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(x, mid - h / 2, BAR_W, h, 1);
ctx.fill();
}
/** One frame while the lane animates; re-arms itself from drawWave as long
* as the state it painted still moves. Position events also repaint, but
* they arrive ~5/s — the swell and the rise need real frames. */
function scheduleAnim(active) {
if (!active || animRaf !== null || !windowActive) return;
animRaf = requestAnimationFrame(() => {
animRaf = null;
drawWave(true);
});
}
$effect(() => () => {
if (animRaf !== null) cancelAnimationFrame(animRaf);
});
/** Resting dots, with the loading swell crossing them while a waveform is
* actually being computed (`player.wavePending`). No accent here — accent
* means played, and this lane has nothing to play yet. */
function drawUnloaded(ctx, W, H, mid, count, now) {
ctx.clearRect(0, 0, W, H);
const pending = player.wavePending;
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;
for (let i = 0; i < count; i++) {
let env = 0;
if (pending && u <= 1) {
const d = (i - centre) / (SWELL_WIDTH_BARS * 0.55);
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})`);
}
return pending;
}
/** 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
* per bar because the heights differ every frame while bars grow. The
* partial waveform describes only the audio that has ARRIVED, so it is
* folded across the covered fraction of the lane, not the whole width. */
function drawRise(ctx, W, H, mid, count, now, frontier, playFrac, playedBars) {
const coveredBars = Math.max(1, frontier);
if (riseLevelsPeaks !== player.waveform || riseLevelsCount !== coveredBars) {
riseLevels = waveLevels(player.waveform, coveredBars);
riseLevelsPeaks = player.waveform;
riseLevelsCount = coveredBars;
}
ctx.clearRect(0, 0, W, H);
const playX = playFrac * W;
const washing = hoverX > playX;
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
const full = Math.floor(playedBars);
for (let i = 0; i < count; i++) {
const x = i * BAR_PITCH;
if (i >= riseReleased) {
paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`);
continue;
}
const grow = Math.min(1, (now - (riseBorn[i] ?? now)) / RISE_MS);
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);
}
if (hoverX >= 0) {
const LINE_H = 18;
ctx.fillStyle = theme.accentHover;
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
}
}
function fmt(secs) {
if (secs == null || !isFinite(secs)) return "–:––";
const s = Math.max(0, Math.floor(secs));
@@ -355,13 +408,15 @@
}
// Wave geometry is invariant while a track and player-bar size stay the
// same. Cache it instead of rebuilding ~1,600 line segments and resetting
// the canvas backing store on every 60 ms position event.
let wavePath = null;
// same. Cache the shaped bars instead of refolding 2000 peaks and
// resetting the canvas backing store on every 60 ms position event.
let wavePath = null; // one Path2D of every bar, for the grouped fills
let waveHeights = []; // per-bar heights, for the one bar the playhead is in
let waveCount = 0;
let wavePathPeaks = null;
let wavePathWidth = 0;
let wavePathHeight = 0;
let lastPlayX = Number.NEGATIVE_INFINITY;
let lastPlayedBars = Number.NEGATIVE_INFINITY;
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
let lastAccent = "";
let lastAccentHover = "";
@@ -378,7 +433,7 @@
const canvas = waveCanvas;
const wrap = waveWrap;
const peaks = player.waveform;
if (!canvas || !wrap || !peaks || peaks.length === 0) return;
if (!canvas || !wrap) return;
const dpr = window.devicePixelRatio || 1;
const W = wrap.clientWidth;
@@ -395,52 +450,130 @@
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const N = peaks.length;
const mid = H / 2;
const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
const now = performance.now();
// Nothing to draw yet: resting dots, with the swell crossing them
// while a waveform request is actually working.
if (!peaks || peaks.length === 0) {
riseKey = null;
const moving = drawUnloaded(ctx, W, H, mid, count, now);
lastPlayedBars = Number.NEGATIVE_INFINITY;
scheduleAnim(moving);
return;
}
// A stream whose waveform does not yet cover the whole track (or one
// still standing its last bars up): the rise owns the lane. The
// frontier is the WAVEFORM'S OWN coverage — the audio that has
// actually been decoded, reported by the backend with the peaks —
// never the live byte counter, whose fresher number stretched the
// drawn shape between refreshes. Where the bars end IS how much of
// the track has loaded: the edge parks when it catches the download
// and marches again when the next chunk lands. A track whose FIRST
// decode already covers everything — a cached replay, a local file —
// never rises at all; it pops in complete.
const waveCov = Math.max(0, Math.min(1, player.waveformCoverage));
const riseCandidate = player.track?.source === "stream";
const riseTrackKey = currentItem?.path ?? player.track?.title ?? "?";
if (riseCandidate && (waveCov < 1 || riseKey === riseTrackKey)) {
if (riseKey !== riseTrackKey) {
riseKey = riseTrackKey;
riseReleased = 0;
riseLastRelease = now;
riseBorn = [];
}
const frontier = Math.floor(waveCov * count);
let guard = 0;
while (
riseReleased < frontier &&
now - riseLastRelease >= RISE_CADENCE_MS &&
guard++ < 600
) {
riseBorn[riseReleased] = now;
riseReleased += 1;
riseLastRelease += RISE_CADENCE_MS;
}
// An idle cadence clock would otherwise bank a backlog and dump it
// all at once when the next coverage arrives.
if (riseReleased >= frontier) {
riseLastRelease = Math.max(riseLastRelease, now - RISE_CADENCE_MS);
}
const playFracNow = player.duration
? Math.max(0, Math.min(1, player.position / player.duration))
: 0;
const playedBarsNow = playFracNow * count;
// Played audio is never hidden behind the queue.
for (let i = riseReleased; i <= Math.min(Math.floor(playedBarsNow), frontier - 1); i++) {
riseBorn[i] = now - RISE_MS;
riseReleased = i + 1;
}
const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0;
const growing = riseReleased > 0 && now - newest < RISE_MS;
if (waveCov < 1 || riseReleased < count || growing) {
drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow);
lastPlayedBars = playedBarsNow;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
scheduleAnim(growing || riseReleased < frontier);
return;
}
// Fully covered, fully grown: hand the lane back to the cached
// painter for good.
riseKey = null;
} else if (!riseCandidate) {
riseKey = null;
}
const geometryChanged =
resized ||
wavePathPeaks !== peaks ||
wavePathWidth !== W ||
wavePathHeight !== H;
if (geometryChanged) {
// Discrete bars on the SAME pitch the Now Playing rail draws — a
// 2px bar on a 4px gap, 1px radius — so the two waveforms in the
// window read as one instrument rather than two. Both derive their
// bar count from that pitch, so neither can drift from the other.
// Mirrored about the centre line here, where the lane is short and
// wide; the rail stands its bars on the floor because it sits above
// a caption.
const path = new Path2D();
// 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
// the centre line here, where the lane is short and wide; the rail
// stands its bars on the floor because it sits above a caption.
const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
const levels = waveLevels(peaks, count);
const path = new Path2D();
waveHeights = levels.map((level) => Math.max(2, level * H));
for (let i = 0; i < count; i++) {
const from = Math.floor((i / count) * N);
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * N));
let sum = 0;
for (let j = from; j < to; j++) sum += peaks[j] ?? 0;
// The rail's shaping, verbatim: capped so a loud passage cannot
// flatten into a ruler, then gamma'd back up.
const v = sum / (to - from);
const h = Math.max(2, Math.pow(Math.min(0.88, v * 1.18), 0.8) * H);
path.roundRect(i * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
path.roundRect(i * BAR_PITCH, mid - waveHeights[i] / 2, BAR_W, waveHeights[i], 1);
}
wavePath = path;
waveCount = count;
wavePathPeaks = peaks;
wavePathWidth = W;
wavePathHeight = H;
}
const playX = player.duration
? Math.max(0, Math.min(W, (player.position / player.duration) * W))
const playFrac = player.duration
? Math.max(0, Math.min(1, player.position / player.duration))
: 0;
const playX = playFrac * W;
const playedBars = playFrac * waveCount;
const visualsChanged =
geometryChanged ||
hoverX !== lastHoverDrawn ||
theme.accent !== lastAccent ||
theme.accentHover !== lastAccentHover ||
player.duration !== lastDuration;
// A sub-device-pixel playhead move cannot materially change the image.
// Keep exact redraws for hover, resize, theme, seeks, and short tracks.
if (!force && !visualsChanged && Math.abs(playX - lastPlayX) < 0.75 / dpr) return;
// The playhead's visible quantum is the boundary bar's BLEND, not a
// clip edge: the fills cut on bar boundaries, so the only thing a
// small move changes is that one bar's colour. The old skip here — no
// redraw under 0.75px of playhead travel — was written for the
// continuous fill and quantized that crossfade into 12% jumps at
// ~200ms, visibly chunkier than the rail blending the same amount
// every position event. Redraw when the blend has moved ≥2% of a bar
// (every event on a normal lane) and still skip the no-op repaints of
// a paused player.
if (!force && !visualsChanged && Math.abs(playedBars - lastPlayedBars) < 0.02) return;
ctx.clearRect(0, 0, W, H);
const fillClip = (x0, x1, color) => {
@@ -454,11 +587,31 @@
ctx.restore();
};
fillClip(0, W, UNPLAYED);
if (hoverX > playX) fillClip(playX, hoverX, PREVIEW);
// Match the primary Play button and the sidebar's completed bars.
// The rail's fade, copied: a bar is one colour, blended from accent to
// the rail's grey by how far the playhead is through it. Fully played
// and unplayed regions clip on bar BOUNDARIES — the cut lands in the
// 4px gap — and only the single bar the playhead is inside gets the
// mixed fill, so no bar is ever chopped down its middle. The accent
// matches the primary Play button and the sidebar's completed bars;
// `accentHover` remains reserved for hover previews and controls.
fillClip(0, playX, theme.accent);
const full = Math.floor(playedBars);
const washing = hoverX > playX;
fillClip(0, W, UNPLAYED);
if (washing) {
// Bar-quantized for the same reason: the wash recolours whole bars
// between the playhead's bar and the cursor, never a sliver.
const washEnd = (Math.floor(hoverX / BAR_PITCH) + 1) * BAR_PITCH;
fillClip((full + 1) * BAR_PITCH, washEnd, PREVIEW);
}
fillClip(0, full * BAR_PITCH, theme.accent);
const frac = playedBars - full;
if (frac > 0 && full < waveCount) {
const h = waveHeights[full];
ctx.fillStyle = blendAccent(theme.accent, frac, washing);
ctx.beginPath();
ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
ctx.fill();
}
// No standing playhead rule any more: at this pitch the accent's own
// leading edge marks the position, and a white line through the bars
@@ -470,7 +623,7 @@
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
}
lastPlayX = playX;
lastPlayedBars = playedBars;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
@@ -541,27 +694,6 @@
</div>
</div>
<!-- Who is sending this, and how far along. Its OWN slot in the bar, not a
child of `.now-playing`: sharing that budget meant a long transfer line
("peer · 50% · 0.3 MB/s · 51s left") crowded the artist and album down
to "Po… · Terrestria…". The two now have independent room, and this one
is what the container query drops when the bar gets tight. Absent
entirely for a library track — see `sourceParts`. -->
{#if sourceParts.length || qualityChip}
<div class="src-slot" bind:this={srcSlotEl}>
<div class="src-line" bind:this={srcLineEl}>
{#each sourceParts as part, i}
{#if i > 0}<span class="sep">·</span>{/if}
<span class:peer={i === 0 && srcState === "transferring"}>{part}</span>
{/each}
{#if qualityChip}
{#if sourceParts.length}<span class="sep">·</span>{/if}
<span class="qchip" class:loss={LOSSLESS.has(quality?.format)}>{qualityChip}</span>
{/if}
</div>
</div>
{/if}
<div class="buttons">
<button
class="ctl small"
@@ -592,46 +724,31 @@
</div>
<div class="scrubber">
<span class="time">{fmt(player.position)}</span>
{#if hasWave}
<div
class="wave-wrap"
bind:this={waveWrap}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(pct)}
tabindex="0"
onmousemove={onWaveHover}
onmouseleave={() => (hoverX = -1)}
class:dragging={draggingSeek}
onpointerdown={(e) => startScrub(e, waveWrap)}
onpointermove={(e) => moveScrub(e, waveWrap)}
onpointerup={(e) => endScrub(e, waveWrap)}
onpointercancel={(e) => endScrub(e, waveWrap)}
>
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
{#if hoverX >= 0}
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
{/if}
</div>
{:else}
<div
class="track"
class:dragging={draggingSeek}
onpointerdown={(e) => startScrub(e, e.currentTarget)}
onpointermove={(e) => moveScrub(e, e.currentTarget)}
onpointerup={(e) => endScrub(e, e.currentTarget)}
onpointercancel={(e) => endScrub(e, e.currentTarget)}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(pct)}
tabindex="0"
>
{#if bufferPct > 0 && player.track?.source === "stream"}
<div class="buffer" style="width: {bufferPct}%"></div>
{/if}
<div class="fill" style="width: {pct}%"></div>
</div>
{/if}
<!-- ALWAYS the bar lane — the solid fallback track is gone. With no
waveform the canvas rests as dots (swelling while one loads,
standing up as a stream arrives), and it stays a seek surface
the whole time: same handlers in every state. The tooltip
needs a duration to have anything truthful to say. -->
<div
class="wave-wrap"
bind:this={waveWrap}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(pct)}
tabindex="0"
onmousemove={onWaveHover}
onmouseleave={() => (hoverX = -1)}
class:dragging={draggingSeek}
onpointerdown={(e) => startScrub(e, waveWrap)}
onpointermove={(e) => moveScrub(e, waveWrap)}
onpointerup={(e) => endScrub(e, waveWrap)}
onpointercancel={(e) => endScrub(e, waveWrap)}
>
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
{#if hoverX >= 0 && player.duration}
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
{/if}
</div>
<span class="time">{fmt(player.duration)}</span>
</div>
@@ -701,9 +818,10 @@
at 560px while the flanks stretch, which is what left a wide window with
a small waveform adrift in the middle.
`container-type` is for the shelf and the source slot below. This element
spans all three grid columns, so its inline size IS the window's usable
width — no sidebar or rail sits inside it. */
Nothing here is width-queried any more — the source slot was the only
container query, and it is gone — so there is no `container-type`. The
row fills its grid area at every window size; the scrubber absorbs
whatever the name, transport and extras do not take. */
.player {
grid-area: player;
display: flex;
@@ -716,7 +834,6 @@
padding: 0 12px;
background: var(--bg-player);
border-radius: var(--radius);
container-type: inline-size;
}
.now-playing {
@@ -724,12 +841,14 @@
align-items: center;
gap: 11px;
min-width: 170px;
/* Capped, or a long title takes the bar: an unbounded cluster shrank
the waveform to 106px on "A Very Long Track Title…". The lane is the
point of this bar, so the title ellipsises before the lane starves.
This is now the meta ALONE — the source line has its own slot — so
the budget no longer moves when a transfer line grows. */
max-width: min(32%, 420px);
/* The song name sets this width, uncapped. `0 1 auto` means the cluster
sits at its natural content width and gives ground ONLY once the
scrubber has reached its 260px floor — at which point the title
ellipsises, and not before. Chosen off
`research/playbar-redesign/width-rule.html` against a capped variant:
the cap bounded how far the transport travelled between tracks (525px
vs 597px on the longest title in that set) at the cost of truncating
names that had room to be read. The name won. */
flex: 0 1 auto;
}
@@ -787,7 +906,15 @@
.who .sub {
min-width: 0;
}
.who .sub:not(.album) {
/* ...but ONLY when there is an album to give way to. `55%` resolves against
`.who`, whose own width is shrink-to-fit of its children — so when the
artist is the only child, the cap is 55% of the artist's own width and it
eats itself: "M83" rendered as "M…" in a 12px box. That is exactly the
state the bar is in while a track loads, where the buffering branch puts
one child in this line. Measured, not reasoned: 23px of text in a 12px
box, with and without the old cluster cap, so this predates that change.
Alone, it keeps the default `0 1 auto` and ellipsises like anything else. */
.who .sub:not(.album):not(:only-child) {
flex: 0 0 auto;
max-width: 55%;
}
@@ -837,52 +964,6 @@
}
.sub.album.link:hover { color: var(--text-primary); }
/* The source line. Width is written by sizeSourceSlot(), never by the
content: while a transfer runs its text changes on every position event,
and a slot that hugged it would slide the transport and the scrubber
sideways continuously. Border-box is global, so that width INCLUDES the
padding — otherwise the padding eats the content and clips the last chip. */
.src-slot {
flex: 0 0 auto;
width: 0;
overflow: hidden;
transition:
width 0.34s cubic-bezier(0.22, 1, 0.36, 1),
padding 0.34s cubic-bezier(0.22, 1, 0.36, 1);
}
.src-line {
display: flex;
align-items: center;
gap: 6px;
width: max-content;
flex: none;
white-space: nowrap;
font-size: 10.5px;
color: var(--text-muted);
/* So a ticking percentage and a counting-down ETA do not reflow. */
font-variant-numeric: tabular-nums;
}
.src-line .sep {
opacity: 0.5;
}
.src-line .peer {
color: var(--text-secondary);
font-weight: 700;
}
.qchip {
font-size: 9.5px;
font-weight: 800;
padding: 1.5px 6px;
border-radius: 4px;
background: var(--bg-highlight);
color: var(--text-secondary);
white-space: nowrap;
}
.qchip.loss {
color: #7ee08a;
background: rgba(80, 200, 110, 0.12);
}
.buttons {
display: flex;
align-items: center;
@@ -988,13 +1069,6 @@
height: 6px;
}
.buffer {
position: absolute;
inset: 0 auto 0 0;
background: hsla(0, 0%, 100%, 0.25);
border-radius: 2px;
}
/* Always the accent — matching the waveform scrubber's played region
(user decision 2026-07-23; the old white-until-hover read as off). */
.fill {
@@ -1099,12 +1173,6 @@
border-radius: 4px;
}
/* What gives way as the window narrows, ordered by how much is lost. The
source line goes first — it is enrichment, and the lane is not. */
@container (max-width: 1180px) {
.src-slot { display: none; }
}
.vol-icon {
display: grid;
place-items: center;
+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);
});
}