Files
SoulStream/src/components/DebugOverlay.svelte
T

544 lines
22 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, the on-deck queue window (what the pre-buffer
// is doing about each upcoming track), 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, onDeckSnapshot } from "../lib/state.svelte.js";
import { previewUi } from "../lib/preview/previewSession.svelte.js";
let visible = $state(false);
let stats = $state(null);
// The on-deck window (QUEUE section): indices + module-private markers
// from the player machinery, refreshed on the same 1s tick as stats.
let deck = $state(null);
// Cache-file byte lengths for on-deck rows with no live transfer —
// tells "cached ✓" from "prefix n MB" from "nothing yet".
let cacheLens = $state({});
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;
// On-deck window. For stream rows with no live transfer reporting
// bytes, ask the cache file itself — that covers tracks that were
// fully cached before this session ever preloaded anything.
deck = onDeckSnapshot();
const lens = {};
await Promise.all(
deck.indices.map(async (i) => {
const it = player.queue[i];
if (!it?.stream || !it.path) return;
const live = stats?.preloadDl?.some(
(p) =>
p.path === it.path
&& p.username === it.stream.username
&& p.state !== "done",
);
if (live) return;
try {
const len = await api.streamCacheLen(it.stream.username, it.path);
if (len != null) lens[it.path] = len;
} catch {}
}),
);
cacheLens = lens;
};
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,
);
// ---- QUEUE: the on-deck pre-buffer window ----
const base = (p) => (p ? p.split("\\").pop() : "?");
/** One readout line per queue row: what the on-deck machinery is doing
* about it right now. Live transfer state comes from the backend's own
* mirror (preloadDl); rows without a transfer fall back to the cache
* file's length; pending rows report the lookahead's posture. */
function classifyRow(item, slot, isCurrent) {
const name = `${item.artist ? `${item.artist} — ` : ""}${item.title ?? base(item.path)}`;
let status;
let pct = null;
let cls = "dim";
if (isCurrent) {
status = player.status;
cls = "";
} else if (item.unplayable) {
status = "unplayable";
cls = "warn";
} else if (!item.path && !item.stream) {
// A pending playlist row: no source yet. One lookahead search per
// row — spent means it came up dry and waits for its real turn.
const tried = deck.wasLookaheadTried(item);
status = tried ? "searched · dry (retries on reach)" : "resolving source…";
cls = tried ? "warn" : "";
} else if (!item.stream) {
status = "local file";
} else {
const dl = stats?.preloadDl?.find(
(p) => p.path === item.path && p.username === item.stream.username,
);
const size = dl?.total || item.stream.size || 0;
const len = cacheLens[item.path];
if (dl?.state === "transferring") {
status = `${mb(dl.received)}/${mb(size)} MB · ${rate(dl.speedBps)}`;
pct = size > 0 ? (dl.received / size) * 100 : null;
cls = "";
} else if (dl?.state === "queued") {
status = dl.place != null ? `queued at peer #${dl.place}` : "queued at peer";
} else if (dl && dl.state !== "done") {
// connecting / requested / failed: reason / cancelled
status = dl.state;
cls = dl.state === "connecting" || dl.state === "requested" ? "dim" : "warn";
} else if (dl?.state === "done" || (len != null && size > 0 && len >= size)) {
status = "cached ✓";
cls = "ok";
} else if (len > 0) {
status = `prefix ${mb(len)} MB`;
} else if (deck.isRequested(item.path)) {
status = "requested…";
} else {
status = "idle";
}
if (deck.appendedNextPath === item.path) status += " · in sink";
}
return { slot, name, status, pct, cls, isCurrent };
}
const queueRows = $derived.by(() => {
if (!deck) return [];
const rows = [];
const cur = player.queue[player.queueIndex];
if (cur) rows.push(classifyRow(cur, "▶", true));
deck.indices.forEach((i, k) => {
const item = player.queue[i];
if (item) rows.push(classifyRow(item, String(k + 1), false));
});
return rows;
});
/** Backend transfers for paths no longer in the window — the sliding
* window moved on (a jump, a reshuffle) but the bytes keep arriving. */
const offWindow = $derived.by(() => {
if (!deck || !stats?.preloadDl?.length) return [];
const winPaths = new Set(
[player.queue[player.queueIndex], ...deck.indices.map((i) => player.queue[i])]
.map((it) => it?.path)
.filter(Boolean),
);
return stats.preloadDl.filter((p) => !winPaths.has(p.path));
});
</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>QUEUE</b>
{#if player.queue.length}
{player.queueIndex + 1}/{player.queue.length}
· on-deck {deck?.indices?.length ?? 0}
{#if player.shuffle}· shuffle{/if}
{#if player.repeat && player.repeat !== "off"}· repeat {player.repeat}{/if}
{:else}
empty
{/if}
</div>
{#each queueRows as r}
<div class="qrow">
<span class="qslot" class:cur={r.isCurrent}>{r.slot}</span>
<span class="qname" title={r.name}>{r.name}</span>
</div>
<div class="qstat {r.cls}">{r.status}</div>
{#if r.pct != null}
<div class="bar q"><i style="width:{Math.min(100, r.pct)}%"></i></div>
{/if}
{/each}
{#each offWindow as p}
<!-- State + bytes lead: the filename is the part that can
afford to truncate. -->
<div class="dim qoff" title={p.path}>
↳ {p.state}{p.state === "transferring" ? ` ${mb(p.received)}${p.total ? `/${mb(p.total)}` : ""} MB` : ""} · {base(p.path)}
</div>
{/each}
</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;
}
/* QUEUE rows: name line, then an indented status line (and a mini bar
while bytes are moving) — two short lines read better at 11px than
one truncated long one. */
.qrow {
display: flex;
gap: 6px;
align-items: baseline;
margin-top: 3px;
}
.qslot {
min-width: 12px;
text-align: right;
color: rgba(215, 224, 234, 0.45);
}
.qslot.cur {
color: #7fd0ff;
}
.qname {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qstat {
margin-left: 18px;
font-variant-numeric: tabular-nums;
}
.qstat.warn {
color: #ffc46b;
}
.qstat.ok {
color: #7fd0ff;
}
.bar.q {
margin-left: 18px;
}
.qoff {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 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>