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.
891 lines
34 KiB
Rust
891 lines
34 KiB
Rust
//! 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. 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.
|
|
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 on each resolve — a
|
|
/// 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 {
|
|
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) {
|
|
// 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(),
|
|
leases: Mutex::new(HashMap::new()),
|
|
recent_clips: Mutex::new(HashMap::new()),
|
|
clip_dir,
|
|
lease_counter: AtomicU64::new(1),
|
|
});
|
|
|
|
// 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<f64> },
|
|
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<PreviewLeaseDto>,
|
|
#[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<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>,
|
|
state: State<'_, PreviewState>,
|
|
track_id: u64,
|
|
) -> 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));
|
|
};
|
|
if let Some(err) = body.get("error") {
|
|
let class = err.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
return Ok(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 Ok(fail("not_provided", false));
|
|
}
|
|
|
|
let media = match metadata::fetch_preview_media(&preview_url).await {
|
|
Ok(m) => m,
|
|
Err(metadata::PreviewFetchError::Status(403)) => return Ok(fail("stale_lease", true)),
|
|
Err(metadata::PreviewFetchError::Status(_)) => return Ok(fail("temporarily_failed", true)),
|
|
Err(metadata::PreviewFetchError::DisallowedUrl) => return Ok(fail("invalid_media", false)),
|
|
Err(metadata::PreviewFetchError::TooLarge) => return Ok(fail("invalid_media", false)),
|
|
Err(metadata::PreviewFetchError::Network) => return Ok(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 Ok(fail("invalid_media", false));
|
|
}
|
|
let audio = strip_id3v2(&media.bytes);
|
|
if !starts_with_mpeg_sync(audio) {
|
|
return Ok(fail("invalid_media", false));
|
|
}
|
|
|
|
let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed);
|
|
let lease_id = format!("pv-{track_id}-{nonce}");
|
|
let path = state.clip_dir.join(format!("{lease_id}.mp3"));
|
|
if std::fs::write(&path, audio).is_err() {
|
|
return Ok(fail("temporarily_failed", true));
|
|
}
|
|
state
|
|
.leases
|
|
.lock()
|
|
.expect("preview lease lock")
|
|
.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());
|
|
|
|
Ok(PreviewResolveResult {
|
|
ok: true,
|
|
lease: Some(PreviewLeaseDto {
|
|
opaque_lease_id: lease_id,
|
|
safe_for_ms,
|
|
byte_length: audio.len(),
|
|
mime_type: "audio/mpeg".into(),
|
|
clip_duration_ms: 30_000,
|
|
}),
|
|
failure: None,
|
|
retryable: false,
|
|
})
|
|
}
|
|
|
|
// ------------------------------------------------------------ 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<u64>,
|
|
start_at_secs: Option<f64>,
|
|
) -> Result<(), String> {
|
|
let path = 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();
|
|
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(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn preview_control(
|
|
state: State<'_, PreviewState>,
|
|
action: String,
|
|
value: Option<f64>,
|
|
) -> 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 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(())
|
|
}
|
|
|
|
/// 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> {
|
|
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);
|
|
}
|
|
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 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 {
|
|
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<PauseAckDto, String> {
|
|
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=<epoch-seconds>` out of an Akamai `hdnea` token (or bare query).
|
|
fn parse_exp_ms(url: &str) -> Option<i64> {
|
|
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::<i64>().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<i64>, date_ms: Option<i64>, 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<i64> = 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"<HTML>"));
|
|
}
|
|
}
|