Files
SoulStream/src-tauri/src/diagnostics.rs
T

401 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::collections::HashMap;
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>)>,
/// On-deck pre-buffer transfers by transfer id, mirrored from the
/// download event stream (those updates are otherwise swallowed — no
/// player, no Downloads view). The Instant is set when the transfer
/// reaches a terminal state, so finished rows linger long enough to be
/// seen and then fall off.
preloads: HashMap<u64, (PreloadDl, Option<Instant>)>,
}
/// One on-deck transfer as the overlay sees it.
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PreloadDl {
pub username: String,
/// Full remote path — the frontend matches queue rows by this.
pub path: String,
pub state: String,
/// Remote queue position, when the peer reported one.
pub place: Option<u32>,
pub received: u64,
pub total: u64,
pub speed_bps: u64,
}
/// Called from the download event stream for every preload update.
pub fn note_preload(app: &tauri::AppHandle, id: u64, dl: PreloadDl, terminal: bool) {
if let Some(state) = app.try_state::<DiagState>() {
let finished = if terminal { Some(Instant::now()) } else { None };
state
.0
.lock()
.expect("diag lock")
.preloads
.insert(id, (dl, finished));
}
}
/// Called when a play path takes the transfer over: it is the CURRENT
/// track's stream from here on (note_stream reports it), not an on-deck row.
pub fn retire_preload(app: &tauri::AppHandle, id: u64) {
if let Some(state) = app.try_state::<DiagState>() {
state.0.lock().expect("diag lock").preloads.remove(&id);
}
}
/// 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,
preloads: HashMap::new(),
})));
}
#[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.
/// MACHINE-WIDE — includes DWM, browsers, Discord, everything.
pub gpu_percent: Option<f32>,
/// Same counters, restricted to OUR process tree — the app's true GPU
/// share, matching Task Manager's per-process rows. The machine-wide
/// number above routinely reads 2× this on a busy desktop.
pub gpu_app_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 normalization gain the sink has RIGHT NOW, and the one the audio
/// thread is slewing toward, both dB. Reported as a pair because the
/// interesting state during a mid-track correction is the gap between
/// them: equal means settled, different means a correction is in flight
/// and the difference is how far it still has to travel.
pub norm_now_db: Option<f32>,
pub norm_target_db: 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>,
/// On-deck pre-buffer transfers, in arrival order (finished rows linger
/// ~15s so a completion is visible before it drops off).
pub preload_dl: Vec<PreloadDl>,
}
#[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),
};
let tree_u32: Vec<u32> = tree.iter().map(|p| p.as_u32()).collect();
let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32));
// On-deck transfers: drop terminal rows after they've been visible a
// while, then report the rest in arrival (transfer-id) order.
inner
.preloads
.retain(|_, v| v.1.is_none_or(|t| t.elapsed().as_secs() < 15));
let mut preload_ids: Vec<u64> = inner.preloads.keys().copied().collect();
preload_ids.sort_unstable();
let preload_dl: Vec<PreloadDl> = preload_ids
.iter()
.filter_map(|id| inner.preloads.get(id).map(|(dl, _)| dl.clone()))
.collect();
DebugStats {
cpu_process,
cpu_system: inner.sys.global_cpu_usage(),
gpu_percent: gpu_sample.map(|(sys, _)| sys),
gpu_app_percent: gpu_sample.map(|(_, app)| app),
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,
norm_now_db: app
.try_state::<crate::playback::PlayerState>()
.map(|s| s.player.norm_gain_now_target_db().0),
norm_target_db: app
.try_state::<crate::playback::PlayerState>()
.map(|s| s.player.norm_gain_now_target_db().1),
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()),
preload_dl,
}
}
/// 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 })
}
}
/// Returns (machine_total, app_tree_total). Instance names carry the
/// owning pid — "pid_1234_luid_..._engtype_3D" — so the app's share is
/// the sum over instances whose pid is in our process tree.
fn sample(&mut self, app_pids: &[u32]) -> Option<(f32, 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;
let mut app = 0.0f64;
for i in 0..count as usize {
let item = &*items.add(i);
let v = item.FmtValue.Anonymous.doubleValue;
total += v;
if !item.szName.is_null() {
if let Some(pid) = instance_pid(item.szName) {
if app_pids.contains(&pid) {
app += v;
}
}
}
}
Some((total.min(100.0) as f32, app.min(100.0) as f32))
}
}
}
/// Parse the pid out of a "pid_1234_luid_…" counter-instance name.
unsafe fn instance_pid(name: windows::core::PWSTR) -> Option<u32> {
let s = name.to_string().ok()?;
let rest = s.strip_prefix("pid_")?;
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
}
impl Drop for GpuCounter {
fn drop(&mut self) {
use windows::Win32::System::Performance::PdhCloseQuery;
unsafe {
let _ = PdhCloseQuery(self.query);
}
}
}