544 lines
22 KiB
Svelte
544 lines
22 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, 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} · 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>
|