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
@@ -25,6 +25,7 @@ tokio.workspace = true
|
||||
|
||||
base64 = "0.22"
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
sysinfo = "0.33"
|
||||
soulseek-proto = { path = "../crates/soulseek-proto" }
|
||||
soulseek-client = { path = "../crates/soulseek-client" }
|
||||
player = { path = "../crates/player" }
|
||||
@@ -41,5 +42,9 @@ windows = { version = "0.61.3", features = [
|
||||
"Media_FaceAnalysis",
|
||||
"Media_Ocr",
|
||||
"Storage_Streams",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_WinRT",
|
||||
] }
|
||||
@@ -75,6 +75,7 @@ pub struct Settings {
|
||||
pub stream_cache_max_mb: u64,
|
||||
pub stream_source_prefs: config::SourcePrefs,
|
||||
pub download_source_prefs: config::SourcePrefs,
|
||||
pub volume_normalization: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -112,6 +113,7 @@ pub async fn get_settings(
|
||||
stream_cache_max_mb: config::stream_cache_max_mb(&cfg),
|
||||
stream_source_prefs: cfg.stream_source_prefs,
|
||||
download_source_prefs: cfg.download_source_prefs,
|
||||
volume_normalization: config::volume_normalization(&cfg),
|
||||
username: cfg.username,
|
||||
})
|
||||
}
|
||||
@@ -138,6 +140,7 @@ pub async fn set_settings(
|
||||
stream_cache_max_mb: Option<u64>,
|
||||
stream_source_prefs: Option<config::SourcePrefs>,
|
||||
download_source_prefs: Option<config::SourcePrefs>,
|
||||
volume_normalization: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = config::load(&app);
|
||||
if let Some(d) = download_dir {
|
||||
@@ -197,6 +200,12 @@ pub async fn set_settings(
|
||||
if stream_cache_max_mb.is_some() {
|
||||
cfg.stream_cache_max_mb = stream_cache_max_mb;
|
||||
}
|
||||
if let Some(enabled) = volume_normalization {
|
||||
cfg.volume_normalization = Some(enabled);
|
||||
// Applies live: off glides the current track back to unity; on
|
||||
// takes effect from the next track.
|
||||
crate::loudness::set_enabled(&app, enabled);
|
||||
}
|
||||
config::save(&app, &cfg);
|
||||
|
||||
// Tighter limits take effect right away, not at the next stream/startup.
|
||||
@@ -538,6 +547,12 @@ pub async fn connect_and_login(
|
||||
}),
|
||||
);
|
||||
if matches!(&update.state, DS::Complete { .. }) {
|
||||
// Gapless normalization: measure the next
|
||||
// track now so its gain is ready at the
|
||||
// boundary.
|
||||
if let Some(c) = &cache {
|
||||
crate::loudness::on_preload_ready(&event_app, c);
|
||||
}
|
||||
// Only explicit saves reach the library —
|
||||
// unless streamed tracks are configured to
|
||||
// be kept forever.
|
||||
@@ -3272,7 +3287,20 @@ pub async fn play_stream(
|
||||
if let Ok(md) = std::fs::metadata(&cache) {
|
||||
if size > 0 && md.len() == size {
|
||||
playback::touch(&cache);
|
||||
players.player.play_local(&cache, duration);
|
||||
crate::diagnostics::clear_stream(&app);
|
||||
let app2 = app.clone();
|
||||
let cache2 = cache.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
&cache,
|
||||
size,
|
||||
None,
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>()
|
||||
.player
|
||||
.play_local(&cache2, duration);
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::remove_file(&cache);
|
||||
@@ -3300,9 +3328,21 @@ pub async fn play_stream(
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id, cache.clone());
|
||||
players
|
||||
.player
|
||||
.play_stream(&partial, progress, size, duration);
|
||||
crate::diagnostics::note_stream(&app, size, progress.clone());
|
||||
let app2 = app.clone();
|
||||
let partial2 = partial.clone();
|
||||
let progress2 = progress.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
&cache,
|
||||
size,
|
||||
Some((partial, progress)),
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>()
|
||||
.player
|
||||
.play_stream(&partial2, progress2, size, duration);
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -3321,9 +3361,21 @@ pub async fn play_stream(
|
||||
.unwrap()
|
||||
.insert(id, cache.clone());
|
||||
|
||||
players
|
||||
.player
|
||||
.play_stream(&partial, progress, size, duration);
|
||||
crate::diagnostics::note_stream(&app, size, progress.clone());
|
||||
let app2 = app.clone();
|
||||
let partial2 = partial.clone();
|
||||
let progress2 = progress.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
&cache,
|
||||
size,
|
||||
Some((partial, progress)),
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>()
|
||||
.player
|
||||
.play_stream(&partial2, progress2, size, duration);
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3342,6 +3394,8 @@ pub async fn preload_stream(
|
||||
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
|
||||
if let Ok(md) = std::fs::metadata(&cache) {
|
||||
if size > 0 && md.len() == size {
|
||||
// Gapless normalization: boundary gain ready before the switch.
|
||||
crate::loudness::on_preload_ready(&app, &cache);
|
||||
return Ok(()); // already fully cached
|
||||
}
|
||||
let _ = std::fs::remove_file(&cache);
|
||||
@@ -3383,6 +3437,8 @@ pub fn preload_cached_stream(
|
||||
Ok(md) if size == 0 || md.len() == size => {
|
||||
playback::touch(&cache);
|
||||
players.player.preload_local(&cache, duration);
|
||||
// Gapless normalization: boundary gain ready before the switch.
|
||||
crate::loudness::on_preload_ready(&app, &cache);
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
@@ -3485,14 +3541,25 @@ pub async fn stream_waveform(
|
||||
/// Play an already-downloaded local file.
|
||||
#[tauri::command]
|
||||
pub fn play_local(
|
||||
players: State<'_, PlayerState>,
|
||||
app: tauri::AppHandle,
|
||||
path: String,
|
||||
duration: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
if std::fs::metadata(&path).is_err() {
|
||||
return Err("file not found".into());
|
||||
}
|
||||
players.player.play_local(&path, duration);
|
||||
crate::diagnostics::clear_stream(&app);
|
||||
let app2 = app.clone();
|
||||
let path2 = path.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
std::path::Path::new(&path),
|
||||
0,
|
||||
None,
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>().player.play_local(&path2, duration);
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3500,6 +3567,7 @@ pub fn play_local(
|
||||
/// used for gapless album playback; it has no effect on streaming downloads.
|
||||
#[tauri::command]
|
||||
pub fn preload_local(
|
||||
app: tauri::AppHandle,
|
||||
players: State<'_, PlayerState>,
|
||||
path: String,
|
||||
duration: Option<f64>,
|
||||
@@ -3508,6 +3576,8 @@ pub fn preload_local(
|
||||
return Err("file not found".into());
|
||||
}
|
||||
players.player.preload_local(&path, duration);
|
||||
// Gapless normalization: have the boundary gain ready before the switch.
|
||||
crate::loudness::on_preload_ready(&app, std::path::Path::new(&path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,10 @@ pub struct AppConfig {
|
||||
/// Some(0) → unlimited.
|
||||
#[serde(default)]
|
||||
pub stream_cache_max_mb: Option<u64>,
|
||||
/// Loudness normalization (−14 LUFS target, BS.1770 measurement):
|
||||
/// every track plays at roughly the same perceived level. None → true.
|
||||
#[serde(default)]
|
||||
pub volume_normalization: Option<bool>,
|
||||
}
|
||||
|
||||
/// Default longest edge for embedded covers when the user hasn't chosen.
|
||||
@@ -166,6 +170,10 @@ pub fn stream_cache_max_mb(cfg: &AppConfig) -> u64 {
|
||||
.unwrap_or(DEFAULT_STREAM_CACHE_MAX_MB)
|
||||
}
|
||||
|
||||
pub fn volume_normalization(cfg: &AppConfig) -> bool {
|
||||
cfg.volume_normalization.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// One-time migration after the identifier rename: the app-data dir moved
|
||||
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
|
||||
/// the old dir into place so the library DB, config and stream cache
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
mod commands;
|
||||
mod config;
|
||||
mod cover_logo;
|
||||
mod diagnostics;
|
||||
mod hero_crop;
|
||||
mod hero_detector;
|
||||
mod loudness;
|
||||
mod pipeline;
|
||||
mod playback;
|
||||
mod preview;
|
||||
@@ -31,6 +33,8 @@ pub fn run() {
|
||||
playback::init(app.handle());
|
||||
playback::prune_cache(app.handle());
|
||||
preview::init(app.handle());
|
||||
loudness::init(app.handle());
|
||||
diagnostics::init(app.handle());
|
||||
if let Err(e) = pipeline::init(app.handle()) {
|
||||
log::error!("library init failed: {e}");
|
||||
}
|
||||
@@ -108,6 +112,9 @@ pub fn run() {
|
||||
preview::preview_control,
|
||||
preview::preview_dispose,
|
||||
preview::player_pause_acked,
|
||||
preview::player_fade_pause_acked,
|
||||
preview::player_fade_resume,
|
||||
diagnostics::debug_stats,
|
||||
])
|
||||
.run({
|
||||
#[allow(unused_mut)]
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Volume normalization — the app-side orchestration over the player
|
||||
//! crate's BS.1770 meter. Every main-player track is measured and played
|
||||
//! with a gain that brings it to one target loudness (−14 LUFS, Spotify's
|
||||
//! reference), so track-to-track volume stops being all over the place.
|
||||
//!
|
||||
//! Streaming-first: an unmeasured track is analyzed DURING its first play,
|
||||
//! reading the same growing partial file the player streams from — analysis
|
||||
//! runs at download speed, so the true integrated loudness usually lands
|
||||
//! within seconds and the gain glides there (the player slew-limits it).
|
||||
//! Measured tracks live in `loudness-cache.json`; their exact gain is
|
||||
//! applied before the first sample on every later play.
|
||||
//!
|
||||
//! Toggleable via config `volume_normalization` (default on). Preview clips
|
||||
//! are deliberately NOT normalized — Deezer masters them consistently.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
|
||||
use player::{analyze_loudness, AnalysisSource, StreamProgress};
|
||||
|
||||
use crate::playback::PlayerState;
|
||||
|
||||
/// Playback target (Spotify's default reference level).
|
||||
pub const TARGET_LUFS: f64 = -14.0;
|
||||
/// Gain clamp: quiet-track boosts are the risky direction (headroom), loud
|
||||
/// tracks can always be cut.
|
||||
const MIN_GAIN_DB: f64 = -15.0;
|
||||
const MAX_GAIN_DB: f64 = 9.0;
|
||||
/// Boosts additionally respect the track's own sample peak up to this
|
||||
/// ceiling — normalization must never introduce clipping.
|
||||
const PEAK_CEILING: f64 = 0.98;
|
||||
|
||||
const CACHE_FILE: &str = "loudness-cache.json";
|
||||
/// Entries beyond this are dropped oldest-first at save time.
|
||||
const CACHE_CAP: usize = 4000;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct Measured {
|
||||
pub lufs: f64,
|
||||
pub peak: f32,
|
||||
/// File size when measured — a different size is a different file.
|
||||
pub size: u64,
|
||||
pub at_epoch_ms: i64,
|
||||
}
|
||||
|
||||
/// Live view of the ACTIVE track's normalization, for the debug overlay.
|
||||
#[derive(Serialize, Clone, Copy)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurrentTrack {
|
||||
pub lufs: Option<f64>,
|
||||
pub peak: f32,
|
||||
pub gain_db: f64,
|
||||
/// The gain came from the cache (exact, applied pre-sample).
|
||||
pub cached: bool,
|
||||
/// The analyzer is still measuring this play.
|
||||
pub analyzing: bool,
|
||||
}
|
||||
|
||||
pub struct LoudnessState {
|
||||
cache: Mutex<HashMap<String, Measured>>,
|
||||
/// Bumped on every track start; orphans older analyzer threads.
|
||||
generation: Arc<AtomicU64>,
|
||||
enabled: AtomicBool,
|
||||
/// Debug-overlay mirror of the active track's measurement state.
|
||||
current: Mutex<Option<CurrentTrack>>,
|
||||
}
|
||||
|
||||
/// (normalization enabled, target LUFS, active-track state) for diagnostics.
|
||||
pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option<CurrentTrack>) {
|
||||
let state = app.state::<LoudnessState>();
|
||||
let current = *state.current.lock().expect("loudness current lock");
|
||||
(
|
||||
state.enabled.load(Ordering::Relaxed),
|
||||
TARGET_LUFS,
|
||||
current,
|
||||
)
|
||||
}
|
||||
|
||||
fn cache_file(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||
app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE))
|
||||
}
|
||||
|
||||
pub fn init(app: &tauri::AppHandle) {
|
||||
let cache: HashMap<String, Measured> = cache_file(app)
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let enabled = crate::config::volume_normalization(&crate::config::load(app));
|
||||
app.manage(LoudnessState {
|
||||
cache: Mutex::new(cache),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
enabled: AtomicBool::new(enabled),
|
||||
current: Mutex::new(None),
|
||||
});
|
||||
}
|
||||
|
||||
fn save_cache(app: &tauri::AppHandle, state: &LoudnessState) {
|
||||
let Some(path) = cache_file(app) else { return };
|
||||
let mut entries: Vec<(String, Measured)> = state
|
||||
.cache
|
||||
.lock()
|
||||
.expect("loudness cache lock")
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
if entries.len() > CACHE_CAP {
|
||||
entries.sort_by_key(|(_, m)| std::cmp::Reverse(m.at_epoch_ms));
|
||||
entries.truncate(CACHE_CAP);
|
||||
}
|
||||
let map: HashMap<&String, &Measured> = entries.iter().map(|(k, v)| (k, v)).collect();
|
||||
if let Ok(json) = serde_json::to_string(&map) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// gain = target − measured, clamped, and boosts capped so the track's own
|
||||
/// peak never exceeds the ceiling.
|
||||
fn gain_db(m: &Measured) -> f64 {
|
||||
let mut g = (TARGET_LUFS - m.lufs).clamp(MIN_GAIN_DB, MAX_GAIN_DB);
|
||||
if g > 0.0 && m.peak > 0.0 {
|
||||
let headroom = 20.0 * (PEAK_CEILING / m.peak as f64).log10();
|
||||
g = g.min(headroom.max(0.0));
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Toggle from settings: applies to the CURRENT track immediately (gain to
|
||||
/// unity when turning off; the cached gain back on when turning on is left
|
||||
/// to the next track — the analyzer generation for the current one is gone).
|
||||
pub fn set_enabled(app: &tauri::AppHandle, enabled: bool) {
|
||||
let state = app.state::<LoudnessState>();
|
||||
state.enabled.store(enabled, Ordering::Relaxed);
|
||||
if !enabled {
|
||||
state.generation.fetch_add(1, Ordering::Relaxed); // orphan analyzers
|
||||
*state.current.lock().expect("loudness current lock") = None;
|
||||
app.state::<PlayerState>().player.set_norm_gain_db(0.0, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-playback measurement window: the audible start is HELD until this
|
||||
/// much audio is analyzed (at download/disk speed — normally well under a
|
||||
/// second of wall time)…
|
||||
const PRELIM_MIN_SECS: f64 = 10.0;
|
||||
/// …or until this much wall time passes after the FIRST measurement lands
|
||||
/// (slow peer): then we commit to what we have. Either way the gain is set
|
||||
/// BEFORE the first audible sample and NEVER changes mid-track (user
|
||||
/// decision 2026-07-23 — the earlier mid-play glide was audible and bad).
|
||||
const PRELIM_WALL_BUDGET: std::time::Duration = std::time::Duration::from_millis(800);
|
||||
/// A head estimate can under-read a track that opens quietly, so
|
||||
/// preliminary BOOSTS are capped harder than cached ones; cuts are trusted
|
||||
/// (a loud head is reliably loud).
|
||||
const PRELIM_MAX_BOOST_DB: f64 = 3.0;
|
||||
|
||||
fn prelim_gain_db(m: &Measured) -> f64 {
|
||||
gain_db(m).min(PRELIM_MAX_BOOST_DB)
|
||||
}
|
||||
|
||||
fn store_measurement(app: &tauri::AppHandle, key: &str, lufs: f64, peak: f32, size_hint: u64) {
|
||||
let final_size = std::fs::metadata(key).map(|m| m.len()).unwrap_or(size_hint);
|
||||
let measured = Measured {
|
||||
lufs,
|
||||
peak,
|
||||
size: final_size,
|
||||
at_epoch_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0),
|
||||
};
|
||||
let state = app.state::<LoudnessState>();
|
||||
state
|
||||
.cache
|
||||
.lock()
|
||||
.expect("loudness cache lock")
|
||||
.insert(key.to_owned(), measured);
|
||||
save_cache(app, &state);
|
||||
}
|
||||
|
||||
/// The hook every main-player track start funnels through. `play` is the
|
||||
/// DEFERRED start of playback: a cache hit or disabled normalization fires
|
||||
/// it immediately; an unmeasured track holds it for the preliminary
|
||||
/// measurement window so the gain is right from the very first sample.
|
||||
/// The analyzer then keeps running silently to EOF — only to fill the
|
||||
/// cache, never to touch the volume of the play in progress.
|
||||
pub fn on_track_started(
|
||||
app: &tauri::AppHandle,
|
||||
cache_path: &Path,
|
||||
size: u64,
|
||||
stream: Option<(PathBuf, Arc<StreamProgress>)>,
|
||||
play: Box<dyn FnOnce() + Send>,
|
||||
) {
|
||||
let state = app.state::<LoudnessState>();
|
||||
let generation = state.generation.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if !state.enabled.load(Ordering::Relaxed) {
|
||||
*state.current.lock().expect("loudness current lock") = None;
|
||||
play();
|
||||
return; // player resets to unity on Play
|
||||
}
|
||||
let key = cache_path.to_string_lossy().into_owned();
|
||||
|
||||
// Known file → play at once with the exact gain (the set lands behind
|
||||
// Play on the ordered command channel, before any sink exists).
|
||||
let known_size = if size > 0 {
|
||||
size
|
||||
} else {
|
||||
std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0)
|
||||
};
|
||||
if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) {
|
||||
if m.size == known_size || known_size == 0 {
|
||||
let g = gain_db(m);
|
||||
play();
|
||||
app.state::<PlayerState>()
|
||||
.player
|
||||
.set_norm_gain_db(g as f32, true);
|
||||
*state.current.lock().expect("loudness current lock") = Some(CurrentTrack {
|
||||
lufs: Some(m.lufs),
|
||||
peak: m.peak,
|
||||
gain_db: g,
|
||||
cached: true,
|
||||
analyzing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
*state.current.lock().expect("loudness current lock") = Some(CurrentTrack {
|
||||
lufs: None,
|
||||
peak: 0.0,
|
||||
gain_db: 0.0,
|
||||
cached: false,
|
||||
analyzing: true,
|
||||
});
|
||||
|
||||
// Unmeasured → the analyzer races ahead of playback (download/disk
|
||||
// speed) and RELEASES the held play once the preliminary window is met.
|
||||
let app = app.clone();
|
||||
let gen_ctr = state.generation.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("loudness-analysis".into())
|
||||
.spawn(move || {
|
||||
let source = match stream {
|
||||
Some((partial, progress)) => AnalysisSource::Growing {
|
||||
path: partial,
|
||||
total: size,
|
||||
progress,
|
||||
},
|
||||
None => AnalysisSource::Local(PathBuf::from(&key)),
|
||||
};
|
||||
let cancelled = || gen_ctr.load(Ordering::Relaxed) != generation;
|
||||
let mut play_slot = Some(play);
|
||||
let mut applied_db: Option<f64> = None;
|
||||
let mut first_measure: Option<std::time::Instant> = None;
|
||||
{
|
||||
let app = &app;
|
||||
let cancelled = &cancelled;
|
||||
let mut on_update = |lufs: f64, peak: f32, secs: f64| {
|
||||
if cancelled() || play_slot.is_none() {
|
||||
return; // started already: NO further volume touches
|
||||
}
|
||||
let first = *first_measure.get_or_insert_with(std::time::Instant::now);
|
||||
if secs < PRELIM_MIN_SECS && first.elapsed() < PRELIM_WALL_BUDGET {
|
||||
return; // keep listening a moment longer
|
||||
}
|
||||
let m = Measured {
|
||||
lufs,
|
||||
peak,
|
||||
size: 0,
|
||||
at_epoch_ms: 0,
|
||||
};
|
||||
let g = prelim_gain_db(&m);
|
||||
(play_slot.take().expect("gate fires once"))();
|
||||
app.state::<PlayerState>()
|
||||
.player
|
||||
.set_norm_gain_db(g as f32, true);
|
||||
applied_db = Some(g);
|
||||
*app.state::<LoudnessState>()
|
||||
.current
|
||||
.lock()
|
||||
.expect("loudness current lock") = Some(CurrentTrack {
|
||||
lufs: Some(lufs),
|
||||
peak,
|
||||
gain_db: g,
|
||||
cached: false,
|
||||
analyzing: true,
|
||||
});
|
||||
};
|
||||
let done = analyze_loudness(source, cancelled, &mut on_update);
|
||||
|
||||
// Gate never fired (clip shorter than a gating block, decode
|
||||
// failure, dead transfer): start at unity — unless a newer
|
||||
// track superseded this one, then never start it at all.
|
||||
if let Some(play) = play_slot.take() {
|
||||
if !cancelled() {
|
||||
play();
|
||||
}
|
||||
}
|
||||
let Some((lufs, peak)) = done else { return };
|
||||
// The full measurement is valid for every future play of
|
||||
// this file, whatever happened to this one.
|
||||
store_measurement(app, &key, lufs, peak, size);
|
||||
if !cancelled() {
|
||||
*app.state::<LoudnessState>()
|
||||
.current
|
||||
.lock()
|
||||
.expect("loudness current lock") = Some(CurrentTrack {
|
||||
lufs: Some(lufs),
|
||||
peak,
|
||||
gain_db: applied_db.unwrap_or(0.0),
|
||||
cached: false,
|
||||
analyzing: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn loudness analysis");
|
||||
}
|
||||
|
||||
/// Gapless normalization: called when the NEXT track's preload is complete
|
||||
/// (or was already cached on disk). Hands the boundary gain to the audio
|
||||
/// thread, which applies it sample-accurately when the gapless switch
|
||||
/// happens — a track change is the one place loudness may step. Guarded by
|
||||
/// the loudness generation: a user-initiated play supersedes any pending
|
||||
/// preload analysis (the player also clears its pending gain on Play/Stop).
|
||||
pub fn on_preload_ready(app: &tauri::AppHandle, cache_path: &Path) {
|
||||
let state = app.state::<LoudnessState>();
|
||||
if !state.enabled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let generation = state.generation.load(Ordering::Relaxed);
|
||||
let key = cache_path.to_string_lossy().into_owned();
|
||||
let known_size = std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0);
|
||||
if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) {
|
||||
if m.size == known_size || known_size == 0 {
|
||||
app.state::<PlayerState>()
|
||||
.player
|
||||
.set_next_norm_gain_db(gain_db(m) as f32);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let app = app.clone();
|
||||
let gen_ctr = state.generation.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("loudness-preload".into())
|
||||
.spawn(move || {
|
||||
let done = analyze_loudness(
|
||||
AnalysisSource::Local(PathBuf::from(&key)),
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
);
|
||||
let Some((lufs, peak)) = done else { return };
|
||||
store_measurement(&app, &key, lufs, peak, 0);
|
||||
// Only hand the boundary gain over if no new track started
|
||||
// since — a stale preload must not color a different queue.
|
||||
if gen_ctr.load(Ordering::Relaxed) == generation {
|
||||
let m = Measured {
|
||||
lufs,
|
||||
peak,
|
||||
size: 0,
|
||||
at_epoch_ms: 0,
|
||||
};
|
||||
app.state::<PlayerState>()
|
||||
.player
|
||||
.set_next_norm_gain_db(gain_db(&m) as f32);
|
||||
}
|
||||
})
|
||||
.expect("spawn preload loudness analysis");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn m(lufs: f64, peak: f32) -> Measured {
|
||||
Measured {
|
||||
lufs,
|
||||
peak,
|
||||
size: 0,
|
||||
at_epoch_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_tracks_boost_toward_target() {
|
||||
assert!((gain_db(&m(-20.0, 0.3)) - 6.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loud_tracks_cut_toward_target() {
|
||||
assert!((gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boost_is_capped_by_peak_headroom() {
|
||||
// Target wants +9 (clamped), but a 0.9 peak only has ~0.73 dB of
|
||||
// headroom to the 0.98 ceiling.
|
||||
let g = gain_db(&m(-30.0, 0.9));
|
||||
assert!(g < 0.8 && g > 0.6, "got {g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_ignore_peak() {
|
||||
assert!((gain_db(&m(-8.0, 0.2)) - (-6.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_measurements_stay_clamped() {
|
||||
assert_eq!(gain_db(&m(-60.0, 0.0001)), MAX_GAIN_DB);
|
||||
assert_eq!(gain_db(&m(20.0, 1.0)), MIN_GAIN_DB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preliminary_boosts_are_capped_but_cuts_pass_through() {
|
||||
// A quiet head would ask +6 dB; the head estimate only gets +3.
|
||||
assert!((prelim_gain_db(&m(-20.0, 0.3)) - PRELIM_MAX_BOOST_DB).abs() < 1e-9);
|
||||
// Cuts are trusted at full strength — a loud head is reliably loud.
|
||||
assert!((prelim_gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
+430
-50
@@ -14,8 +14,8 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -30,6 +30,15 @@ use crate::playback::PlayerState;
|
||||
/// motion than the main bar's 200 ms cadence, and one small window is cheap.
|
||||
const PREVIEW_POSITION_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Fade-engine tick. ~15 ms keeps a 250 ms ramp at 16+ steps — audibly
|
||||
/// smooth — while each step is one cheap volume command to the sink.
|
||||
const FADE_TICK: Duration = Duration::from_millis(15);
|
||||
|
||||
/// The preview voices' resting volume. The player crate's own default; the
|
||||
/// renderer never sets a preview volume today, so fades ramp to this unless
|
||||
/// a `volume` control changes the target.
|
||||
const DEFAULT_TARGET_VOL: f32 = 0.7;
|
||||
|
||||
/// Lease-lifetime constants, mirroring `src/lib/preview/lease.js` — the two
|
||||
/// sides MUST agree or the renderer would trust a lease the backend already
|
||||
/// considers dead. Values are ADRs backed by the Phase 0 soak.
|
||||
@@ -41,15 +50,138 @@ const MAX_SAFE_MS: i64 = 30 * 60_000;
|
||||
/// preview session never legitimately needs an hour-old clip.
|
||||
const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
/// Re-resolving a track whose clip bytes are already on disk skips the
|
||||
/// network entirely within this window (the bytes are immutable audio — only
|
||||
/// the signed URL goes stale, and a local file needs no URL). This is what
|
||||
/// makes stepping BACK to a just-played track as fast as a prefetched
|
||||
/// forward step. Kept well under CLIP_MAX_AGE so the sweep can never delete
|
||||
/// a file this cache would still hand out.
|
||||
const CLIP_REUSE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Two voices, not one: a 250 ms equal-power crossfade between track
|
||||
/// previews (user decision 2026-07-23) needs the outgoing and incoming clips
|
||||
/// SOUNDING at once, which a single `Player` cannot do. `active` names the
|
||||
/// voice whose events reach the UI; the other is either idle or a dying
|
||||
/// fade-out tail. Shadow volumes exist because `Player` has no volume
|
||||
/// getter and an interrupted fade must hand its partial level to the next
|
||||
/// ramp — restarting from the target would audibly bump.
|
||||
pub struct PreviewState {
|
||||
pub player: Player,
|
||||
voices: [Arc<Player>; 2],
|
||||
active: Arc<AtomicUsize>,
|
||||
/// f32 bits; what fades ramp to and steady playback sits at.
|
||||
target_vol: AtomicU32,
|
||||
/// f32 bits per voice; the last volume actually commanded.
|
||||
shadow_vols: Arc<[AtomicU32; 2]>,
|
||||
/// Bumping this orphans every in-flight fade thread (generation guard —
|
||||
/// same discipline as the renderer's session reducer).
|
||||
fade_gen: Arc<AtomicU64>,
|
||||
/// Per-voice counter bumped by the event forwarders on that voice's
|
||||
/// Playing events — i.e. a clip actually STARTED SOUNDING there. The
|
||||
/// crossfade waits on this before starting its ramps: ramping on the
|
||||
/// load command would fade the outgoing into the incoming clip's decode
|
||||
/// latency (the mock-derived "ramp on audible, not on play()" trap).
|
||||
audible_marks: Arc<[AtomicU64; 2]>,
|
||||
/// opaque lease id → clip file. Entries die with the session (dispose)
|
||||
/// or the sweep; the map is small by construction (active + prefetch).
|
||||
leases: Mutex<HashMap<String, PathBuf>>,
|
||||
/// track id → (clip file, fetched at): the CLIP_REUSE_TTL byte cache.
|
||||
/// Several leases may point at one file; dispose clears both maps.
|
||||
recent_clips: Mutex<HashMap<u64, (PathBuf, Instant)>>,
|
||||
clip_dir: PathBuf,
|
||||
lease_counter: AtomicU64,
|
||||
}
|
||||
|
||||
impl PreviewState {
|
||||
fn active_idx(&self) -> usize {
|
||||
self.active.load(Ordering::Relaxed)
|
||||
}
|
||||
fn target(&self) -> f32 {
|
||||
f32::from_bits(self.target_vol.load(Ordering::Relaxed))
|
||||
}
|
||||
fn set_vol(&self, idx: usize, v: f32) {
|
||||
self.voices[idx].set_volume(v);
|
||||
self.shadow_vols[idx].store(v.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
fn shadow(&self, idx: usize) -> f32 {
|
||||
f32::from_bits(self.shadow_vols[idx].load(Ordering::Relaxed))
|
||||
}
|
||||
/// Cancel any running fade and return the new generation.
|
||||
fn bump_fades(&self) -> u64 {
|
||||
self.fade_gen.fetch_add(1, Ordering::Relaxed) + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a crossfade waits for the incoming voice to become audible
|
||||
/// before ramping anyway. Local-file decode start is tens of ms; hitting
|
||||
/// this means the clip is broken (its error event fails the item) and the
|
||||
/// outgoing should still fade out rather than play forever.
|
||||
const AUDIBLE_WAIT_MAX: Duration = Duration::from_millis(2_000);
|
||||
|
||||
/// One fade thread: equal-power ramp (incoming rides sin, outgoing cos, so
|
||||
/// summed acoustic power stays level — a linear fade dips in the middle).
|
||||
/// `audible_gate` (marks array, voice, snapshot) holds BOTH ramps until that
|
||||
/// voice's mark moves past the snapshot — the incoming clip is genuinely
|
||||
/// sounding — so the outgoing keeps playing at level through decode startup
|
||||
/// and the cross is a true overlap. Generation-guarded at every tick; at
|
||||
/// completion the outgoing voice is STOPPED, not just silent, so a decayed
|
||||
/// clip can never linger.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_fade(
|
||||
gen: u64,
|
||||
gen_ctr: Arc<AtomicU64>,
|
||||
voices: [Arc<Player>; 2],
|
||||
shadows: Arc<[AtomicU32; 2]>,
|
||||
fade_in: Option<(usize, f32)>,
|
||||
fade_out: Option<(usize, f32)>,
|
||||
duration_ms: u64,
|
||||
audible_gate: Option<(Arc<[AtomicU64; 2]>, usize, u64)>,
|
||||
) {
|
||||
let set = move |shadows: &[AtomicU32; 2], voices: &[Arc<Player>; 2], idx: usize, v: f32| {
|
||||
voices[idx].set_volume(v);
|
||||
shadows[idx].store(v.to_bits(), Ordering::Relaxed);
|
||||
};
|
||||
std::thread::Builder::new()
|
||||
.name("preview-fade".into())
|
||||
.spawn(move || {
|
||||
if let Some((marks, idx, snapshot)) = &audible_gate {
|
||||
let wait0 = Instant::now();
|
||||
while marks[*idx].load(Ordering::Relaxed) == *snapshot
|
||||
&& wait0.elapsed() < AUDIBLE_WAIT_MAX
|
||||
{
|
||||
if gen_ctr.load(Ordering::Relaxed) != gen {
|
||||
return; // a newer command owns the voices now
|
||||
}
|
||||
std::thread::sleep(FADE_TICK);
|
||||
}
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
loop {
|
||||
if gen_ctr.load(Ordering::Relaxed) != gen {
|
||||
return; // a newer command owns the voices now
|
||||
}
|
||||
let t = (t0.elapsed().as_millis() as f32 / duration_ms.max(1) as f32).min(1.0);
|
||||
let phase = t * std::f32::consts::FRAC_PI_2;
|
||||
if let Some((idx, target)) = fade_in {
|
||||
set(&shadows, &voices, idx, target * phase.sin());
|
||||
}
|
||||
if let Some((idx, from)) = fade_out {
|
||||
set(&shadows, &voices, idx, from * phase.cos());
|
||||
}
|
||||
if t >= 1.0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(FADE_TICK);
|
||||
}
|
||||
if gen_ctr.load(Ordering::Relaxed) != gen {
|
||||
return;
|
||||
}
|
||||
if let Some((idx, _)) = fade_out {
|
||||
voices[idx].stop();
|
||||
}
|
||||
})
|
||||
.expect("spawn preview fade");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ init --
|
||||
|
||||
pub fn init(app: &tauri::AppHandle) {
|
||||
@@ -57,7 +189,8 @@ pub fn init(app: &tauri::AppHandle) {
|
||||
// the user's routing. (Device CHANGES while a preview session is open
|
||||
// are part of the Phase 2 focus matrix, handled at the coordinator.)
|
||||
let output_device = crate::config::load(app).audio_output_device;
|
||||
let (player, events) = Player::spawn_with_device(output_device);
|
||||
let (p0, events0) = Player::spawn_with_device(output_device.clone());
|
||||
let (p1, events1) = Player::spawn_with_device(output_device);
|
||||
|
||||
let clip_dir = app
|
||||
.path()
|
||||
@@ -67,39 +200,71 @@ pub fn init(app: &tauri::AppHandle) {
|
||||
let _ = std::fs::create_dir_all(&clip_dir);
|
||||
sweep_old_clips(&clip_dir);
|
||||
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let audible_marks: Arc<[AtomicU64; 2]> = Arc::new([AtomicU64::new(0), AtomicU64::new(0)]);
|
||||
|
||||
app.manage(PreviewState {
|
||||
player,
|
||||
voices: [Arc::new(p0), Arc::new(p1)],
|
||||
active: active.clone(),
|
||||
target_vol: AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()),
|
||||
shadow_vols: Arc::new([
|
||||
AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()),
|
||||
AtomicU32::new(DEFAULT_TARGET_VOL.to_bits()),
|
||||
]),
|
||||
fade_gen: Arc::new(AtomicU64::new(0)),
|
||||
audible_marks: audible_marks.clone(),
|
||||
leases: Mutex::new(HashMap::new()),
|
||||
recent_clips: Mutex::new(HashMap::new()),
|
||||
clip_dir,
|
||||
lease_counter: AtomicU64::new(1),
|
||||
});
|
||||
|
||||
let handle = app.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("preview-events".into())
|
||||
.spawn(move || {
|
||||
let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL;
|
||||
for ev in events {
|
||||
let ui = match ev {
|
||||
PlayerEvent::Buffering => PreviewUiEvent::Buffering,
|
||||
PlayerEvent::Playing => PreviewUiEvent::Playing,
|
||||
PlayerEvent::Paused => PreviewUiEvent::Paused,
|
||||
PlayerEvent::Stopped => PreviewUiEvent::Stopped,
|
||||
PlayerEvent::TrackEnded { .. } => PreviewUiEvent::Ended,
|
||||
PlayerEvent::Position { secs, duration } => {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_position) < PREVIEW_POSITION_INTERVAL {
|
||||
continue;
|
||||
}
|
||||
last_position = now;
|
||||
PreviewUiEvent::Position { secs, duration }
|
||||
// One forwarder per voice. Only the ACTIVE voice's events reach the UI:
|
||||
// after a crossfade handover the dying voice keeps decoding for its fade
|
||||
// tail, and its Stopped/Ended must not masquerade as the new clip's.
|
||||
for (idx, events) in [(0usize, events0), (1usize, events1)] {
|
||||
let handle = app.clone();
|
||||
let active = active.clone();
|
||||
let audible_marks = audible_marks.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("preview-events-{idx}"))
|
||||
.spawn(move || {
|
||||
let mut last_position = Instant::now() - PREVIEW_POSITION_INTERVAL;
|
||||
for ev in events {
|
||||
// Audible-mark bump happens BEFORE the active filter: it
|
||||
// is a per-voice fact the crossfade gate must see even if
|
||||
// a later command flipped active away. Playing ONLY — a
|
||||
// rapid step can land while this voice still runs an
|
||||
// orphaned fade tail, whose Position stream would open
|
||||
// the gate before the NEW clip sounds; Playing is emitted
|
||||
// exactly once per play start, so it names the new clip.
|
||||
if matches!(&ev, PlayerEvent::Playing) {
|
||||
audible_marks[idx].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
PlayerEvent::Error(message) => PreviewUiEvent::Error { message },
|
||||
};
|
||||
let _ = handle.emit("soulstream:preview", ui);
|
||||
}
|
||||
})
|
||||
.expect("spawn preview event forwarder");
|
||||
if active.load(Ordering::Relaxed) != idx {
|
||||
continue;
|
||||
}
|
||||
let ui = match ev {
|
||||
PlayerEvent::Buffering => PreviewUiEvent::Buffering,
|
||||
PlayerEvent::Playing => PreviewUiEvent::Playing,
|
||||
PlayerEvent::Paused => PreviewUiEvent::Paused,
|
||||
PlayerEvent::Stopped => PreviewUiEvent::Stopped,
|
||||
PlayerEvent::TrackEnded { .. } => PreviewUiEvent::Ended,
|
||||
PlayerEvent::Position { secs, duration } => {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_position) < PREVIEW_POSITION_INTERVAL {
|
||||
continue;
|
||||
}
|
||||
last_position = now;
|
||||
PreviewUiEvent::Position { secs, duration }
|
||||
}
|
||||
PlayerEvent::Error(message) => PreviewUiEvent::Error { message },
|
||||
};
|
||||
let _ = handle.emit("soulstream:preview", ui);
|
||||
}
|
||||
})
|
||||
.expect("spawn preview event forwarder");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
@@ -167,6 +332,42 @@ fn fail(failure: &'static str, retryable: bool) -> PreviewResolveResult {
|
||||
/// fresh — /track responses carry per-request signed URLs (Phase 0), so a
|
||||
/// cached body is a stale lease by construction. The single-retry cap on
|
||||
/// failures lives with the caller (renderer), matching the fixture resolver.
|
||||
/// Mint a lease over an already-downloaded clip file, if one is fresh enough.
|
||||
/// The bytes are immutable; only the signed URL a lease normally rides on can
|
||||
/// go stale, and a local file has no URL — so the reuse window is ours to
|
||||
/// pick (CLIP_REUSE_TTL). safe_for_ms gets the short fallback: a re-resolve
|
||||
/// after it lapses lands right back here and is equally cheap.
|
||||
fn mint_from_recent_clip(state: &PreviewState, track_id: u64) -> Option<PreviewResolveResult> {
|
||||
let (path, byte_length) = {
|
||||
let clips = state.recent_clips.lock().expect("preview clip lock");
|
||||
let (path, fetched_at) = clips.get(&track_id)?;
|
||||
if fetched_at.elapsed() > CLIP_REUSE_TTL {
|
||||
return None;
|
||||
}
|
||||
let len = std::fs::metadata(path).ok()?.len() as usize;
|
||||
(path.clone(), len)
|
||||
};
|
||||
let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let lease_id = format!("pv-{track_id}-{nonce}");
|
||||
state
|
||||
.leases
|
||||
.lock()
|
||||
.expect("preview lease lock")
|
||||
.insert(lease_id.clone(), path);
|
||||
Some(PreviewResolveResult {
|
||||
ok: true,
|
||||
lease: Some(PreviewLeaseDto {
|
||||
opaque_lease_id: lease_id,
|
||||
safe_for_ms: FALLBACK_SAFE_MS,
|
||||
byte_length,
|
||||
mime_type: "audio/mpeg".into(),
|
||||
clip_duration_ms: 30_000,
|
||||
}),
|
||||
failure: None,
|
||||
retryable: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn preview_resolve(
|
||||
library: State<'_, LibraryState>,
|
||||
@@ -175,6 +376,10 @@ pub async fn preview_resolve(
|
||||
) -> Result<PreviewResolveResult, String> {
|
||||
sweep_old_clips(&state.clip_dir);
|
||||
|
||||
if let Some(cached) = mint_from_recent_clip(&state, track_id) {
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let Some(body) = library.metadata.deezer_track_uncached(track_id).await else {
|
||||
return Ok(fail("network", true));
|
||||
};
|
||||
@@ -231,13 +436,15 @@ pub async fn preview_resolve(
|
||||
.leases
|
||||
.lock()
|
||||
.expect("preview lease lock")
|
||||
.insert(lease_id.clone(), path);
|
||||
.insert(lease_id.clone(), path.clone());
|
||||
state
|
||||
.recent_clips
|
||||
.lock()
|
||||
.expect("preview clip lock")
|
||||
.insert(track_id, (path, Instant::now()));
|
||||
|
||||
let safe_for_ms = compute_safe_for_ms(
|
||||
parse_exp_ms(&preview_url),
|
||||
media.date_ms,
|
||||
now_epoch_ms(),
|
||||
);
|
||||
let safe_for_ms =
|
||||
compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms());
|
||||
|
||||
Ok(PreviewResolveResult {
|
||||
ok: true,
|
||||
@@ -258,11 +465,21 @@ pub async fn preview_resolve(
|
||||
/// Load a resolved clip into the preview player. `start_paused` uses the
|
||||
/// desired-paused latch (Phase 0.4): the decoder comes up parked, so opening
|
||||
/// against a still-negotiating focus transfer can never leak audio.
|
||||
///
|
||||
/// `crossfade_ms > 0` (and not paused): the clip lands on the IDLE voice,
|
||||
/// which becomes active and ramps 0→target while the old voice ramps down
|
||||
/// from wherever it is and stops — the equal-power crossfade. `0`/paused is
|
||||
/// the original hard swap on the active voice.
|
||||
///
|
||||
/// `start_at_secs`: the session's resume memory — the decoder comes up
|
||||
/// already positioned there (no play-then-seek race).
|
||||
#[tauri::command]
|
||||
pub fn preview_load(
|
||||
state: State<'_, PreviewState>,
|
||||
lease_id: String,
|
||||
start_paused: bool,
|
||||
crossfade_ms: Option<u64>,
|
||||
start_at_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
let path = state
|
||||
.leases
|
||||
@@ -271,15 +488,47 @@ pub fn preview_load(
|
||||
.get(&lease_id)
|
||||
.cloned()
|
||||
.ok_or("unknown or expired lease")?;
|
||||
state.player.play_local(&path, Some(30.0));
|
||||
if start_paused {
|
||||
// Ack receipt matters less than the latch being set before any
|
||||
// decoder can finish; a timeout here still leaves the latch armed.
|
||||
let _ = state
|
||||
.player
|
||||
.pause_acked()
|
||||
.recv_timeout(Duration::from_millis(750));
|
||||
let gen = state.bump_fades();
|
||||
let xfade = crossfade_ms.unwrap_or(0);
|
||||
let cur = state.active_idx();
|
||||
let target = state.target();
|
||||
|
||||
if xfade == 0 || start_paused {
|
||||
// Hard swap (open, replay, muted, parked): idle voice must be silent
|
||||
// — it may be a fade tail a bumped generation just orphaned.
|
||||
state.voices[1 - cur].stop();
|
||||
state.set_vol(cur, target);
|
||||
state.voices[cur].play_local_from(&path, Some(30.0), start_at_secs);
|
||||
if start_paused {
|
||||
// Ack receipt matters less than the latch being set before any
|
||||
// decoder can finish; a timeout here still leaves the latch armed.
|
||||
let _ = state.voices[cur]
|
||||
.pause_acked()
|
||||
.recv_timeout(Duration::from_millis(750));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let next = 1 - cur;
|
||||
let out_from = state.shadow(cur);
|
||||
state.set_vol(next, 0.0);
|
||||
// Snapshot the incoming voice's audible mark BEFORE play so the fade
|
||||
// thread can tell "sounding now" from any earlier life of this voice.
|
||||
let mark = state.audible_marks[next].load(Ordering::Relaxed);
|
||||
// Active flips BEFORE play so the new voice's Buffering/Playing events
|
||||
// are the ones the UI sees; the old voice goes dark mid-fade by design.
|
||||
state.active.store(next, Ordering::Relaxed);
|
||||
state.voices[next].play_local_from(&path, Some(30.0), start_at_secs);
|
||||
spawn_fade(
|
||||
gen,
|
||||
state.fade_gen.clone(),
|
||||
state.voices.clone(),
|
||||
state.shadow_vols.clone(),
|
||||
Some((next, target)),
|
||||
Some((cur, out_from)),
|
||||
xfade,
|
||||
Some((state.audible_marks.clone(), next, mark)),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -289,12 +538,47 @@ pub fn preview_control(
|
||||
action: String,
|
||||
value: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
let cur = state.active_idx();
|
||||
match action.as_str() {
|
||||
"pause" => state.player.pause(),
|
||||
"resume" => state.player.resume(),
|
||||
"stop" => state.player.stop(),
|
||||
"seek" => state.player.seek(value.unwrap_or(0.0)),
|
||||
"volume" => state.player.set_volume(value.unwrap_or(0.7) as f32),
|
||||
"pause" => {
|
||||
// Pause silences EVERYTHING: orphan fades, kill any dying tail,
|
||||
// and park the active voice back at target so resume is full.
|
||||
state.bump_fades();
|
||||
state.voices[1 - cur].stop();
|
||||
state.voices[cur].pause();
|
||||
state.set_vol(cur, state.target());
|
||||
}
|
||||
"resume" => state.voices[cur].resume(),
|
||||
"stop" => {
|
||||
state.bump_fades();
|
||||
state.voices[0].stop();
|
||||
state.voices[1].stop();
|
||||
state.set_vol(cur, state.target());
|
||||
}
|
||||
"seek" => state.voices[cur].seek(value.unwrap_or(0.0)),
|
||||
"volume" => {
|
||||
let v = value.unwrap_or(DEFAULT_TARGET_VOL as f64) as f32;
|
||||
state.target_vol.store(v.to_bits(), Ordering::Relaxed);
|
||||
state.set_vol(cur, v);
|
||||
}
|
||||
// The crossfade's out-half when the incoming clip is not ready yet:
|
||||
// ramp the active voice to silence and stop it. The voice STAYS
|
||||
// active — its tail events carry a stale generation and die at the
|
||||
// renderer's reducer; a later crossfade load flips voices itself.
|
||||
"fade_out" => {
|
||||
let gen = state.bump_fades();
|
||||
state.voices[1 - cur].stop();
|
||||
spawn_fade(
|
||||
gen,
|
||||
state.fade_gen.clone(),
|
||||
state.voices.clone(),
|
||||
state.shadow_vols.clone(),
|
||||
None,
|
||||
Some((cur, state.shadow(cur))),
|
||||
(value.unwrap_or(250.0) as u64).max(1),
|
||||
None,
|
||||
);
|
||||
}
|
||||
other => return Err(format!("unknown preview action {other}")),
|
||||
}
|
||||
Ok(())
|
||||
@@ -304,7 +588,16 @@ pub fn preview_control(
|
||||
/// call repeatedly (close, navigation, hot reload all funnel here).
|
||||
#[tauri::command]
|
||||
pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> {
|
||||
state.player.stop();
|
||||
state.bump_fades();
|
||||
state.voices[0].stop();
|
||||
state.voices[1].stop();
|
||||
state.set_vol(0, state.target());
|
||||
state.set_vol(1, state.target());
|
||||
state
|
||||
.recent_clips
|
||||
.lock()
|
||||
.expect("preview clip lock")
|
||||
.clear();
|
||||
let mut leases = state.leases.lock().expect("preview lease lock");
|
||||
for (_, path) in leases.drain() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
@@ -314,6 +607,93 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> {
|
||||
|
||||
// ----------------------------------------------------- main-player ack ----
|
||||
|
||||
/// Generation guard for MAIN-player fade threads (the session open/close
|
||||
/// crossfades). A newer fade command orphans any running one, exactly like
|
||||
/// the preview voices' `fade_gen`.
|
||||
static MAIN_FADE_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Session-open crossfade, main side: ramp the main player to silence over
|
||||
/// `fade_ms` (equal-power cos, so the preview's sin ramp-in sums level),
|
||||
/// then pause it WITH the desired-paused latch, then restore `volume` on the
|
||||
/// now-silent sink so the later resume has its level back. Returns as soon
|
||||
/// as the fade thread is RUNNING — the caller starts the preview's ramp-in
|
||||
/// over it; the overlap is the point (deliberate relaxation of the Phase
|
||||
/// 0.4 "acknowledged silence before any preview audio" rule, user decision
|
||||
/// 2026-07-23). If the ack never lands, the volume stays at 0 — silent is
|
||||
/// the safe failure — and the close-side fade-resume self-heals it.
|
||||
#[tauri::command]
|
||||
pub fn player_fade_pause_acked(
|
||||
app: tauri::AppHandle,
|
||||
fade_ms: u64,
|
||||
volume: f64,
|
||||
) -> Result<(), String> {
|
||||
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
std::thread::Builder::new()
|
||||
.name("main-fade-out".into())
|
||||
.spawn(move || {
|
||||
let players = app.state::<PlayerState>();
|
||||
let v = volume as f32;
|
||||
let dur = fade_ms.max(1);
|
||||
let t0 = Instant::now();
|
||||
loop {
|
||||
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
||||
return;
|
||||
}
|
||||
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
||||
players
|
||||
.player
|
||||
.set_volume(v * (t * std::f32::consts::FRAC_PI_2).cos());
|
||||
if t >= 1.0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(FADE_TICK);
|
||||
}
|
||||
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
||||
return;
|
||||
}
|
||||
let rx = players.player.pause_acked();
|
||||
match rx.recv_timeout(Duration::from_millis(1_500)) {
|
||||
Ok(_) => players.player.set_volume(v),
|
||||
Err(_) => eprintln!("preview: main fade-pause ack timed out; volume left silent"),
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Session-close crossfade, main side: come back from the paused snapshot at
|
||||
/// volume 0 and ramp to `volume` while the preview's fade-out rings down.
|
||||
/// Resume also clears the desired-paused latch (player-crate contract).
|
||||
#[tauri::command]
|
||||
pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64, volume: f64) -> Result<(), String> {
|
||||
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
std::thread::Builder::new()
|
||||
.name("main-fade-in".into())
|
||||
.spawn(move || {
|
||||
let players = app.state::<PlayerState>();
|
||||
let v = volume as f32;
|
||||
players.player.set_volume(0.0);
|
||||
players.player.resume();
|
||||
let dur = fade_ms.max(1);
|
||||
let t0 = Instant::now();
|
||||
loop {
|
||||
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
||||
return;
|
||||
}
|
||||
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
||||
players
|
||||
.player
|
||||
.set_volume(v * (t * std::f32::consts::FRAC_PI_2).sin());
|
||||
if t >= 1.0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(FADE_TICK);
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PauseAckDto {
|
||||
|
||||
Reference in new issue
Block a user