Clicking a track could play silently: the store showed status=playing with the position advancing and no error, but the sink was at volume 0. The open/close crossfade ramped the main player's USER VOLUME as its fade channel and left it at 0 on an ack timeout (or when a track change suppressed the close resume). User volume persists across tracks and a new Play doesn't reset it, so every later click played at 0 while the slider still read 0.7. Give the main player a separate fade_gain channel (Cmd::SetFadeGain, sink = volume * norm * fade_gain), reset to 1 by every Play/Stop. The open/close fades ramp fade_gain instead of set_volume (their volume arg is gone, JS callers too), so the user volume is never touched -- store and backend can't desync -- and a clicked track always resets fade_gain to 1, so it can never be stranded silent. fade_resume lands exactly at 1. Player::fade_gain() mirrors it into a report atomic; debug overlay shows main fade + this proved the fix live (open 1->0, close 0->1, new track 1.0, volume steady at 0.7). 39 app + 12 player + 85 JS tests pass.
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
//! 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>,
|
|
/// Main player's live fade-gain (1.0 = full; <1 only during a preview
|
|
/// open/close crossfade). Proves a fade never strands the volume.
|
|
pub main_fade_gain: Option<f32>,
|
|
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
|
pub preview_volume: Option<f32>,
|
|
/// The active preview clip's loudness-normalization gain in dB.
|
|
pub preview_norm_db: Option<f32>,
|
|
}
|
|
|
|
#[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,
|
|
preview_volume: app
|
|
.try_state::<crate::preview::PreviewState>()
|
|
.map(|s| crate::preview::target_volume(&s)),
|
|
preview_norm_db: app
|
|
.try_state::<crate::preview::PreviewState>()
|
|
.map(|s| crate::preview::active_norm_db(&s)),
|
|
main_fade_gain: app
|
|
.try_state::<crate::playback::PlayerState>()
|
|
.map(|p| p.player.fade_gain()),
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
}
|