feat: crossfade preview suite, loudness normalization, debug overlay

Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
This commit is contained in:
KeelrLeek committed 2026-07-23 16:05:14 -05:00
1 parent 5e877c0b0a
commit 793f78370e
27 files changed
+2834 -178

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
<script>
// Dev debug overlay — Ctrl+Shift+D. Read-only glass pane over the app:
// system + process-tree CPU/GPU/RAM, network rates, the active track's
// download progress/speed and loudness-normalization state, renderer
// FPS, and the preview session when one is open. Polls the backend only
// while visible; costs nothing when hidden.
import * as api from "../lib/api.js";
import { player } from "../lib/state.svelte.js";
import { previewUi } from "../lib/preview/previewSession.svelte.js";
let visible = $state(false);
let stats = $state(null);
let fps = $state(0);
let dlSpeed = $state(0); // bytes/sec of the CURRENT track's stream
function onKeydown(e) {
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
e.preventDefault();
visible = !visible;
}
}
// Poll + FPS only while visible.
$effect(() => {
if (!visible) return;
let lastReceived = 0;
let lastAt = performance.now();
const tick = async () => {
try {
stats = await api.debugStats();
} catch {
stats = null;
}
// Download speed from the BACKEND's own progress handle — the
// player store's mirror is window-activity-gated and can lag.
const now = performance.now();
const cur = stats?.dlReceived ?? 0;
const dt = (now - lastAt) / 1000;
dlSpeed = cur >= lastReceived && dt > 0 ? (cur - lastReceived) / dt : 0;
lastReceived = cur;
lastAt = now;
};
void tick();
const id = setInterval(tick, 1000);
let frames = 0;
let fpsWindow = performance.now();
let raf = requestAnimationFrame(function loop() {
frames += 1;
const now = performance.now();
if (now - fpsWindow >= 500) {
fps = Math.round((frames * 1000) / (now - fpsWindow));
frames = 0;
fpsWindow = now;
}
raf = requestAnimationFrame(loop);
});
return () => {
clearInterval(id);
cancelAnimationFrame(raf);
};
});
const mb = (bytes) => (bytes / 1048576).toFixed(bytes >= 10485760 ? 0 : 1);
const gb = (bytes) => (bytes / 1073741824).toFixed(1);
function rate(bps) {
if (bps >= 1048576) return `${(bps / 1048576).toFixed(1)} MB/s`;
return `${(bps / 1024).toFixed(0)} KB/s`;
}
function fmtUptime(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m${String(secs % 60).padStart(2, "0")}s`;
}
const dlTotal = $derived(stats?.dlTotal ?? 0);
const dlDone = $derived(stats?.dlReceived ?? 0);
const loud = $derived(stats?.loudness ?? null);
</script>
<svelte:window onkeydown={onKeydown} />
{#if visible}
<aside class="debug-overlay" aria-hidden="true">
<div class="hdr">DEBUG <span class="dim">Ctrl+Shift+D</span></div>
{#if stats}
<div class="sec">
<div><b>CPU</b> app {stats.cpuProcess.toFixed(1)}% · sys {stats.cpuSystem.toFixed(1)}%</div>
<div><b>GPU</b> {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}% (3D)` : "n/a"}</div>
<div><b>RAM</b> ws {mb(stats.memWorkingSet)} MB · reserved {mb(stats.memPrivate)} MB</div>
<div class="dim">system {gb(stats.memSystemUsed)} / {gb(stats.memSystemTotal)} GB · {stats.processCount} procs</div>
<div><b>NET</b> ↓ {rate(stats.netRxBps)} · ↑ {rate(stats.netTxBps)}</div>
</div>
<div class="sec">
<div><b>FPS</b> {fps}</div>
<div><b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}</div>
{#if dlTotal > 0}
<div>
<b>DL</b>
{mb(dlDone)} / {mb(dlTotal)} MB ({((dlDone / dlTotal) * 100).toFixed(0)}%)
{#if dlDone < dlTotal}· {rate(dlSpeed)}{:else}· done{/if}
</div>
<div class="bar"><i style="width:{Math.min(100, (dlDone / dlTotal) * 100)}%"></i></div>
{:else}
<div class="dim">DL — no active stream</div>
{/if}
</div>
<div class="sec">
<div>
<b>NORM</b>
{#if !stats.normalizationEnabled}off{:else if loud}
{loud.lufs != null ? `${loud.lufs.toFixed(1)} LUFS` : "measuring…"}
→ {loud.gainDb >= 0 ? "+" : ""}{loud.gainDb.toFixed(1)} dB
<span class="dim">(target {stats.targetLufs})</span>
{:else}on · idle{/if}
</div>
{#if stats.normalizationEnabled && loud}
<div class="dim">
peak {loud.peak.toFixed(2)}
· {loud.cached ? "cache hit" : loud.analyzing ? "analyzing" : "measured this play"}
</div>
{/if}
{#if previewUi.open}
<div>
<b>PREVIEW</b>
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
</div>
{/if}
</div>
<div class="dim ft">up {fmtUptime(stats.uptimeSecs)}</div>
{:else}
<div class="dim">waiting for backend…</div>
{/if}
</aside>
{/if}
<style>
.debug-overlay {
position: fixed;
top: 40px;
right: 12px;
z-index: 2147483000;
pointer-events: none;
min-width: 300px;
max-width: 380px;
padding: 10px 12px;
background: rgba(8, 10, 14, 0.82);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
backdrop-filter: blur(4px);
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 11px;
line-height: 1.55;
color: #d7e0ea;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.hdr {
font-weight: 700;
letter-spacing: 0.08em;
color: #7fd0ff;
margin-bottom: 4px;
}
.sec {
padding: 4px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
b {
color: #9fc1e0;
font-weight: 600;
display: inline-block;
min-width: 46px;
}
.dim {
color: rgba(215, 224, 234, 0.55);
}
.ft {
margin-top: 2px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 4px;
}
.bar {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
margin: 2px 0 1px;
}
.bar i {
display: block;
height: 100%;
background: #7fd0ff;
}
</style>
+147 -18
View File
@@ -11,6 +11,8 @@
// R1b capsule, T3 directional metadata swaps, folded control row with
// the P2 ghost pill, F3 footer.
import { tick, untrack } from "svelte";
import { fade, scale } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import {
previewUi,
previewCommands,
@@ -31,6 +33,16 @@
const playing = $derived(st?.phase === PHASE.PLAYING);
const ended = $derived(st?.phase === PHASE.ENDED);
const failed = $derived(st?.phase === PHASE.ITEM_FAILED);
// The play button shows INTENT, not the transport's instantaneous phase:
// during a step the session passes through resolving/buffering for a few
// hundred ms, and flipping the glyph there reads as a stutter (user
// report 2026-07-23). togglePlay() parks from those phases, so the pause
// glyph is honest — clicking it stops the audio either way.
const wantsPlay = $derived(
playing ||
(st?.desiredPlaying &&
(st?.phase === PHASE.RESOLVING_MEDIA || st?.phase === PHASE.BUFFERING))
);
const playable = $derived(st ? playableManifestIndices(st) : []);
const cursor = $derived(st ? playableCursor(st) : null);
const unavailableCount = $derived(items.length - playable.length);
@@ -399,6 +411,16 @@
const posLabel = $derived(fmtMs(st?.positionMs ?? 0) || "0:00");
const clipLabel = $derived(fmtMs(st?.clipDurationMs ?? 30_000) || "0:30");
// Shared by the transport buttons and the reel-side arrows.
const prevDisabled = $derived(
cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0
);
const nextDisabled = $derived(
cursor == null
? activeIndex != null && !playable.some((i) => i > activeIndex)
: cursor >= playable.length - 1
);
async function onPlayFull() {
if (!previewUi.playFull || !activeItem) return;
const starter = previewUi.playFull;
@@ -410,7 +432,15 @@
<svelte:window onkeydown={onKeydown} />
{#if previewUi.open && st}
<div class="scrim" onclick={() => closePreview()} aria-hidden="true"></div>
<!-- Scale & fade (user pick 2026-07-23, variant A of the motion mockup):
240 ms in from 96%, 190 ms out to 97%, scrim fading alongside. -->
<div
class="scrim"
onclick={() => closePreview()}
aria-hidden="true"
in:fade={{ duration: 240 }}
out:fade={{ duration: 190 }}
></div>
<div
class="modal"
class:is-playing={playing}
@@ -419,6 +449,8 @@
aria-label={manifest ? `Track previews: ${manifest.context.title}` : "Track previews"}
style="--pv-accent: {pvAccent}; --pv-accent-hover: {pvHover};"
bind:this={dialogEl}
in:scale={{ duration: 240, start: 0.96, easing: cubicOut }}
out:scale={{ duration: 190, start: 0.97 }}
>
<div class="bg" aria-hidden="true">
<img class="bleed" class:show={bleedSlot === 1 && bleedA} src={bleedA || undefined} alt="" />
@@ -490,18 +522,18 @@
class="m-step"
title="Previous preview (↑)"
aria-label="Previous preview"
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0}
disabled={prevDisabled}
onclick={() => step(-1)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2.2v12H6zm3.8 6l8.2 6V6z" /></svg>
</button>
<button
class="m-play"
title={playing ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
aria-label={playing ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
title={wantsPlay ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
aria-label={wantsPlay ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
onclick={() => previewCommands.togglePlay()}
>
{#if playing}
{#if wantsPlay}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
{:else if ended}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /></svg>
@@ -513,7 +545,7 @@
class="m-step"
title="Next preview (↓)"
aria-label="Next preview"
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i > activeIndex) : cursor >= playable.length - 1}
disabled={nextDisabled}
onclick={() => step(1)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.8 6H18v12h-2.2zM6 6l8.2 6L6 18z" /></svg>
@@ -574,6 +606,26 @@
{/each}
</div>
</div>
<div class="reel-arrows">
<button
class="reel-arrow"
title="Previous preview (↑)"
aria-label="Previous preview"
disabled={prevDisabled}
onclick={() => step(-1)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14.5l6-6 6 6" /></svg>
</button>
<button
class="reel-arrow"
title="Next preview (↓)"
aria-label="Next preview"
disabled={nextDisabled}
onclick={() => step(1)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9.5l6 6 6-6" /></svg>
</button>
</div>
</div>
</div>
@@ -589,7 +641,13 @@
{/each}
</span>
<span class="m-pos">
{cursor != null ? `${cursor + 1} of ${playable.length}` : `unavailable · ${(activeIndex ?? 0) + 1} of ${items.length}`}
{#if cursor != null}
<!-- Reserve the widest digit count so stepping 9→10
never widens the label and reflows the flex rail. -->
<span class="pos-n" style="min-width: {String(playable.length).length}ch">{cursor + 1}</span> of {playable.length}
{:else}
unavailable · {(activeIndex ?? 0) + 1} of {items.length}
{/if}
{#if unavailableCount > 0}&nbsp;· {unavailableCount} unavailable{/if}
</span>
</div>
@@ -604,14 +662,31 @@
position: fixed;
inset: 0;
z-index: 60;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
/* Spotify-style: the page behind darkens clearly and blurs a touch,
so the panel reads as a floating modal rather than a full takeover. */
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.modal {
/* A centred, window-relative panel — not an inset-26 near-fullscreen
sheet. Capped so it never dominates a large monitor while the two
columns still keep their room on smaller windows. */
position: fixed;
inset: 26px;
/* Safe frame: the modal centers (margin auto) inside a fixed inset
box whose 48px top ALWAYS clears the 30px app titlebar — the modal
can never cover it, whatever the window size — with matching
gutters on the sides and bottom. The width/height caps keep it a
real modal on a 4K monitor (1660×1000) while the calc() terms
guarantee it never outgrows the frame on small windows. */
inset: 48px;
margin: auto;
width: min(1660px, 90vw, calc(100vw - 96px));
height: min(1000px, calc(100vh - 96px));
/* vw-based headline: small windows sit at the 38px floor and only
wide screens grow it. */
--pv-title: clamp(38px, 2.4vw, 52px);
z-index: 61;
border-radius: 14px;
overflow: hidden;
@@ -727,7 +802,7 @@
/* -------------------------------------------------------------- body */
.m-body {
display: grid;
grid-template-columns: minmax(300px, 500px) auto;
grid-template-columns: minmax(320px, 680px) auto;
justify-content: center;
gap: 36px;
padding: 0 20px;
@@ -743,13 +818,14 @@
/* Fixed two-line title slot, BOTTOM-aligned: a one-line title sits against
the artist row instead of floating a line above it. */
.title-slot {
height: 82px;
/* Two lines of the (now responsive) title at 1.08 line-height. */
height: calc(var(--pv-title, 38px) * 1.08 * 2);
display: flex;
align-items: flex-end;
margin-bottom: 8px;
}
.m-title {
font-size: 38px;
font-size: var(--pv-title, 38px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.8px;
@@ -797,7 +873,7 @@
gap: 11px;
margin-top: 28px;
/* Spans the same width as the control row beneath it. */
max-width: 472px;
max-width: 560px;
}
.m-wave {
flex: 1;
@@ -830,7 +906,10 @@
cursor: pointer;
font-family: inherit;
}
.m-play {
/* Scoped under .m-transport so it outranks the `.m-transport button`
reset (0,1,1) that was blanking the accent fill and the black glyph —
the mockup only had a plain `button{}` reset, which lost to `.m-play`. */
.m-transport .m-play {
width: 46px;
height: 46px;
border-radius: 50%;
@@ -841,7 +920,7 @@
transition: background 0.35s ease;
flex: none;
}
.m-play:hover {
.m-transport .m-play:hover {
transform: scale(1.05);
}
.m-step {
@@ -917,7 +996,11 @@
}
.reel {
/* Single source of geometry: everything below is calc() off these. */
--cover: clamp(190px, 34vh, 340px);
/* max(34vh, 22vw) so wide screens (4K) grow the cover via the vw term
while smaller windows stay on 34vh exactly as before — 22vw only
overtakes 34vh once the viewport is very wide, so the 4:3 window is
untouched and the 4K cover jumps from 340 up to the 540 cap. */
--cover: clamp(190px, max(34vh, 22vw), 540px);
--gap: 30px;
--stride: calc(var(--cover) + var(--gap));
--bleed-room: 70px;
@@ -928,6 +1011,43 @@
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
}
/* Manual step chevrons beside the art (user request 2026-07-23): bare
glyphs like the reference, no chrome until hover. They sit in the
reel's 70px bleed gutter — negative margin tucks them against the
cover's edge without widening the column. */
.reel-arrows {
display: flex;
flex-direction: column;
gap: 26px;
margin-left: -30px;
position: relative;
z-index: 2;
}
.reel-arrow {
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
background: none;
border: 0;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, transform 0.15s ease;
}
.reel-arrow:hover:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.08);
transform: scale(1.08);
}
.reel-arrow:active:not(:disabled) {
transform: scale(0.96);
}
.reel-arrow:disabled {
opacity: 0.25;
cursor: default;
}
.reel-track {
/* A fixed anchor at the reel centre — it never moves. Cards position
themselves relative to it at (index − activeIndex) × stride and
@@ -1134,6 +1254,11 @@
gap: 3px;
flex: 1;
min-width: 0;
/* Fixed box height: the active segment animates 3↔5px, so without a
pinned height the rail's box shrank mid-step and the centred rail
bounced ~0.5px vertically on every track change. Now the segments
grow inside a constant-height box and nothing reflows. */
height: 5px;
}
.rail i {
flex: 1;
@@ -1149,6 +1274,10 @@
font-variant-numeric: tabular-nums;
flex: none;
}
.pos-n {
display: inline-block;
text-align: right;
}
.sr-announce {
position: absolute;