Files
psysonic/src-tauri/src/audio/commands.rs
T
Psychotoxical 0fae33f00b refactor(audio): extract transport commands into transport_commands
Pull audio_pause / audio_resume / audio_stop / audio_seek (~210 LOC)
out of audio/commands.rs into audio/transport_commands.rs. They mutate
state.current on an already-running sink and coordinate radio
warm/cold resume — distinct concern from playback startup
(audio_play / audio_chain_preload / audio_preload).

audio/commands.rs: 1447 → 1240 LOC. Imports trimmed: TryLockError,
radio_download_task, RADIO_BUF_CAPACITY are no longer pulled in here;
they followed the transport block to its new home. lib.rs
invoke_handler updated.

The remaining commands.rs is now focused on the playback orchestration
proper (track + chain preload + initial preload + the shared
spawn_progress_task helper).
2026-05-08 14:01:56 +02:00

1242 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tauri commands: audio_play / chain_preload / preload + the shared
//! spawn_progress_task helper. Transport (pause/resume/stop/seek), device,
//! radio, mix-mode and AutoEQ commands live in sibling modules.
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use ringbuf::{HeapCons, HeapRb};
use ringbuf::traits::Split;
use rodio::Player;
use rodio::Source;
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter, Manager, State};
use super::decode::{build_source, build_streaming_source, SizedDecoder};
use super::engine::{audio_http_client, AudioCurrent, AudioEngine};
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::preview::preview_clear_for_new_main_playback;
use super::state::{ChainedInfo, PreloadedTrack};
use super::stream::{
ranged_download_task, track_download_task, AudioStreamReader,
LocalFileSource, RangedHttpSource, RADIO_READ_TIMEOUT_SECS,
TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY, LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
};
// ─── Commands ─────────────────────────────────────────────────────────────────
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
/// for HTTP streams (`playback_identity` is used as fallback).
///
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
#[tauri::command]
pub async fn audio_play(
url: String,
volume: f32,
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
analysis_track_id: Option<String>,
stream_format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
// After a gapless auto-advance, the frontend may fire a stale playTrack()
// call via IPC. If we're within 500 ms of the last gapless switch AND the
// requested URL matches the already-playing chained track, reject it.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
if switch_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if now_ms.saturating_sub(switch_ms) < 500 {
// Within the guard window — suppress this ghost command.
return Ok(());
}
}
}
// Cancel any active preview before starting fresh main playback so the
// two sinks don't end up mixed.
preview_clear_for_new_main_playback(&state, &app);
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
// audio_chain_preload already appended this URL to the Sink 30 s in
// advance. The source is live in the queue — just return and let the
// progress task handle the state transition when the previous source ends.
//
// Never for manual skips: the UI already jumped to this track in JS, but
// the current source is still playing until the chain drains. User-initiated
// play must clear the chain and start this URL immediately (standard path).
if gapless && !manual {
let already_chained = state.chained_info.lock().unwrap()
.as_ref()
.map(|c| same_playback_target(&c.url, &url))
.unwrap_or(false);
if already_chained {
return Ok(());
}
}
// ── Standard (new-sink) path ─────────────────────────────────────────────
// Used for: manual skip, gapless OFF, first play, or gapless when the
// proactive chain was not set up in time.
// Bump generation first so the old progress task stops before we peel
// chained_info (avoids a race where it sees current_done + empty chain).
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Manual skip onto the gapless-pre-chained track: reuse raw bytes (no HTTP;
// preload cache was already consumed when the chain was built). Otherwise
// clear any stale chain metadata.
let reuse_chained_bytes: Option<Vec<u8>> = if gapless && manual {
let mut ci = state.chained_info.lock().unwrap();
if ci.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
ci.take().map(|info| {
Arc::try_unwrap(info.raw_bytes).unwrap_or_else(|a| (*a).clone())
})
} else {
*ci = None;
None
}
} else {
*state.chained_info.lock().unwrap() = None;
None
};
// Stop fading-out sink from previous crossfade.
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
old.stop();
}
// Pin the logical playback URL immediately so `audio_update_replay_gain` (e.g. from
// a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not
// the previous URL still stored until the sink swap completes.
*state.current_playback_url.lock().unwrap() = Some(url.clone());
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
*state.current_analysis_track_id.lock().unwrap() = logical_trim.clone();
let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url);
// Extract format hint from URL for better symphonia probing. Strip the
// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
// don't latch onto random query-param substrings; only accept short
// alphanumeric tails that look like an actual audio extension.
let format_hint = url
.split('?').next()
.and_then(|path| path.rsplit('.').next())
.filter(|ext| {
(1..=5).contains(&ext.len())
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
&& matches!(
ext.to_ascii_lowercase().as_str(),
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4"
| "aac" | "wav" | "wave" | "ape" | "wv" | "webm" | "mka"
)
})
.map(|s| s.to_lowercase());
enum PlayInput {
Bytes(Vec<u8>),
/// Seekable on-demand source — `RangedHttpSource` for HTTP streams,
/// `LocalFileSource` for `psysonic-local://` files. Goes through
/// `build_streaming_source` (no iTunSMPB scan, since we don't have the
/// bytes in memory; chained-track gapless trim still applies via the
/// re-played `Bytes` path on the next start).
SeekableMedia {
reader: Box<dyn MediaSource>,
format_hint: Option<String>,
tag: &'static str,
},
Streaming {
reader: AudioStreamReader,
format_hint: Option<String>,
},
}
// Data source selection:
// 1) Reused chained bytes (manual skip onto pre-chained track)
// 2) `psysonic-local://` (offline / hot cache hit) → LocalFileSource (instant)
// 3) Manual uncached remote start:
// a) Server supports Range + Content-Length → seekable RangedHttpSource
// b) Server does not → legacy non-seekable AudioStreamReader fallback
// 4) Preloaded/streamed-cache hit → in-memory bytes via fetch_data
let play_input = if let Some(d) = reuse_chained_bytes {
spawn_analysis_seed_from_in_memory_bytes(
&app,
cache_id_for_tasks.as_deref(),
gen,
&state.generation,
&d,
);
PlayInput::Bytes(d)
} else {
let stream_cache_hit = {
let streamed = state.stream_completed_cache.lock().unwrap();
streamed
.as_ref()
.is_some_and(|p| same_playback_target(&p.url, &url))
};
let preloaded_hit = {
let preloaded = state.preloaded.lock().unwrap();
preloaded
.as_ref()
.is_some_and(|p| same_playback_target(&p.url, &url))
};
let is_local = url.starts_with("psysonic-local://");
if is_local && !stream_cache_hit && !preloaded_hit {
let path = url.strip_prefix("psysonic-local://").unwrap();
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
let local_hint = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
crate::app_deprintln!(
"[stream] LocalFileSource selected — size={} KB, hint={:?}",
len / 1024,
local_hint
);
if let Some(ref seed_id) = cache_id_for_tasks {
let skip_cpu_seed = app
.try_state::<crate::analysis_cache::AnalysisCache>()
.map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false))
.unwrap_or(false);
if !skip_cpu_seed {
let path_owned = std::path::PathBuf::from(path);
let app_seed = app.clone();
let gen_seed = gen;
let gen_arc_seed = state.generation.clone();
let seed_id = seed_id.clone();
tokio::spawn(async move {
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
let data = match tokio::fs::read(&path_owned).await {
Ok(d) => d,
Err(_) => return,
};
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
crate::app_deprintln!(
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
seed_id,
data.len(),
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
);
return;
}
crate::app_deprintln!(
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
seed_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
if let Err(e) =
crate::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
{
crate::app_eprintln!(
"[analysis] local-file seed failed for {}: {}",
seed_id,
e
);
}
});
}
}
let reader = LocalFileSource { file, len };
PlayInput::SeekableMedia {
reader: Box::new(reader),
format_hint: local_hint,
tag: "local-file",
}
} else if !stream_cache_hit && !preloaded_hit && !is_local {
// `manual` must NOT gate this branch: natural track end calls `next(false)`,
// so auto-advance must still use ranged HTTP when available. Gating used to
// force every auto-start through `fetch_data` (full RAM buffer + extra fetch log)
// instead of `RangedHttpSource`.
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // superseded
}
let status = response.status().as_u16();
let msg = format!("HTTP {status}");
app.emit("audio:error", &msg).ok();
return Err(msg);
}
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(|cd| format_hint_from_content_disposition(cd))
})
.or_else(|| normalize_stream_suffix_for_hint(stream_format_suffix.as_deref()))
.or_else(|| format_hint.clone());
let supports_range = response.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = audio_http_client(&state)
.get(&url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
match pr.bytes().await {
Ok(bytes) if !bytes.is_empty() => {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
if stream_hint.is_some() {
crate::app_deprintln!(
"[stream] ranged: format sniff from {} B prefix → hint={:?}",
bytes.len(),
stream_hint
);
}
}
_ => {}
}
}
}
}
}
// Guardrail: when format/container hint is unknown, some demuxers may
// seek near EOF during probe. With a progressively downloaded ranged
// source that can delay first audible samples until most/all bytes are
// fetched. Prefer sequential streaming in that case for faster start.
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
let total_usize = total as usize;
crate::app_deprintln!(
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let loudness_hold_for_defer = (total_usize <= crate::audio::stream::TRACK_STREAM_PROMOTE_MAX_BYTES)
.then_some(state.ranged_loudness_seed_hold.clone());
tokio::spawn(ranged_download_task(
gen,
state.generation.clone(),
audio_http_client(&state),
app.clone(),
duration_hint,
url.clone(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
cache_id_for_tasks.clone(),
loudness_hold_for_defer,
));
let reader = RangedHttpSource {
buf,
downloaded_to,
total_size: total,
pos: 0,
done,
gen_arc: state.generation.clone(),
gen,
};
PlayInput::SeekableMedia {
reader: Box::new(reader),
format_hint: stream_hint,
tag: "ranged-stream",
}
} else {
crate::app_deprintln!(
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range, total_size, stream_hint
);
let buffer_cap = total_size
.map(|n| n as usize)
.unwrap_or(TRACK_STREAM_MIN_BUF_CAPACITY)
.clamp(TRACK_STREAM_MIN_BUF_CAPACITY, TRACK_STREAM_MAX_BUF_CAPACITY);
let rb = HeapRb::<u8>::new(buffer_cap);
let (prod, cons) = rb.split();
let done = Arc::new(AtomicBool::new(false));
tokio::spawn(track_download_task(
gen,
state.generation.clone(),
audio_http_client(&state),
app.clone(),
url.clone(),
response,
prod,
done.clone(),
state.stream_completed_cache.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
cache_id_for_tasks.clone(),
));
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
let reader = AudioStreamReader {
cons: Mutex::new(cons),
new_cons_rx: Mutex::new(new_cons_rx),
deadline: std::time::Instant::now()
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
gen_arc: state.generation.clone(),
gen,
source_tag: "track-stream",
eof_when_empty: Some(done),
pos: 0,
};
PlayInput::Streaming {
reader,
format_hint: stream_hint,
}
}
} else {
let data = fetch_data(&url, &state, gen, &app).await?;
let data = match data {
Some(d) => d,
None => return Ok(()), // superseded while downloading
};
spawn_analysis_seed_from_in_memory_bytes(
&app,
cache_id_for_tasks.as_deref(),
gen,
&state.generation,
&data,
);
PlayInput::Bytes(data)
}
};
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
let cache_loudness = resolve_loudness_gain_from_cache(
&app,
&url,
target_lufs,
logical_trim.as_deref(),
);
let resolved_loudness_gain_db = cache_loudness;
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
let startup_loudness_gain_db = if norm_mode == 2 {
loudness_gain_db_after_resolve(
cache_loudness,
target_lufs,
pre_analysis_db,
false,
loudness_gain_db,
)
} else {
cache_loudness
};
let (gain_linear, effective_volume) = compute_gain(
norm_mode,
replay_gain_db,
replay_gain_peak,
startup_loudness_gain_db,
pre_gain_db,
fallback_db,
volume,
);
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
crate::app_deprintln!(
"[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}",
playback_identity(&url),
normalization_engine_name(norm_mode),
replay_gain_db,
replay_gain_peak,
resolved_loudness_gain_db,
gain_linear,
current_gain_db,
target_lufs,
volume,
effective_volume
);
maybe_emit_normalization_state(
&app,
NormalizationStatePayload {
engine: normalization_engine_name(norm_mode).to_string(),
current_gain_db,
target_lufs,
},
);
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
// Measure how much audio Track A actually has left right now.
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
// have consumed ~500800ms from Track A's tail — so its true remaining time
// is always less than crossfade_secs_val. Using the measured remaining time
// for BOTH fade-out (Track A) and fade-in (Track B) keeps them in sync and
// guarantees Track A reaches 0 exactly when its source exhausts.
let actual_fade_secs: f32 = if crossfade_enabled {
let cur = state.current.lock().unwrap();
let remaining = (cur.duration_secs - cur.position()) as f32;
remaining.clamp(0.1, crossfade_secs_val)
} else {
0.0
};
// Fade-in duration for Track B:
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
// hard cut → 5 ms micro-fade to suppress DC-offset click
let fade_in_dur = if crossfade_enabled {
Duration::from_secs_f32(actual_fade_secs)
} else {
Duration::from_millis(5)
};
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
// Always 0 — no application-level resampling. Rodio handles conversion to
// the output device rate internally; we let every track play at its native rate.
let target_rate: u32 = 0;
let mut new_is_seekable = true;
let built = match play_input {
PlayInput::Bytes(data) => build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint.as_deref(),
hi_res_enabled,
),
PlayInput::SeekableMedia { reader, format_hint, tag } => {
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, format_hint.as_deref(), tag)
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
target_rate,
)
}
PlayInput::Streaming { reader, format_hint } => {
new_is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref(), "track-stream")
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
target_rate,
)
}
}.map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
state.current_is_seekable.store(new_is_seekable, Ordering::SeqCst);
let source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
let output_channels = built.output_channels;
// Store the actual output rate/channels for position calculation.
state.current_sample_rate.store(output_rate, Ordering::Relaxed);
state.current_channels.store(output_channels as u32, Ordering::Relaxed);
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
// ── Stream rate management ────────────────────────────────────────────────
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
// toggle was just turned off mid-session), restore the device
// default rate so playback is no longer at 88.2/96 kHz etc.
// If already at the device default — skip entirely (no IPC, no
// PipeWire reconfigure, no scheduler cost).
{
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let target_rate = if hi_res_enabled {
output_rate // native file rate
} else {
state.device_default_rate // restore device default
};
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
let dev = state.selected_device.lock().unwrap().clone();
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(new_handle) => {
*state.stream_handle.lock().unwrap() = new_handle;
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
// Give PipeWire time to reconfigure at the new rate before
// we open a Sink — only needed for large hi-res quanta.
if hi_res_enabled && target_rate > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
Err(_) => {
crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
}
}
}
}
// Re-check gen: a rapid skip during the settle sleep would have bumped it.
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
}
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
// At sample rates > 48 kHz the hardware quantum is larger and the first
// period demands more decoded frames than at 44.1/48 kHz.
// Strategy: pause the sink before appending so rodio's internal mixer
// decodes into its ring buffer ahead of the hardware. After a short delay
// we resume — the buffer is already full and the hardware gets its frames
// without an underrun on the very first period.
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
let needs_prefill = hi_res_enabled && output_rate > 48_000;
if needs_prefill {
sink.pause();
}
// Gapless OFF: prepend a short silence so tracks are clearly separated.
// Only when this is an auto-advance (near end), not on manual skip.
//
// Use a frame-aligned `SamplesBuffer` rather than `Zero + take_duration` —
// the latter computes its sample count via integer-nanosecond division
// (1_000_000_000 / (sr * ch)), which at common rates leaks an odd number
// of samples (e.g. 44103 at 44.1 kHz / 2 ch / 500 ms = 22051.5 frames).
// The half-frame leak shifts the next source's L/R parity in the device
// stream and can manifest as a dead channel for the rest of the track
// (reported by users for natural-end-without-gapless transitions only).
if !gapless {
let cur_pos = {
let cur = state.current.lock().unwrap();
cur.position()
};
let cur_dur = {
let cur = state.current.lock().unwrap();
cur.duration_secs
};
let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0;
if is_auto_advance {
let ch = source.channels();
let sr = source.sample_rate();
// 500 ms in whole frames, then expand to interleaved samples.
let frames = (sr.get() / 2) as usize;
let total_samples = frames.saturating_mul(ch.get() as usize);
let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]);
sink.append(silence);
}
}
sink.append(source);
if needs_prefill {
// 500 ms lets rodio decode several seconds of hi-res audio into its
// internal buffer while the sink is paused. The hardware sees no gap
// because the output is held — it only starts draining after sink.play().
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size,
// absorbing scheduler jitter and PipeWire graph wake-up latency.
tokio::time::sleep(Duration::from_millis(500)).await;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
sink.play();
}
// Atomically swap sinks — extract old sink + its fade-out trigger.
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(built.fadeout_trigger);
cur.fadeout_samples = Some(built.fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
// Handle old sink: symmetric crossfade or immediate stop.
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
app.emit("audio:playing", duration_secs).ok();
// ── Progress + ended detection ────────────────────────────────────────────
spawn_progress_task(
gen,
state.generation.clone(),
state.current.clone(),
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
done_flag,
app,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
state.current_playback_url.clone(),
);
Ok(())
}
/// Proactively appends the next track to the current Sink ~30 s before the
/// current track ends. Called from JS at the same trigger point as preload.
///
/// Because this runs well before the track boundary, the IPC round-trip is
/// irrelevant — by the time the current track actually ends, the next source
/// is already live in the Sink queue and rodio transitions at sample accuracy.
///
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
/// immediately without touching the Sink (pure no-op on the audio path).
#[tauri::command]
pub async fn audio_chain_preload(
url: String,
volume: f32,
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
hi_res_enabled: bool,
analysis_track_id: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Idempotent: already chained this track → nothing to do.
{
let chained = state.chained_info.lock().unwrap();
if chained.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
return Ok(());
}
}
// Gapless must be enabled and a sink must exist.
if !state.gapless_enabled.load(Ordering::Relaxed) {
return Ok(());
}
let snapshot_gen = state.generation.load(Ordering::SeqCst);
// Fetch bytes — use preload cache if available, otherwise HTTP.
let data: Vec<u8> = {
let cached = {
let mut preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
preloaded.take().map(|p| p.data)
} else {
None
}
};
if let Some(d) = cached {
d
} else {
if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = audio_http_client(&state).get(&url).send().await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
}
let hint = resp.content_length().unwrap_or(0) as usize;
let mut stream = resp.bytes_stream();
let mut buf = Vec::with_capacity(hint);
while let Some(chunk) = stream.next().await {
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(()); // superseded by manual skip — abort download
}
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
}
buf
}
}
};
// Bail if the user skipped to a different track while we were downloading.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(());
}
let raw_bytes = Arc::new(data);
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
// the still-playing current source). Volume for the chained track is
// applied at the gapless transition in `spawn_progress_task`, not here.
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
let chain_loudness_db = if norm_mode == 2 {
loudness_gain_db_or_startup(
&app,
&url,
target_lufs,
logical_trim.as_deref(),
pre_analysis_db,
false,
loudness_gain_db,
)
} else {
resolve_loudness_gain_from_cache(&app, &url, target_lufs, logical_trim.as_deref())
};
let (gain_linear, _effective_volume) = compute_gain(
norm_mode,
replay_gain_db,
replay_gain_peak,
chain_loudness_db,
pre_gain_db,
fallback_db,
volume,
);
let done_next = Arc::new(AtomicBool::new(false));
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
// Always 0 — no application-level resampling (same as audio_play).
let target_rate: u32 = 0;
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
let built = build_source(
(*raw_bytes).clone(),
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
format_hint.as_deref(),
hi_res_enabled,
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
// Final gen check — reject if a manual skip happened during decode.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(());
}
// In hi-res mode: if the next track's native rate differs from the current
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
// with a stream re-open. Store raw bytes to avoid re-downloading.
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
// chain proceeds and rodio resamples internally — no bail needed.
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
crate::app_eprintln!(
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
next_rate, stream_rate
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
// Append to the existing Sink. The audio hardware stream never stalls.
// Note: `set_volume` is deliberately NOT called here (see comment above).
{
let cur = state.current.lock().unwrap();
match &cur.sink {
Some(sink) => {
sink.append(source);
}
None => return Ok(()), // playback stopped — bail
}
}
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
url,
raw_bytes,
duration_secs,
replay_gain_linear: gain_linear,
base_volume: volume.clamp(0.0, 1.0),
source_done: done_next,
sample_counter: chain_counter,
});
Ok(())
}
/// Spawns the per-generation progress + ended-detection task.
///
/// The task owns a local `done: Arc<AtomicBool>` reference that starts as
/// the current track's done flag. When the progress task detects that the
/// done flag is set AND `chained_info` has data, it swaps `done` to the
/// chained source's flag and transitions state — all without creating a new
/// task or changing the generation counter.
///
/// Key changes from the previous implementation:
/// • 100 ms tick (was 500 ms) — halves worst-case event latency
/// • Position from atomic sample counter (no wall-clock drift)
/// • Immediate `audio:track_switched` event at decoder boundary
/// • `audio:ended` only fires when no chained successor exists
pub(super) fn spawn_progress_task(
gen: u64,
gen_counter: Arc<AtomicU64>,
current_arc: Arc<Mutex<AudioCurrent>>,
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
crossfade_enabled_arc: Arc<AtomicBool>,
crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>,
app: AppHandle,
samples_played: Arc<AtomicU64>,
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
gapless_switch_at: Arc<AtomicU64>,
current_playback_url: Arc<Mutex<Option<String>>>,
) {
// Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on
// Linux; mirrors the quantum policy used for stream open/reopen plus a small
// scheduler/mixer cushion so the UI doesn't run ahead. Other platforms have
// their own latency reporting paths and don't need the compensation here.
#[cfg(target_os = "linux")]
fn estimated_output_latency_secs(sample_rate_hz: f64) -> f64 {
let rate = sample_rate_hz.max(1.0);
let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 };
(frames / rate) + 0.012
}
#[cfg(not(target_os = "linux"))]
fn estimated_output_latency_secs(_sample_rate_hz: f64) -> f64 {
0.0
}
// Keep near-end detection at 100 ms, but throttle progress IPC to webview.
const PROGRESS_EMIT_MIN_MS: u64 = 1500;
const PROGRESS_EMIT_MIN_DELTA_SECS: f64 = 0.9;
tokio::spawn(async move {
let mut near_end_ticks: u32 = 0;
// Local done-flag reference; swapped on gapless transition.
let mut current_done = initial_done;
// Local sample counter; swapped to chained source's counter on transition.
let mut samples_played = samples_played;
let mut last_progress_emit_at = Instant::now() - Duration::from_millis(PROGRESS_EMIT_MIN_MS);
let mut last_progress_emit_pos = -1.0f64;
let mut last_progress_emit_paused = false;
loop {
// 100 ms tick keeps near-end detection timely for crossfade/gapless
// handoff while frontend still interpolates smoothly via rAF.
tokio::time::sleep(Duration::from_millis(100)).await;
if gen_counter.load(Ordering::SeqCst) != gen {
break;
}
// ── Gapless transition detection ─────────────────────────────────
// If the current source is exhausted AND we have a chained track
// ready, transition seamlessly: swap tracking state, emit
// audio:track_switched for the new track, and continue the loop.
if current_done.load(Ordering::SeqCst) {
// Radio (dur == 0): stream exhausted / connection dropped → stop.
let cur_dur = current_arc.lock().unwrap().duration_secs;
if cur_dur <= 0.0 {
crate::app_eprintln!("[radio] current_done fired → emitting audio:ended (dur=0)");
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
}
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
// Swap to the chained source's done flag.
current_done = info.source_done;
// Swap to the chained source's sample counter.
// The chained CountingSource increments its own Arc,
// so we must rebind our local reference to it —
// a one-time value copy would go stale immediately.
samples_played = info.sample_counter;
// Update tracking state and apply the chained track's
// effective volume. Deferred from `audio_chain_preload`
// (which runs ~30 s before the current track ends) to
// avoid changing loudness of the still-playing current
// track. `Sink::set_volume` affects the whole Sink, so it
// must only be called at the boundary, not at preload.
{
let mut cur = current_arc.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.replay_gain_linear = info.replay_gain_linear;
cur.base_volume = info.base_volume;
cur.duration_secs = info.duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
if let Some(sink) = &cur.sink {
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
}
}
*current_playback_url.lock().unwrap() = Some(info.url.clone());
// Record the gapless switch timestamp for ghost-command guard.
let switch_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
gapless_switch_at.store(switch_ts, Ordering::SeqCst);
// Emit the new track_switched event — this is immediate,
// not delayed by 500 ms like the old audio:playing was.
app.emit("audio:track_switched", info.duration_secs).ok();
near_end_ticks = 0;
continue;
}
// Current source exhausted but no chain queued — the Sink is
// likely draining; audio:ended will fire on the next tick via
// the near-end logic below.
}
// ── Position from atomic sample counter ──────────────────────────
let rate = sample_rate_arc.load(Ordering::Relaxed) as f64;
let ch = channels_arc.load(Ordering::Relaxed) as f64;
let samples = samples_played.load(Ordering::Relaxed) as f64;
let divisor = (rate * ch).max(1.0);
// Read playback snapshot under a single lock to minimize contention
// with seek/play/pause commands that also touch `current`.
let (dur, paused_at) = {
let cur = current_arc.lock().unwrap();
(cur.duration_secs, cur.paused_at)
};
let is_paused = paused_at.is_some();
let pos_raw = if let Some(p) = paused_at {
p
} else {
(samples / divisor).min(dur.max(0.001))
};
let progress_latency = if is_paused {
0.0
} else {
estimated_output_latency_secs(rate)
};
let pos = (pos_raw - progress_latency).max(0.0);
let now = Instant::now();
let should_emit_progress = if is_paused != last_progress_emit_paused {
true
} else if now.duration_since(last_progress_emit_at) >= Duration::from_millis(PROGRESS_EMIT_MIN_MS) {
true
} else {
(pos - last_progress_emit_pos).abs() >= PROGRESS_EMIT_MIN_DELTA_SECS
};
if should_emit_progress {
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
last_progress_emit_at = now;
last_progress_emit_pos = pos;
last_progress_emit_paused = is_paused;
}
if is_paused {
continue;
}
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
if dur > end_threshold && pos_raw >= dur - end_threshold {
near_end_ticks += 1;
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
if near_end_ticks >= 10 {
// If a gapless chain is pending, the source hasn't
// exhausted yet — duration_hint (integer seconds from
// Subsonic) is shorter than the actual audio content.
// Don't emit audio:ended; let the gapless transition
// handle it when current_done fires.
let has_chain = chained_arc.lock().unwrap().is_some();
if has_chain {
continue;
}
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
}
} else {
near_end_ticks = 0;
}
}
});
}
#[tauri::command]
pub async fn audio_preload(
url: String,
duration_hint: f64,
analysis_track_id: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
{
let preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
let _ = app.emit("audio:preload-ready", url.clone());
return Ok(());
}
}
// Throttle: wait 8 s before starting the background download so it does not
// compete with the decode + sink-feed work of the just-started current track.
// If the user skips during the wait the generation counter changes and we abort.
let gen_snapshot = state.generation.load(Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
return Ok(());
}
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
response.bytes().await.map_err(|e| e.to_string())?.into()
};
let _ = duration_hint; // kept in API for compatibility
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
crate::app_deprintln!(
"[stream] audio_preload: bytes ready track_id={} size_mib={:.2} — invoking full-track analysis",
track_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_track_id_is_current_playback(&state, &track_id);
if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
}
}
let url_for_emit = url.clone();
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
let _ = app.emit("audio:preload-ready", url_for_emit);
Ok(())
}