Files
SoulStream/src/components/DebugOverlay.svelte
T
KeelrLeek 69f64c11eb previews: the glow goes, the eq steps, and every clip lands before you scroll to it
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.
2026-08-01 07:11:17 -05:00

362 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}&nbsp;· 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>