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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user