Files
SoulStream/src-tauri/src/diagnostics.rs
T
KeelrLeek 86e0d86604 fix: preview crossfade no longer strands the main volume (silent playback)
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.
2026-07-23 17:31:06 -05:00

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);
}
}
}