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:
KeelrLeek committed 2026-07-23 16:05:14 -05:00
1 parent 5e877c0b0a
commit 793f78370e
27 files changed
+2834 -178

No files matched your search

+430 -50
View File
@@ -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 {