The track preview modal could hold a machine at 25% GPU. Three separate causes, each measured in isolation with a kill-style ladder and, once the GPU-engine counter proved to be clock-relative (a lighter workload read HIGHER), with clock-immune process CPU-seconds. Idle drain. The scrim's backdrop-filter is re-executed on every presented frame -- a backdrop filter is never cached -- so its cost is presents/sec times window area. Two infinite animations kept the modal presenting at the display's refresh rate forever: the eq capsule's bars animated `height` (a layout property, so style/layout/paint on the main thread every vsync) and the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either one alone kept the pipeline hot, which is why killing one changed nothing. The glow is deleted outright at Kyle's request; the eq bars now scale with a stepped transform whose three delays are multiples of the step interval, so all three jump on the same instants and one present serves them all. Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%. Scrolling. The animations turned out to be innocent here -- reverting them moved the number not at all. The cost was per-step work: re-decoding the full artwork for the background wash, a main-thread accent scan, a new cover mount, three times a second. The wash is now a 96x58 canvas with the blur/saturate/brightness baked in at draw time and cached per artwork, and an overlay preloader walks the manifest once at open -- nearest to the active card first, capped at 60 -- filling the decoded-image, wash and accent caches ahead of the user. A rapid-nav jump-cut was tried and rejected on sight; the animations stay full at every rate. Backend. preview_resolve swept the clip directory on every call, and any landing outside the +/-1 prefetch window paid a full Deezer mint, download, validate, write and loudness measure. The fetch-store-measure core is factored out and a new preview_prewarm walks the session's track ids at open, paced 250ms on top of the existing Deezer throttle and starting 1.5s in so the first clip owns the network, halted by a generation guard on dispose. A burst landing on a never-visited track is now audible in 154ms off local disk. The sweep is throttled to ten minutes, and dispose deletes prewarmed files instead of orphaning them until the hourly reap. The debug overlay's GPU row was summing the counters machine-wide, so it read roughly double the app's share with Discord and DWM folded in. It now reports `app X% - sys Y%`, the app figure restricted to our process tree, matching Task Manager's per-process rows.
362 lines
15 KiB
Svelte
362 lines
15 KiB
Svelte
<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 drops = $state(0); // main-thread frames >25ms since overlay opened
|
||
let worstGap = $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 prevFrame = performance.now();
|
||
drops = 0;
|
||
worstGap = 0;
|
||
let raf = requestAnimationFrame(function loop() {
|
||
frames += 1;
|
||
const now = performance.now();
|
||
// Main-thread frame gap — the jank signal. >25ms = a frame the
|
||
// user can feel; count it and remember the worst.
|
||
const gap = now - prevFrame;
|
||
prevFrame = now;
|
||
if (gap > 25) {
|
||
drops += 1;
|
||
if (gap > worstGap) worstGap = Math.round(gap);
|
||
}
|
||
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);
|
||
|
||
// ---- normalization slew readout ----
|
||
// The audio thread mirrors both the applied gain and the one it is heading
|
||
// for; a correction in flight is exactly the difference between them.
|
||
const fmtDb = (db) => `${db >= 0 ? "+" : ""}${db.toFixed(2)} dB`;
|
||
const slewing = $derived(
|
||
stats?.normNowDb != null
|
||
&& stats?.normTargetDb != null
|
||
// The audio thread stops slewing below this, so anything smaller is
|
||
// settled rather than creeping.
|
||
&& Math.abs(stats.normTargetDb - stats.normNowDb) > 0.001,
|
||
);
|
||
/** Seconds left at the player's 0.25 dB/s slew (0.015 dB per 60ms tick). */
|
||
const slewSecs = $derived(
|
||
slewing ? Math.abs(stats.normTargetDb - stats.normNowDb) / 0.25 : 0,
|
||
);
|
||
/** Position on a −15…+9 dB scale — the clamp `set_norm_gain_db` enforces. */
|
||
const dbPct = (db) => Math.max(0, Math.min(100, ((db + 15) / 24) * 100));
|
||
/**
|
||
* The head start is only meaningful while a prefix-derived gain is the one
|
||
* in force, and only visible as the gap between what the prefix asked for
|
||
* and where the track actually started — so both ends are required before
|
||
* it is drawn at all. A stale or absent value must read as "not applied"
|
||
* rather than as zero decibels of correction.
|
||
*
|
||
* `analyzing` is the third condition and not an optional one. At
|
||
* `Analysis::Complete` the backend retires the cut-only rule and slews to
|
||
* the whole-track gain, so the head start stops being what the player is
|
||
* doing — while `loudness.gainDb` still reports the prefix value, being
|
||
* kept for the NEXT play. Without this the line goes on claiming a
|
||
* correction that has already been released.
|
||
*/
|
||
const headStart = $derived(
|
||
loud?.headStartDb > 0 && loud?.prefixGainDb != null && loud?.analyzing
|
||
? { db: loud.headStartDb, from: loud.prefixGainDb }
|
||
: null,
|
||
);
|
||
</script>
|
||
|
||
<svelte:window onkeydown={onKeydown} />
|
||
|
||
{#if visible}
|
||
<aside class="debug-overlay">
|
||
<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>
|
||
<!-- app = OUR process tree (what Task Manager shows per
|
||
process); sys = machine-wide, DWM and Discord included.
|
||
Only the app number is this app's doing. -->
|
||
<div><b>GPU</b> {stats.gpuAppPercent != null ? `app ${stats.gpuAppPercent.toFixed(1)}%` : "app n/a"} · sys {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}%` : "n/a"} (3D)</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} · drops {drops}{#if worstGap > 0} · worst {worstGap}ms{/if}
|
||
<button class="mini" onclick={() => { drops = 0; worstGap = 0; }}>reset</button>
|
||
</div>
|
||
<div>
|
||
<b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}
|
||
{#if stats.mainFadeGain != null && stats.mainFadeGain < 0.999}· fade {stats.mainFadeGain.toFixed(2)}{/if}
|
||
</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.source}</div>
|
||
{/if}
|
||
{#if stats.normalizationEnabled && stats.normNowDb != null}
|
||
<!-- The pair, not a single number: a mid-track correction is
|
||
only visible as the GAP between what the sink has and
|
||
where the slew is heading. Equal = settled. -->
|
||
<div>
|
||
<b>SLEW</b>
|
||
<span class="num">{fmtDb(stats.normNowDb)}</span>
|
||
{#if slewing}
|
||
→ <span class="num hot">{fmtDb(stats.normTargetDb)}</span>
|
||
<span class="dim">
|
||
{fmtDb(stats.normTargetDb - stats.normNowDb)} to go ·
|
||
~{slewSecs.toFixed(0)}s
|
||
</span>
|
||
{:else}
|
||
<span class="dim">settled</span>
|
||
{/if}
|
||
</div>
|
||
{#if headStart}
|
||
<!-- The correction as a MOVE: where the prefix alone
|
||
would have started this play, and where it actually
|
||
did. A single number cannot show a bias correction
|
||
working. -->
|
||
<div class="dim">
|
||
head start <span class="num hot"
|
||
>−{headStart.db.toFixed(2)} dB</span
|
||
>
|
||
· prefix asked {fmtDb(headStart.from)} → started
|
||
{fmtDb(headStart.from - headStart.db)}
|
||
</div>
|
||
{/if}
|
||
<div class="bar slew">
|
||
<!-- −15…+9 dB is the clamp the player enforces. -->
|
||
{#if headStart}
|
||
<i
|
||
class="prefix"
|
||
style="left:{dbPct(headStart.from)}%"
|
||
title="where the prefix alone would have started"
|
||
></i>
|
||
{/if}
|
||
<i class="now" style="left:{dbPct(stats.normNowDb)}%"></i>
|
||
<i class="tgt" style="left:{dbPct(stats.normTargetDb)}%"></i>
|
||
<i class="zero" style="left:{dbPct(0)}%"></i>
|
||
</div>
|
||
{#if loud?.analyzing}
|
||
<div class="dim">analyzing — gain may still ratchet down</div>
|
||
{/if}
|
||
{/if}
|
||
{#if previewUi.open}
|
||
<div>
|
||
<b>PREVIEW</b>
|
||
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
|
||
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
|
||
{#if stats.previewVolume != null}· vol {stats.previewVolume.toFixed(2)}{/if}
|
||
{#if stats.previewNormDb != null}· norm {stats.previewNormDb >= 0 ? "+" : ""}{stats.previewNormDb.toFixed(1)} dB{/if}
|
||
</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;
|
||
}
|
||
.num {
|
||
font-variant-numeric: tabular-nums;
|
||
color: #d7e0ea;
|
||
}
|
||
.num.hot {
|
||
color: #ffc46b;
|
||
}
|
||
/* A scale, not a fill: the two markers are POSITIONS on −15…+9 dB, so the
|
||
distance between them is the correction still to travel. */
|
||
.bar.slew {
|
||
position: relative;
|
||
height: 7px;
|
||
overflow: visible;
|
||
margin: 4px 0 2px;
|
||
}
|
||
.bar.slew i {
|
||
position: absolute;
|
||
top: -1px;
|
||
width: 2px;
|
||
height: 9px;
|
||
border-radius: 1px;
|
||
transition: left 220ms linear;
|
||
}
|
||
.bar.slew .now { background: #7fd0ff; }
|
||
.bar.slew .tgt { background: #ffc46b; }
|
||
/* Where the prefix alone would have started: a ghost, because it is the
|
||
counterfactual rather than a gain anything is heading for. The distance
|
||
from here to .now IS the head start. */
|
||
.bar.slew .prefix {
|
||
background: rgba(255, 255, 255, 0.28);
|
||
height: 11px;
|
||
top: -2px;
|
||
}
|
||
/* Unity, so a cut and a boost are told apart at a glance. */
|
||
.bar.slew .zero {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
height: 5px;
|
||
top: 1px;
|
||
transition: none;
|
||
}
|
||
/* The overlay is passthrough glass (pointer-events: none on the aside)
|
||
— only its buttons take clicks. */
|
||
button {
|
||
pointer-events: auto;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||
border-radius: 4px;
|
||
color: #d7e0ea;
|
||
font: inherit;
|
||
font-size: 10px;
|
||
padding: 1px 7px;
|
||
margin-left: 4px;
|
||
cursor: pointer;
|
||
}
|
||
button:hover {
|
||
border-color: #7fd0ff;
|
||
color: #fff;
|
||
}
|
||
button.mini {
|
||
font-size: 9px;
|
||
padding: 0 5px;
|
||
opacity: 0.7;
|
||
}
|
||
</style>
|