//! Preview playback backend — scrollable-track-previews Phase 2. //! //! A wholly separate audio path from the ordinary player: its own `Player` //! instance, its own event channel (`soulstream:preview`), its own bounded //! on-disk clip cache. Nothing here touches the queue, history, stream //! caches, or the Soulseek resolver — that isolation is the feature's //! non-negotiable boundary. //! //! The resolver contract mirrors `src/lib/preview/resolver.js`: stable track //! id in, `{ ok, lease | failure }` out, with the failure classes Phase 0 //! actually observed. Signed URLs never cross the IPC boundary — the //! renderer receives an opaque lease id; bytes live in a temp file the //! renderer never sees either. use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde::Serialize; use tauri::{Emitter, Manager, State}; use player::{Player, PlayerEvent}; use crate::pipeline::LibraryState; use crate::playback::PlayerState; /// Position events at ~10/s: the modal's canvas scrubber wants smoother /// 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 before the renderer speaks. Since /// 2026-07-23 the renderer mirrors the APP volume (mute → 0) via the /// `volume` control at session open and live while the modal is up, so /// this default only covers the instant before that first set arrives. 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. const SAFETY_MARGIN_MS: i64 = 20_000; const FALLBACK_SAFE_MS: i64 = 60_000; const MAX_SAFE_MS: i64 = 30 * 60_000; /// Clip files older than this are swept on init and periodically from the /// resolve path — a preview session never legitimately needs an hour-old clip. const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 60); /// The resolve-path sweep runs at most this often. Sweeping on every resolve /// was a directory walk + stat per step of a scrolling session for files /// that age out hourly. const SWEEP_MIN_INTERVAL: Duration = Duration::from_secs(10 * 60); /// Pace between prewarm downloads. Rides ON TOP of the metadata crate's own /// Deezer throttle; this keeps the walker from ever competing with a live /// step's resolve for the gate. const PREWARM_SPACING: Duration = Duration::from_millis(250); /// The walker starts after the session's opening resolve (and its ±1 /// neighbour prefetches) have had the network to themselves. const PREWARM_INITIAL_DELAY: Duration = Duration::from_millis(1500); /// Hard cap on one prewarm call, mirroring the frontend preloader's cap — a /// 500-item manifest warms its head, not the whole tail. const PREWARM_CAP: usize = 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 { voices: [Arc; 2], active: Arc, /// 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, /// 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]>, /// f32 bits per voice; the loudness-normalization gain (LINEAR) of the /// clip currently on that voice. The voice's effective volume is /// `target_vol * clip_gain` — the same "gain layered under the user /// volume" the main player uses, so Deezer clips match the user's /// −14 LUFS library instead of playing at raw master level. clip_gains: Arc<[AtomicU32; 2]>, /// opaque lease id → (clip file, normalization gain LINEAR). Entries die /// with the session (dispose) or the sweep; map is small (active + pre). leases: Mutex>, /// track id → (clip file, gain LINEAR, fetched at): the CLIP_REUSE_TTL /// byte cache. Several leases may point at one file; dispose clears both. recent_clips: Mutex>, clip_dir: PathBuf, lease_counter: AtomicU64, /// Generation guard for the session prewarm walker: bumping it (a new /// prewarm call, or dispose) halts any walker still in flight. prewarm_gen: Arc, /// The resolve-path clip sweep runs at most once per SWEEP_MIN_INTERVAL — /// it used to walk the clip directory on EVERY resolve. last_sweep: Mutex, } 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 clip_gain(&self, idx: usize) -> f32 { f32::from_bits(self.clip_gains[idx].load(Ordering::Relaxed)) } fn set_clip_gain(&self, idx: usize, g: f32) { self.clip_gains[idx].store(g.to_bits(), Ordering::Relaxed); } /// The voice's steady volume: app-volume mirror × the clip's loudness gain. fn effective_target(&self, idx: usize) -> f32 { self.target() * self.clip_gain(idx) } 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 } } /// The preview voices' current resting volume — diagnostics readout, so the /// debug overlay can prove the renderer's app-volume mirror actually lands. pub fn target_volume(state: &PreviewState) -> f32 { state.target() } /// The active clip's loudness-normalization gain in dB (0 = untouched), /// for the debug overlay — proves preview normalization is working, the way /// the main player's NORM row does. pub fn active_norm_db(state: &PreviewState) -> f32 { 20.0 * state.clip_gain(state.active_idx()).max(1e-6).log10() } /// 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, voices: [Arc; 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; 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) { // Same configured output device as the main player, so previews follow // 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 (p0, events0) = Player::spawn_with_device(output_device.clone()); let (p1, events1) = Player::spawn_with_device(output_device); let clip_dir = app .path() .app_cache_dir() .map(|d| d.join("preview-clips")) .unwrap_or_else(|_| std::env::temp_dir().join("soulstream-preview-clips")); 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 { 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(), clip_gains: Arc::new([ AtomicU32::new(1.0f32.to_bits()), AtomicU32::new(1.0f32.to_bits()), ]), leases: Mutex::new(HashMap::new()), recent_clips: Mutex::new(HashMap::new()), clip_dir, lease_counter: AtomicU64::new(1), prewarm_gen: Arc::new(AtomicU64::new(0)), last_sweep: Mutex::new(Instant::now()), }); // 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); } 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)] #[serde(tag = "kind", rename_all = "kebab-case")] enum PreviewUiEvent { Buffering, Playing, Paused, Stopped, Ended, Position { secs: f64, duration: Option }, Error { message: String }, } fn sweep_old_clips(dir: &std::path::Path) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let stale = entry .metadata() .and_then(|m| m.modified()) .map(|t| t.elapsed().unwrap_or_default() > CLIP_MAX_AGE) .unwrap_or(true); if stale { let _ = std::fs::remove_file(entry.path()); } } } // ------------------------------------------------------------- resolver --- #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PreviewLeaseDto { pub opaque_lease_id: String, pub safe_for_ms: i64, pub byte_length: usize, pub mime_type: String, /// Hint only; the transport reports the decoder's real duration. pub clip_duration_ms: i64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PreviewResolveResult { pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] pub lease: Option, #[serde(skip_serializing_if = "Option::is_none")] pub failure: Option<&'static str>, pub retryable: bool, } fn fail(failure: &'static str, retryable: bool) -> PreviewResolveResult { PreviewResolveResult { ok: false, lease: None, failure: Some(failure), retryable, } } /// Resolve a stable Deezer track id into a playable lease. Every call mints /// 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 { let (path, gain, byte_length) = { let clips = state.recent_clips.lock().expect("preview clip lock"); let (path, gain, 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(), *gain, 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, gain)); 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, }) } /// Loudness-normalization gain (LINEAR) for a freshly written preview clip: /// measure it with the same BS.1770 meter the main player uses and bring it /// to −14 LUFS, so Deezer's hot masters match the user's normalized library. /// 1.0 (no change) when normalization is off or the clip can't be measured. fn measure_clip_gain(app: &tauri::AppHandle, path: &std::path::Path) -> f32 { if !crate::config::volume_normalization(&crate::config::load(app)) { return 1.0; } match player::analyze_loudness( player::AnalysisSource::Local(path.to_path_buf()), &|| false, &mut |_, _, _| {}, ) { player::Analysis::Complete { lufs, peak } => { 10f64.powf(crate::loudness::normalization_gain_db(lufs, peak) / 20.0) as f32 } // A clip that did not decode all the way through is measured over an // unknown fraction of itself; play it as mastered rather than at a // gain derived from part of it. player::Analysis::Incomplete(_) => 1.0, } } /// A clip fetched, validated, stored on disk and loudness-measured — the /// shared core of the live resolve and the session prewarm. Registers the /// clip in `recent_clips`; minting a lease over it is the caller's business. struct StoredClip { path: PathBuf, gain: f32, byte_length: usize, safe_for_ms: i64, } async fn fetch_store_clip( app: &tauri::AppHandle, library: &LibraryState, state: &PreviewState, track_id: u64, ) -> Result { let Some(body) = library.metadata.deezer_track_uncached(track_id).await else { return Err(fail("network", true)); }; if let Some(err) = body.get("error") { let class = err.get("type").and_then(|t| t.as_str()).unwrap_or(""); return Err(match class { // Deezer's 200-with-error-body shapes, from Phase 0. "DataException" | "InvalidQueryException" => fail("not_found", false), _ => fail("temporarily_failed", true), }); } let preview_url = body .get("preview") .and_then(|p| p.as_str()) .unwrap_or("") .to_string(); if preview_url.is_empty() { return Err(fail("not_provided", false)); } let media = match metadata::fetch_preview_media(&preview_url).await { Ok(m) => m, Err(metadata::PreviewFetchError::Status(403)) => return Err(fail("stale_lease", true)), Err(metadata::PreviewFetchError::Status(_)) => return Err(fail("temporarily_failed", true)), Err(metadata::PreviewFetchError::DisallowedUrl) => return Err(fail("invalid_media", false)), Err(metadata::PreviewFetchError::TooLarge) => return Err(fail("invalid_media", false)), Err(metadata::PreviewFetchError::Network) => return Err(fail("network", true)), }; // MIME gate first (the stale-lease body is an HTML error page), then the // container transform every Deezer clip needs: skip the universal empty // ID3v2.4 shell that crashes the symphonia entry (Phase 0.3), then prove // the remainder starts on an MPEG sync before it goes anywhere near a // decoder. let mime_ok = media .content_type .as_deref() .is_some_and(|m| m.starts_with("audio/mpeg")); if !mime_ok { return Err(fail("invalid_media", false)); } let audio = strip_id3v2(&media.bytes); if !starts_with_mpeg_sync(audio) { return Err(fail("invalid_media", false)); } let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); let path = state.clip_dir.join(format!("pv-{track_id}-{nonce}.mp3")); if std::fs::write(&path, audio).is_err() { return Err(fail("temporarily_failed", true)); } // Measure loudness off the async worker (a 30 s clip decodes in tens of // ms). On a prefetched or prewarmed clip this is fully off the playback // path. let gain = { let app2 = app.clone(); let path2 = path.clone(); tauri::async_runtime::spawn_blocking(move || measure_clip_gain(&app2, &path2)) .await .unwrap_or(1.0) }; let byte_length = audio.len(); state .recent_clips .lock() .expect("preview clip lock") .insert(track_id, (path.clone(), gain, Instant::now())); Ok(StoredClip { path, gain, byte_length, safe_for_ms: compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()), }) } #[tauri::command] pub async fn preview_resolve( app: tauri::AppHandle, library: State<'_, LibraryState>, state: State<'_, PreviewState>, track_id: u64, ) -> Result { { // Throttled sweep: a directory walk per resolve was pure waste in a // scrolling session; hour-old files don't need minute-fresh reaping. let mut last = state.last_sweep.lock().expect("preview sweep lock"); if last.elapsed() > SWEEP_MIN_INTERVAL { *last = Instant::now(); sweep_old_clips(&state.clip_dir); } } if let Some(cached) = mint_from_recent_clip(&state, track_id) { return Ok(cached); } let stored = match fetch_store_clip(&app, &library, &state, track_id).await { Ok(s) => s, Err(failure) => return Ok(failure), }; 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(), (stored.path, stored.gain)); Ok(PreviewResolveResult { ok: true, lease: Some(PreviewLeaseDto { opaque_lease_id: lease_id, safe_for_ms: stored.safe_for_ms, byte_length: stored.byte_length, mime_type: "audio/mpeg".into(), clip_duration_ms: 30_000, }), failure: None, retryable: false, }) } /// Best-effort session prewarm: walk the manifest's Deezer track ids in /// session order and pull every clip onto disk ahead of the user, so a step /// landing ANYWHERE in the session resolves from `recent_clips` without /// touching the network — the audio-side twin of the overlay's artwork /// preloader (Kyle's call 2026-08-01: decode once at open, never during /// scrolling). Paced gently (PREWARM_SPACING between clips, on top of the /// metadata crate's own Deezer throttle), capped at PREWARM_CAP, and halted /// by the generation guard the moment the session closes or a newer session /// opens. Failures are skipped silently — the live resolve path still owns /// error classification for anything the user actually lands on. #[tauri::command] pub async fn preview_prewarm( app: tauri::AppHandle, library: State<'_, LibraryState>, state: State<'_, PreviewState>, track_ids: Vec, ) -> Result<(), String> { let my_gen = state.prewarm_gen.fetch_add(1, Ordering::Relaxed) + 1; let gen_ctr = state.prewarm_gen.clone(); let fresh = move || gen_ctr.load(Ordering::Relaxed) == my_gen; tokio::time::sleep(PREWARM_INITIAL_DELAY).await; for track_id in track_ids.into_iter().take(PREWARM_CAP) { if !fresh() { return Ok(()); } let cached = { let clips = state.recent_clips.lock().expect("preview clip lock"); clips .get(&track_id) .is_some_and(|(_, _, at)| at.elapsed() < CLIP_REUSE_TTL) }; if cached { continue; } let _ = fetch_store_clip(&app, &library, &state, track_id).await; if !fresh() { return Ok(()); } tokio::time::sleep(PREWARM_SPACING).await; } Ok(()) } // ------------------------------------------------------------ transport --- /// 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, start_at_secs: Option, ) -> Result<(), String> { let (path, clip_gain) = state .leases .lock() .expect("preview lease lock") .get(&lease_id) .cloned() .ok_or("unknown or expired lease")?; let gen = state.bump_fades(); let xfade = crossfade_ms.unwrap_or(0); let cur = state.active_idx(); 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_clip_gain(cur, clip_gain); state.set_vol(cur, state.effective_target(cur)); 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_clip_gain(next, clip_gain); state.set_vol(next, 0.0); // The incoming voice ramps to ITS clip's normalized level (app volume × // this clip's gain) — so a loud clip crossing into a quiet one lands at // the right, matched loudness, not the outgoing clip's. let in_target = state.effective_target(next); // 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, in_target)), Some((cur, out_from)), xfade, Some((state.audible_marks.clone(), next, mark)), ); Ok(()) } #[tauri::command] pub fn preview_control( state: State<'_, PreviewState>, action: String, value: Option, ) -> Result<(), String> { let cur = state.active_idx(); match action.as_str() { "pause" => { // Pause silences EVERYTHING: orphan fades, kill any dying tail, // and park the active voice back at its normalized level so a // resume is at full (matched) loudness. state.bump_fades(); state.voices[1 - cur].stop(); state.voices[cur].pause(); state.set_vol(cur, state.effective_target(cur)); } "resume" => state.voices[cur].resume(), "stop" => { state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); state.set_vol(cur, state.effective_target(cur)); } "seek" => state.voices[cur].seek(value.unwrap_or(0.0)), "volume" => { // App-volume mirror. The clip's normalization gain rides UNDER // it (effective = app volume × clip gain), so the level tracks // the slider while staying loudness-matched. 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, state.effective_target(cur)); } // 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(()) } /// Session teardown: silence the transport and drop every clip file. Safe to /// call repeatedly (close, navigation, hot reload all funnel here). #[tauri::command] pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { // Halt any in-flight session prewarm walker before touching the caches. state.prewarm_gen.fetch_add(1, Ordering::Relaxed); state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); // Reset clip gains with the session — the next session measures afresh. state.set_clip_gain(0, 1.0); state.set_clip_gain(1, 1.0); state.set_vol(0, state.target()); state.set_vol(1, state.target()); // Drain BOTH maps and delete the files each references — prewarmed clips // exist only in recent_clips (no lease was ever minted over them), so // clearing the map without deleting orphaned them until the hourly // sweep. Shared paths get two remove attempts; the second is a no-op. { let mut clips = state.recent_clips.lock().expect("preview clip lock"); for (_, (path, _gain, _at)) in clips.drain() { let _ = std::fs::remove_file(path); } } let mut leases = state.leases.lock().expect("preview lease lock"); for (_, (path, _gain)) in leases.drain() { let _ = std::fs::remove_file(path); } Ok(()) } // ----------------------------------------------------- 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's FADE-GAIN (a /// channel separate from the user volume) 1→0 over `fade_ms` (equal-power /// cos, so the preview's sin ramp-in sums level), then pause with the /// desired-paused latch. 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). /// /// Fade-gain is used INSTEAD of the user volume precisely so a fade can never /// strand the volume: fade-gain resets to 1 on the next Play, and the /// close-side fade-resume ramps it back — so even if the ack times out or a /// track change suppresses the resume, a later clicked track still plays at /// full (the "audio broken after a track click" bug this replaces). #[tauri::command] pub fn player_fade_pause_acked(app: tauri::AppHandle, fade_ms: u64) -> 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::(); 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_fade_gain((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; } // Pause with the latch. Fade-gain stays 0 while paused (silent), // but the user volume is untouched, so nothing is stranded. let rx = players.player.pause_acked(); let _ = rx.recv_timeout(Duration::from_millis(1_500)); }) .map_err(|e| e.to_string())?; Ok(()) } /// Session-close crossfade, main side: resume the paused main and ramp its /// FADE-GAIN 0→1 while the preview's fade-out rings down. Resume clears the /// desired-paused latch (player-crate contract); the user volume is never /// touched, so the track returns at exactly the level it left. #[tauri::command] pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64) -> 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::(); players.player.set_fade_gain(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_fade_gain((t * std::f32::consts::FRAC_PI_2).sin()); if t >= 1.0 { break; } std::thread::sleep(FADE_TICK); } // Land exactly at unity so a stale tick can't leave it short. if MAIN_FADE_GEN.load(Ordering::Relaxed) == gen { players.player.set_fade_gain(1.0); } }) .map_err(|e| e.to_string())?; Ok(()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PauseAckDto { pub generation: u64, pub sink_was_active: bool, pub latched: bool, } /// The focus coordinator's silence request against the MAIN player: pause /// with a generation-bound acknowledgement (Phase 0.4 primitive). A timeout /// is an error — the caller keeps preview parked; it never assumes silence. #[tauri::command] pub async fn player_pause_acked(players: State<'_, PlayerState>) -> Result { let rx = players.player.pause_acked(); tauri::async_runtime::spawn_blocking(move || { rx.recv_timeout(Duration::from_millis(1_500)) .map(|ack| PauseAckDto { generation: ack.generation, sink_was_active: ack.sink_was_active, latched: ack.latched, }) .map_err(|_| "silence acknowledgement timed out".to_string()) }) .await .map_err(|e| e.to_string())? } // ------------------------------------------------------- pure helpers ----- fn now_epoch_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0) } /// `exp=` out of an Akamai `hdnea` token (or bare query). fn parse_exp_ms(url: &str) -> Option { let idx = url.find("exp=")?; let digits: String = url[idx + 4..] .chars() .take_while(|c| c.is_ascii_digit()) .collect(); if !(9..=11).contains(&digits.len()) { return None; } digits.parse::().ok().map(|s| s * 1000) } /// Mirror of `computeSafeForMs` in src/lib/preview/lease.js — see that file /// for the reasoning; the two implementations are kept behaviorally /// identical and both covered by tests. fn compute_safe_for_ms(exp_ms: Option, date_ms: Option, local_now_ms: i64) -> i64 { let Some(exp) = exp_ms else { return FALLBACK_SAFE_MS; }; if exp <= 0 { return FALLBACK_SAFE_MS; } let mut remaining: Option = None; if let Some(date) = date_ms { if date > 0 { let r = exp - date; if r <= 0 || r > 2 * MAX_SAFE_MS { return FALLBACK_SAFE_MS; } remaining = Some(r); } } let local_view = exp - local_now_ms; if local_view <= 0 && remaining.is_none() { return FALLBACK_SAFE_MS; } let usable = match remaining { None => local_view, Some(r) => { if local_view > 0 { r.min(local_view) } else { r } } }; (usable.min(MAX_SAFE_MS) - SAFETY_MARGIN_MS).max(0) } /// Skip a leading ID3v2 tag. Every Phase 0 sample carries a 10-byte v2.4 /// shell with tag size 0, but the parser handles real non-zero tags too — /// the syncsafe size field is authoritative. fn strip_id3v2(bytes: &[u8]) -> &[u8] { if bytes.len() < 10 || &bytes[0..3] != b"ID3" { return bytes; } let size = ((bytes[6] & 0x7f) as usize) << 21 | ((bytes[7] & 0x7f) as usize) << 14 | ((bytes[8] & 0x7f) as usize) << 7 | (bytes[9] & 0x7f) as usize; let end = 10usize.saturating_add(size); if end >= bytes.len() { return bytes; // tag claims to swallow the file: leave it alone } &bytes[end..] } fn starts_with_mpeg_sync(bytes: &[u8]) -> bool { bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 } // ------------------------------------------------------------------ tests - #[cfg(test)] mod tests { use super::*; const MIN: i64 = 60_000; const NOW: i64 = 1_784_760_000_000; #[test] fn safe_for_ms_valid_uses_provider_clock() { assert_eq!( compute_safe_for_ms(Some(NOW + 14 * MIN), Some(NOW), NOW), 14 * MIN - SAFETY_MARGIN_MS ); } #[test] fn safe_for_ms_local_clock_ahead_shrinks() { assert_eq!( compute_safe_for_ms(Some(NOW + 14 * MIN), Some(NOW), NOW + 10 * MIN), 4 * MIN - SAFETY_MARGIN_MS ); } #[test] fn safe_for_ms_local_clock_behind_cannot_extend() { assert_eq!( compute_safe_for_ms(Some(NOW + 14 * MIN), Some(NOW), NOW - 60 * MIN), 14 * MIN - SAFETY_MARGIN_MS ); } #[test] fn safe_for_ms_missing_or_contradictory_falls_back() { assert_eq!(compute_safe_for_ms(None, Some(NOW), NOW), FALLBACK_SAFE_MS); assert_eq!( compute_safe_for_ms(Some(NOW - MIN), Some(NOW), NOW), FALLBACK_SAFE_MS ); assert_eq!( compute_safe_for_ms(Some(NOW + 6 * 60 * MIN), Some(NOW), NOW), FALLBACK_SAFE_MS ); } #[test] fn exp_parses_from_hdnea_token() { let url = "https://cdnt-preview.dzcdn.net/api/x.mp3?hdnea=exp=1784761537~acl=/x*~hmac=aa"; assert_eq!(parse_exp_ms(url), Some(1_784_761_537_000)); assert_eq!(parse_exp_ms("https://h/x"), None); assert_eq!(parse_exp_ms("https://h/x?exp=12"), None); // too short } #[test] fn id3_empty_shell_is_skipped() { // The exact Phase 0 shape: v2.4 header, flags 0, size 0, then sync. let mut f = b"ID3\x04\x00\x00\x00\x00\x00\x00".to_vec(); f.extend_from_slice(&[0xFF, 0xFB, 0x92, 0x64, 1, 2, 3]); let out = strip_id3v2(&f); assert!(starts_with_mpeg_sync(out)); assert_eq!(out.len(), 7); } #[test] fn id3_nonzero_tag_is_skipped_by_syncsafe_size() { let mut f = b"ID3\x03\x00\x00\x00\x00\x00\x0A".to_vec(); // size 10 f.extend_from_slice(&[0u8; 10]); // tag body f.extend_from_slice(&[0xFF, 0xFB, 0x00, 0x00]); assert!(starts_with_mpeg_sync(strip_id3v2(&f))); } #[test] fn id3_overlong_tag_leaves_bytes_untouched() { let f = b"ID3\x04\x00\x00\x7f\x7f\x7f\x7f rest".to_vec(); assert_eq!(strip_id3v2(&f), &f[..]); } #[test] fn plain_frames_pass_through() { let f = [0xFF, 0xFB, 0x92, 0x64]; assert_eq!(strip_id3v2(&f), &f[..]); assert!(starts_with_mpeg_sync(&f)); assert!(!starts_with_mpeg_sync(b"")); } }