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:
KeelrLeek committed 2026-07-23 16:05:14 -05:00
1 parent 5e877c0b0a
commit 793f78370e
27 files changed
+2834 -178

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
//! Dev diagnostics for the debug overlay (Ctrl+Shift+D): system + process
//! CPU, memory, GPU, network rates, and the loudness-normalization state of
//! the active track. Polled by the frontend only while the overlay is
//! visible — nothing here runs otherwise.
//!
//! Process figures aggregate the WHOLE process tree: the WebView2 children
//! do the actual rendering, so app.exe alone would tell a comforting lie.
use std::sync::{Arc, Mutex};
use std::time::Instant;
use serde::Serialize;
use sysinfo::{Networks, Pid, ProcessRefreshKind, ProcessesToUpdate, System};
use tauri::{Manager, State};
use player::StreamProgress;
pub struct DiagState(Mutex<Inner>);
struct Inner {
sys: System,
networks: Networks,
last_net: Instant,
gpu: Option<GpuCounter>,
started: Instant,
/// The ACTIVE track's transfer, straight from the player's own
/// StreamProgress — the overlay must read download truth at the source,
/// not the UI store's window-activity-gated mirror.
current_stream: Option<(u64, Arc<StreamProgress>)>,
}
/// Called by the play paths: this track streams from a peer.
pub fn note_stream(app: &tauri::AppHandle, total: u64, progress: Arc<StreamProgress>) {
if let Some(state) = app.try_state::<DiagState>() {
state.0.lock().expect("diag lock").current_stream = Some((total, progress));
}
}
/// Called by the play paths: this track plays from a complete local file.
pub fn clear_stream(app: &tauri::AppHandle) {
if let Some(state) = app.try_state::<DiagState>() {
state.0.lock().expect("diag lock").current_stream = None;
}
}
pub fn init(app: &tauri::AppHandle) {
app.manage(DiagState(Mutex::new(Inner {
sys: System::new(),
networks: Networks::new_with_refreshed_list(),
last_net: Instant::now(),
gpu: GpuCounter::open(),
started: Instant::now(),
current_stream: None,
})));
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DebugStats {
/// Whole-process-tree CPU, percent of the whole machine.
pub cpu_process: f32,
pub cpu_system: f32,
/// GPU 3D-engine utilization (Task-Manager-style), if counters opened.
pub gpu_percent: Option<f32>,
/// Process tree working set / private commit, bytes.
pub mem_working_set: u64,
pub mem_private: u64,
pub mem_system_used: u64,
pub mem_system_total: u64,
/// System-wide network rates since the previous poll, bytes/sec.
pub net_rx_bps: f64,
pub net_tx_bps: f64,
pub process_count: usize,
pub uptime_secs: u64,
/// Active track's transfer, from the player's own progress handle.
pub dl_received: Option<u64>,
pub dl_total: Option<u64>,
pub dl_done: Option<bool>,
pub normalization_enabled: bool,
pub target_lufs: f64,
pub loudness: Option<crate::loudness::CurrentTrack>,
}
#[tauri::command]
pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugStats {
let mut inner = state.0.lock().expect("diag lock");
let inner = &mut *inner;
inner.sys.refresh_cpu_usage();
inner.sys.refresh_processes_specifics(
ProcessesToUpdate::All,
true,
ProcessRefreshKind::nothing().with_cpu().with_memory(),
);
// Our process tree: app.exe + every descendant (the WebView2 family).
let me = Pid::from_u32(std::process::id());
let mut tree = vec![me];
let mut grew = true;
while grew {
grew = false;
for (pid, proc_) in inner.sys.processes() {
if let Some(parent) = proc_.parent() {
if tree.contains(&parent) && !tree.contains(pid) {
tree.push(*pid);
grew = true;
}
}
}
}
let cores = inner.sys.cpus().len().max(1) as f32;
let mut cpu_process = 0.0f32;
let mut mem_working_set = 0u64;
let mut mem_private = 0u64;
for pid in &tree {
if let Some(p) = inner.sys.process(*pid) {
cpu_process += p.cpu_usage(); // % of one core
mem_working_set += p.memory();
// sysinfo's virtual_memory() is the raw address space on Windows
// (WebView reserves terabytes of it) — useless. The meaningful
// "reserved" figure is the private commit, straight from Win32.
mem_private += private_commit(pid.as_u32());
}
}
cpu_process /= cores;
inner.sys.refresh_memory();
// Network: sysinfo reports bytes since the LAST refresh per interface.
let elapsed = inner.last_net.elapsed().as_secs_f64().max(0.05);
inner.last_net = Instant::now();
inner.networks.refresh(true);
let (mut rx, mut tx) = (0u64, 0u64);
for (_, data) in inner.networks.iter() {
rx += data.received();
tx += data.transmitted();
}
let (normalization_enabled, target_lufs, loudness) = crate::loudness::snapshot(&app);
let (dl_received, dl_total, dl_done) = match &inner.current_stream {
Some((total, progress)) => {
let (received, done) = progress.received_done();
(Some(received), Some(*total), Some(done))
}
None => (None, None, None),
};
DebugStats {
cpu_process,
cpu_system: inner.sys.global_cpu_usage(),
gpu_percent: inner.gpu.as_mut().and_then(|g| g.sample()),
mem_working_set,
mem_private,
mem_system_used: inner.sys.used_memory(),
mem_system_total: inner.sys.total_memory(),
net_rx_bps: rx as f64 / elapsed,
net_tx_bps: tx as f64 / elapsed,
process_count: tree.len(),
uptime_secs: inner.started.elapsed().as_secs(),
dl_received,
dl_total,
dl_done,
normalization_enabled,
target_lufs,
loudness,
}
}
/// Private commit ("reserved") of one process, bytes. 0 if unreadable.
fn private_commit(pid: u32) -> u64 {
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::ProcessStatus::{
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
unsafe {
let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {
return 0;
};
let mut counters = PROCESS_MEMORY_COUNTERS_EX::default();
let ok = K32GetProcessMemoryInfo(
handle,
(&mut counters as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
)
.as_bool();
let _ = CloseHandle(handle);
if ok {
counters.PrivateUsage as u64
} else {
0
}
}
}
// ------------------------------------------------------------ GPU (PDH) ---
/// Total 3D-engine utilization via the PDH "GPU Engine" counter set — the
/// number Task Manager labels "GPU". Instances are per-process-per-engine;
/// summing the 3D ones gives machine-wide utilization. First sample after
/// opening reads as None (PDH rate counters need two collections).
struct GpuCounter {
query: windows::Win32::System::Performance::PDH_HQUERY,
counter: windows::Win32::System::Performance::PDH_HCOUNTER,
}
// PDH handles are plain registry-ish handles; the query is only touched
// under the DiagState mutex.
unsafe impl Send for GpuCounter {}
impl GpuCounter {
fn open() -> Option<GpuCounter> {
use windows::core::w;
use windows::Win32::System::Performance::{
PdhAddEnglishCounterW, PdhOpenQueryW, PDH_HCOUNTER, PDH_HQUERY,
};
unsafe {
let mut query = PDH_HQUERY::default();
if PdhOpenQueryW(None, 0, &mut query) != 0 {
return None;
}
let mut counter = PDH_HCOUNTER::default();
let path = w!("\\GPU Engine(*engtype_3D)\\Utilization Percentage");
if PdhAddEnglishCounterW(query, path, 0, &mut counter) != 0 {
return None;
}
Some(GpuCounter { query, counter })
}
}
fn sample(&mut self) -> Option<f32> {
use windows::Win32::System::Performance::{
PdhCollectQueryData, PdhGetFormattedCounterArrayW, PDH_FMT_COUNTERVALUE_ITEM_W,
PDH_FMT_DOUBLE,
};
unsafe {
if PdhCollectQueryData(self.query) != 0 {
return None;
}
let mut buf_len: u32 = 0;
let mut count: u32 = 0;
// Size probe returns PDH_MORE_DATA; then fetch.
let _ = PdhGetFormattedCounterArrayW(
self.counter,
PDH_FMT_DOUBLE,
&mut buf_len,
&mut count,
None,
);
if buf_len == 0 {
return None;
}
let mut buf = vec![0u8; buf_len as usize];
let items = buf.as_mut_ptr() as *mut PDH_FMT_COUNTERVALUE_ITEM_W;
if PdhGetFormattedCounterArrayW(
self.counter,
PDH_FMT_DOUBLE,
&mut buf_len,
&mut count,
Some(items),
) != 0
{
return None;
}
let mut total = 0.0f64;
for i in 0..count as usize {
total += (*items.add(i)).FmtValue.Anonymous.doubleValue;
}
Some(total.min(100.0) as f32)
}
}
}
impl Drop for GpuCounter {
fn drop(&mut self) {
use windows::Win32::System::Performance::PdhCloseQuery;
unsafe {
let _ = PdhCloseQuery(self.query);
}
}
}