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:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2834
-178
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user