mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor: extract psysonic-audio crate (M3/7)
Moves all audio playback code (Symphonia decode, rodio output, HTTP
streaming, gapless, previews, and the seven stream/ source-type
submodules from the prior split) out of the top crate into a new
psysonic-audio crate.
crates/psysonic-audio/ new lib crate, depends on
psysonic-core + psysonic-analysis
src/{engine,helpers,decode,…}.rs flattened layout (no more
extra audio/ namespace level)
src/stream/ seven submodules from M0
src/lib.rs re-exports macros from
psysonic-core and the public
API surface
The audio↔analysis edges identified in the dep survey are now real
crate deps (audio depends on analysis directly: AnalysisCache reads,
recommended_gain_for_target, submit_analysis_cpu_seed). Only the
analysis→audio back-edge goes through the PlaybackQueryHandle port
registered in M2.
Cross-crate ref migrations applied via batch sed:
crate::audio::* → crate::* (intra-crate)
crate::analysis_cache::* → psysonic_analysis::analysis_cache::*
crate::submit_analysis_cpu_seed → psysonic_analysis::analysis_runtime::*
crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
Top crate keeps `crate::audio::*` paths working via
`pub use psysonic_audio as audio;` — lib_commands/cli callers untouched.
`stop_audio_engine` (mac process-exit cleanup) moved into the audio
crate as `pub fn stop_audio_engine` since it reaches AudioEngine
internals; tray.rs now re-exports the moved fn.
Two small visibility promotions in engine.rs:
pub(crate) fn analysis_track_id_is_current_playback → pub
pub(crate) fn ranged_loudness_backfill_should_defer → pub
Behaviour preserving. Cargo check + clippy --workspace clean.
This commit is contained in:
@@ -1,52 +0,0 @@
|
||||
//! AutoEQ proxy commands: fetch entry list and FixedBandEQ profiles from
|
||||
//! GitHub via Rust to bypass WebView CORS.
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
|
||||
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
|
||||
#[tauri::command]
|
||||
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
|
||||
audio_http_client(&state)
|
||||
.get("https://autoeq.app/entries")
|
||||
.send().await.map_err(|e| e.to_string())?
|
||||
.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
|
||||
///
|
||||
/// Directory layout in the AutoEQ repo:
|
||||
/// results/{source}/{form}/{name}/{name} FixedBandEQ.txt (most sources)
|
||||
/// results/{source}/{rig} {form}/{name}/{name} FixedBandEQ.txt (crinacle — rig-prefixed dir)
|
||||
///
|
||||
/// We try the rig-prefixed path first (when rig is present), then fall back to form-only.
|
||||
#[tauri::command]
|
||||
pub async fn autoeq_fetch_profile(
|
||||
name: String,
|
||||
source: String,
|
||||
rig: Option<String>,
|
||||
form: String,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<String, String> {
|
||||
let base = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results";
|
||||
let filename = format!("{} FixedBandEQ.txt", name);
|
||||
|
||||
let candidates: Vec<String> = if let Some(ref r) = rig {
|
||||
vec![
|
||||
format!("{}/{}/{} {}/{}/{}", base, source, r, form, name, filename),
|
||||
format!("{}/{}/{}/{}/{}", base, source, form, name, filename),
|
||||
]
|
||||
} else {
|
||||
vec![format!("{}/{}/{}/{}/{}", base, source, form, name, filename)]
|
||||
};
|
||||
|
||||
for url in &candidates {
|
||||
let resp = audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
|
||||
if resp.status().is_success() {
|
||||
return resp.text().await.map_err(|e| e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("FixedBandEQ profile not found for '{}'", name))
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//! Symphonia codec registry (incl. Opus) and radio decoder factory.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
|
||||
|
||||
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
|
||||
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut registry = CodecRegistry::new();
|
||||
symphonia::default::register_enabled_codecs(&mut registry);
|
||||
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn try_make_radio_decoder(
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
opts: &DecoderOptions,
|
||||
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
|
||||
psysonic_codec_registry().make(params, opts)
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
//! 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, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use rodio::Player;
|
||||
use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::build_source;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{
|
||||
build_source_from_play_input, select_play_input, swap_in_new_sink, url_format_hint,
|
||||
PlayInputContext, SinkSwapInputs,
|
||||
};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
|
||||
// ─── 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);
|
||||
|
||||
let format_hint = url_format_hint(&url);
|
||||
|
||||
let play_input = match select_play_input(
|
||||
PlayInputContext {
|
||||
url: &url,
|
||||
gen,
|
||||
duration_hint,
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
format_hint: format_hint.as_deref(),
|
||||
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
|
||||
reuse_chained_bytes,
|
||||
},
|
||||
&state,
|
||||
&app,
|
||||
).await? {
|
||||
Some(input) => input,
|
||||
None => {
|
||||
crate::app_deprintln!(
|
||||
"[audio] audio_play superseded inside select_play_input: gen={} cur={} track_id={:?}",
|
||||
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
crate::app_deprintln!(
|
||||
"[audio] audio_play superseded after select_play_input: gen={} cur={} track_id={:?}",
|
||||
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
|
||||
let (gain_linear, effective_volume) = compute_gain(
|
||||
gain_inputs.norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.effective_loudness_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(gain_inputs.norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.cache_loudness_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
gain_inputs.target_lufs,
|
||||
volume,
|
||||
effective_volume
|
||||
);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(gain_inputs.norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs: gain_inputs.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 ~500–800ms 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);
|
||||
let playback_source = build_source_from_play_input(
|
||||
play_input,
|
||||
&state,
|
||||
format_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Suppress the audio:error toast when this play was already superseded
|
||||
// by a newer audio_play (rapid skip): the failure is the inevitable
|
||||
// Ok(0)/EOF from RangedHttpSource after gen-bump, not a real codec
|
||||
// problem. The frontend would otherwise show "Couldn't play track" for
|
||||
// the abandoned URL while a new track is already loading.
|
||||
if state.generation.load(Ordering::SeqCst) == gen {
|
||||
app.emit("audio:error", &e).ok();
|
||||
} else {
|
||||
crate::app_deprintln!(
|
||||
"[audio] suppressed audio:error for superseded play (gen={} cur={}): {}",
|
||||
gen, state.generation.load(Ordering::SeqCst), e
|
||||
);
|
||||
}
|
||||
e
|
||||
})?;
|
||||
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
|
||||
let built = playback_source.built;
|
||||
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();
|
||||
}
|
||||
|
||||
swap_in_new_sink(&state, SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: built.fadeout_trigger,
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
});
|
||||
|
||||
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 gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
|
||||
let (gain_linear, _effective_volume) = compute_gain(
|
||||
gain_inputs.norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.effective_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(())
|
||||
}
|
||||
|
||||
@@ -1,701 +0,0 @@
|
||||
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::source::UniformSourceIterator;
|
||||
use rodio::Source;
|
||||
use symphonia::core::{
|
||||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||||
meta::MetadataOptions,
|
||||
probe::Hint,
|
||||
units::{self, Time},
|
||||
};
|
||||
|
||||
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
|
||||
use super::sources::*;
|
||||
|
||||
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
|
||||
//
|
||||
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
|
||||
// byte_len() → None. This tells symphonia "stream length unknown", which
|
||||
// prevents the FLAC demuxer from seeking (it validates seek offsets against
|
||||
// the total stream length from byte_len). MP3 is unaffected because its
|
||||
// demuxer uses Xing/LAME headers instead.
|
||||
//
|
||||
// This wrapper provides the actual byte length, fixing seek for all formats.
|
||||
|
||||
pub(crate) struct SizedCursorSource {
|
||||
inner: Cursor<Vec<u8>>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl Read for SizedCursorSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.inner.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for SizedCursorSource {
|
||||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for SizedCursorSource {
|
||||
fn is_seekable(&self) -> bool { true }
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||
}
|
||||
|
||||
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
|
||||
//
|
||||
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
|
||||
// (byte_len = None). This constructs the symphonia pipeline directly,
|
||||
// providing the correct byte_len via SizedCursorSource.
|
||||
//
|
||||
// Implements Iterator<Item = i16> + Source — identical interface to
|
||||
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||||
|
||||
/// Debug logging: codec parameters in human-readable form to verify whether
|
||||
/// playback is genuinely lossless.
|
||||
pub(crate) fn log_codec_resolution(
|
||||
tag: &str,
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
container_hint: Option<&str>,
|
||||
) {
|
||||
let codec_name = symphonia::default::get_codecs()
|
||||
.get_codec(params.codec)
|
||||
.map(|d| d.short_name)
|
||||
.unwrap_or("?");
|
||||
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
|
||||
let bits = params.bits_per_sample
|
||||
.or(params.bits_per_coded_sample)
|
||||
.map(|b| format!("{}-bit", b))
|
||||
.unwrap_or_else(|| "?-bit".into());
|
||||
let ch = params.channels
|
||||
.map(|c| format!("{}ch", c.count()))
|
||||
.unwrap_or_else(|| "?ch".into());
|
||||
let lossless = codec_name.starts_with("pcm")
|
||||
|| matches!(
|
||||
codec_name,
|
||||
"flac" | "alac" | "wavpack" | "monkeys-audio" | "tta" | "shorten"
|
||||
);
|
||||
let kind = if lossless { "LOSSLESS" } else { "lossy" };
|
||||
crate::app_deprintln!(
|
||||
"[stream] {tag}: codec={codec_name} ({kind}) {bits} {rate} {ch} container={}",
|
||||
container_hint.unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
/// Max retries for IO/packet-read errors (fatal — network drop, truncated file).
|
||||
const DECODE_MAX_RETRIES: usize = 3;
|
||||
/// Max *consecutive* DecodeErrors before giving up on a file.
|
||||
/// Non-fatal errors like "invalid main_data offset" are silently dropped up to
|
||||
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
|
||||
/// playable track (VLC-style frame dropping).
|
||||
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
|
||||
|
||||
pub(crate) struct SizedDecoder {
|
||||
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||||
current_frame_offset: usize,
|
||||
format: Box<dyn FormatReader>,
|
||||
total_duration: Option<Time>,
|
||||
buffer: SampleBuffer<f32>,
|
||||
spec: SignalSpec,
|
||||
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
|
||||
/// successfully decoded frame. Used to detect fully undecodable streams.
|
||||
consecutive_decode_errors: usize,
|
||||
}
|
||||
|
||||
impl SizedDecoder {
|
||||
pub(crate) fn new(data: Vec<u8>, format_hint: Option<&str>, hi_res: bool) -> Result<Self, String> {
|
||||
let data_len = data.len() as u64;
|
||||
let source = SizedCursorSource {
|
||||
inner: Cursor::new(data),
|
||||
len: data_len,
|
||||
};
|
||||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||||
// and compete with the playback thread at track start.
|
||||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||||
let mss = MediaSourceStream::new(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||||
);
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let format_opts = FormatOptions {
|
||||
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
|
||||
// present in older iTunes-purchased M4A files.
|
||||
enable_gapless: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let meta_opts = symphonia::core::meta::MetadataOptions {
|
||||
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
|
||||
// iTunes M4A files don't choke the parser.
|
||||
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &meta_opts)
|
||||
.map_err(|e| {
|
||||
let hint_str = format_hint.unwrap_or("unknown");
|
||||
// Always print the raw Symphonia error to the terminal for diagnosis.
|
||||
crate::app_eprintln!("[psysonic] probe failed (hint={hint_str}): {e}");
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
format!("unsupported format: .{hint_str} files cannot be played (no demuxer)")
|
||||
} else {
|
||||
format!("could not open audio stream (.{hint_str}): {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let track = probed.format
|
||||
.tracks()
|
||||
.iter()
|
||||
// Explicitly select only audio tracks: must have a valid codec and a
|
||||
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
|
||||
// files embed as a secondary video track.
|
||||
.find(|t| {
|
||||
t.codec_params.codec != CODEC_TYPE_NULL
|
||||
&& t.codec_params.sample_rate.is_some()
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
|
||||
"no playable audio track found in file".to_string()
|
||||
})?;
|
||||
|
||||
let track_id = track.id;
|
||||
let total_duration = track.codec_params.time_base
|
||||
.zip(track.codec_params.n_frames)
|
||||
.map(|(base, frames)| base.calc_time(frames));
|
||||
|
||||
log_codec_resolution("bytes", &track.codec_params, format_hint);
|
||||
|
||||
let mut decoder = psysonic_codec_registry()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| {
|
||||
crate::app_eprintln!("[psysonic] codec init failed: {e}");
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
"unsupported codec: no decoder available for this audio format".to_string()
|
||||
} else {
|
||||
format!("failed to initialise audio decoder: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
// Decode the first packet to initialise spec + buffer.
|
||||
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
|
||||
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
|
||||
let mut decode_errors: usize = 0;
|
||||
let decoded = loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||
break decoder.last_decoded();
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] next_packet error: {e}");
|
||||
return Err(format!("could not read audio data: {e}"));
|
||||
}
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
|
||||
continue;
|
||||
}
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => break decoded,
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
decode_errors += 1;
|
||||
crate::app_eprintln!("[psysonic] init: dropped corrupt frame #{decode_errors}: {msg}");
|
||||
if decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err("too many consecutive decode errors during init — file may be corrupt".into());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] fatal decode error: {e}");
|
||||
return Err(format!("audio decode error: {e}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let spec = decoded.spec().to_owned();
|
||||
let buffer = Self::make_buffer(decoded, &spec);
|
||||
|
||||
Ok(SizedDecoder {
|
||||
decoder,
|
||||
current_frame_offset: 0,
|
||||
format,
|
||||
total_duration,
|
||||
buffer,
|
||||
spec,
|
||||
consecutive_decode_errors: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
pub(crate) fn new_streaming(
|
||||
media: Box<dyn MediaSource>,
|
||||
format_hint: Option<&str>,
|
||||
source_tag: &str,
|
||||
) -> Result<Self, String> {
|
||||
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
|
||||
// read() call frequency into the ring buffer, easing I/O spikes.
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint { hint.with_extension(ext); }
|
||||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
|
||||
|
||||
let track = probed.format.tracks().iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
||||
let track_id = track.id;
|
||||
log_codec_resolution(source_tag, &track.codec_params, format_hint);
|
||||
// Live streams have no known total frame count → total_duration = None.
|
||||
let total_duration = None;
|
||||
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
|
||||
let mut format = probed.format;
|
||||
|
||||
let mut errors = 0usize;
|
||||
let decoded = loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(_) => break decoder.last_decoded(),
|
||||
};
|
||||
if packet.track_id() != track_id { continue; }
|
||||
match decoder.decode(&packet) {
|
||||
Ok(d) => break d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
errors += 1;
|
||||
crate::app_eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}");
|
||||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
|
||||
}
|
||||
};
|
||||
let spec = decoded.spec().to_owned();
|
||||
let buffer = Self::make_buffer(decoded, &spec);
|
||||
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
|
||||
let duration = units::Duration::from(decoded.capacity() as u64);
|
||||
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
|
||||
buffer.copy_interleaved_ref(decoded);
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Refine position after a coarse seek — decode packets until we reach the
|
||||
/// exact requested timestamp.
|
||||
fn refine_position(
|
||||
&mut self,
|
||||
seek_res: symphonia::core::formats::SeekedTo,
|
||||
) -> Result<(), String> {
|
||||
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
|
||||
let packet = loop {
|
||||
let candidate = self.format.next_packet()
|
||||
.map_err(|e| format!("refine seek: {e}"))?;
|
||||
if candidate.dur() > samples_to_pass {
|
||||
break candidate;
|
||||
}
|
||||
samples_to_pass -= candidate.dur();
|
||||
};
|
||||
|
||||
let mut decoded = self.decoder.decode(&packet);
|
||||
for _ in 0..DECODE_MAX_RETRIES {
|
||||
if decoded.is_err() {
|
||||
let p = self.format.next_packet()
|
||||
.map_err(|e| format!("refine retry: {e}"))?;
|
||||
decoded = self.decoder.decode(&p);
|
||||
}
|
||||
}
|
||||
|
||||
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SizedDecoder {
|
||||
type Item = f32;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.current_frame_offset >= self.buffer.len() {
|
||||
// Loop until a decodable packet is found or the stream ends.
|
||||
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
|
||||
// drop the frame and advance to the next packet. IO errors and a
|
||||
// clean end-of-stream both terminate the iterator normally.
|
||||
loop {
|
||||
let packet = self.format.next_packet().ok()?;
|
||||
match self.decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
self.consecutive_decode_errors = 0;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = 0;
|
||||
break;
|
||||
}
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
#[cfg(not(debug_assertions))]
|
||||
let _ = msg;
|
||||
self.consecutive_decode_errors += 1;
|
||||
// Log sparingly: first drop, then every 10th to avoid spam.
|
||||
if self.consecutive_decode_errors == 1
|
||||
|| self.consecutive_decode_errors % 10 == 0
|
||||
{
|
||||
crate::app_deprintln!(
|
||||
"[psysonic] dropped corrupt frame #{}: {msg}",
|
||||
self.consecutive_decode_errors
|
||||
);
|
||||
}
|
||||
if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
crate::app_deprintln!(
|
||||
"[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \
|
||||
failures — stream appears unrecoverable, stopping"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// continue → fetch next packet
|
||||
}
|
||||
Err(_) => return None, // IO error or fatal codec error → end of stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||||
self.current_frame_offset += 1;
|
||||
Some(sample)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SizedDecoder {
|
||||
#[inline]
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.buffer.samples().len())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn channels(&self) -> rodio::ChannelCount {
|
||||
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
|
||||
.unwrap_or(std::num::NonZeroU16::MIN)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_rate(&self) -> rodio::SampleRate {
|
||||
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.total_duration.map(|Time { seconds, frac }| {
|
||||
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
|
||||
})
|
||||
}
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
let seek_beyond_end = self
|
||||
.total_duration()
|
||||
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
|
||||
|
||||
let time: Time = if seek_beyond_end {
|
||||
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
|
||||
// Step back a tiny bit — some demuxers can't seek to the exact end.
|
||||
let mut secs = t.seconds;
|
||||
let mut frac = t.frac - 0.0001;
|
||||
if frac < 0.0 {
|
||||
secs = secs.saturating_sub(1);
|
||||
frac = 1.0 - frac;
|
||||
}
|
||||
Time { seconds: secs, frac }
|
||||
} else {
|
||||
pos.as_secs_f64().into()
|
||||
};
|
||||
|
||||
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||||
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||||
))?;
|
||||
|
||||
self.refine_position(seek_res)
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e))
|
||||
))?;
|
||||
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
||||
//
|
||||
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
||||
// samples for LAME) and append end-padding to fill the final frame.
|
||||
// iTunes embeds the exact counts in an ID3v2 COMM frame with description
|
||||
// "iTunSMPB". Format: " 00000000 DELAY PADDING TOTAL ..." (space-separated hex)
|
||||
//
|
||||
// Parsing strategy: scan raw bytes for the ASCII marker, then extract the
|
||||
// first whitespace-separated hex tokens after it.
|
||||
|
||||
pub(crate) struct GaplessInfo {
|
||||
delay_samples: u64,
|
||||
total_valid_samples: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for GaplessInfo {
|
||||
fn default() -> Self {
|
||||
Self { delay_samples: 0, total_valid_samples: None }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_subsequence(data: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
data.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
||||
let pos = match find_subsequence(data, b"iTunSMPB") {
|
||||
Some(p) => p,
|
||||
None => return GaplessInfo::default(),
|
||||
};
|
||||
|
||||
// In M4A/iTunes files the key is followed by a binary 'data' atom header
|
||||
// (16 bytes: size[4] + "data"[4] + type_flags[4] + locale[4]) before the
|
||||
// actual value string. Search for the " 00000000 " sentinel that every
|
||||
// iTunSMPB value starts with to locate the true start of the text.
|
||||
let search_end = data.len().min(pos + 8 + 128);
|
||||
let search_window = &data[pos + 8..search_end];
|
||||
let value_start = find_subsequence(search_window, b" 00000000 ")
|
||||
.map(|off| pos + 8 + off)
|
||||
.unwrap_or(pos + 8);
|
||||
|
||||
let tail = &data[value_start..data.len().min(value_start + 256)];
|
||||
let text: String = tail.iter()
|
||||
.map(|&b| b as char)
|
||||
.filter(|c| c.is_ascii_hexdigit() || *c == ' ')
|
||||
.collect();
|
||||
|
||||
let parts: Vec<&str> = text.split_whitespace().collect();
|
||||
// parts[0] = "00000000", parts[1] = delay, parts[2] = padding, parts[3] = total
|
||||
if parts.len() < 3 {
|
||||
return GaplessInfo::default();
|
||||
}
|
||||
let delay = u64::from_str_radix(parts.get(1).unwrap_or(&"0"), 16).unwrap_or(0);
|
||||
let padding = u64::from_str_radix(parts.get(2).unwrap_or(&"0"), 16).unwrap_or(0);
|
||||
let total_raw = parts.get(3).and_then(|s| u64::from_str_radix(s, 16).ok());
|
||||
|
||||
let total_valid = total_raw.map(|t| t).filter(|&t| t > 0).or_else(|| {
|
||||
// Derive from delay + padding if total not available:
|
||||
// Not possible without knowing total encoded samples, so just use None.
|
||||
let _ = padding;
|
||||
None
|
||||
});
|
||||
|
||||
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
|
||||
}
|
||||
|
||||
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
|
||||
pub(crate) struct BuiltSource {
|
||||
pub(crate) source: PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) output_rate: u32,
|
||||
pub(crate) output_channels: u16,
|
||||
/// Trigger for the sample-level crossfade fade-out.
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
/// Total samples for the fade-out (set before triggering).
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Build a fully-prepared playback source:
|
||||
/// decode → trim → resample → EQ → fade-in → triggered-fade-out → notify → count
|
||||
///
|
||||
/// `fade_in_dur`:
|
||||
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
|
||||
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
|
||||
/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
|
||||
///
|
||||
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
|
||||
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
|
||||
/// `format_hint`: optional file extension (e.g. "flac", "mp3") to help symphonia probe.
|
||||
pub(crate) fn build_source(
|
||||
data: Vec<u8>,
|
||||
duration_hint: f64,
|
||||
eq_gains: Arc<[AtomicU32; 10]>,
|
||||
eq_enabled: Arc<AtomicBool>,
|
||||
eq_pre_gain: Arc<AtomicU32>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
target_rate: u32,
|
||||
format_hint: Option<&str>,
|
||||
hi_res: bool,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let gapless = parse_gapless_info(&data);
|
||||
|
||||
let decoder = SizedDecoder::new(data, format_hint, hi_res)?;
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
|
||||
// Determine effective duration.
|
||||
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
|
||||
let effective_dur = if duration_hint > 1.0 {
|
||||
duration_hint
|
||||
} else {
|
||||
decoder.total_duration()
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(duration_hint)
|
||||
};
|
||||
|
||||
// Apply encoder-delay trim and optional end-padding trim,
|
||||
// then resample to the canonical target rate if needed.
|
||||
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
|
||||
let delay_dur = Duration::from_secs_f64(
|
||||
gapless.delay_samples as f64 / sample_rate.get() as f64
|
||||
);
|
||||
let base = decoder.skip_duration(delay_dur);
|
||||
|
||||
if let Some(total) = gapless.total_valid_samples {
|
||||
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate.get() as f64);
|
||||
let trimmed = base.take_duration(valid_dur);
|
||||
if target_rate > 0 && sample_rate.get() != target_rate {
|
||||
DynSource::new(UniformSourceIterator::new(
|
||||
trimmed,
|
||||
channels,
|
||||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||
))
|
||||
} else {
|
||||
DynSource::new(trimmed)
|
||||
}
|
||||
} else {
|
||||
if target_rate > 0 && sample_rate.get() != target_rate {
|
||||
DynSource::new(UniformSourceIterator::new(
|
||||
base,
|
||||
channels,
|
||||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||
))
|
||||
} else {
|
||||
DynSource::new(base)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let converted = decoder;
|
||||
if target_rate > 0 && sample_rate.get() != target_rate {
|
||||
DynSource::new(UniformSourceIterator::new(
|
||||
converted,
|
||||
channels,
|
||||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||
))
|
||||
} else {
|
||||
DynSource::new(converted)
|
||||
}
|
||||
};
|
||||
|
||||
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate { target_rate } else { sample_rate.get() };
|
||||
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
let counting = CountingSource::new(notifying, sample_counter);
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
|
||||
Ok(BuiltSource {
|
||||
source: boosted,
|
||||
duration_secs: effective_dur,
|
||||
output_rate,
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
fadeout_samples,
|
||||
})
|
||||
}
|
||||
|
||||
/// Streaming variant of `build_source`: uses a live `SizedDecoder` source
|
||||
/// (non-seekable) and skips iTunSMPB parsing, but preserves the same EQ/fade/
|
||||
/// counting wrappers and output metadata.
|
||||
pub(crate) fn build_streaming_source(
|
||||
decoder: SizedDecoder,
|
||||
duration_hint: f64,
|
||||
eq_gains: Arc<[AtomicU32; 10]>,
|
||||
eq_enabled: Arc<AtomicBool>,
|
||||
eq_pre_gain: Arc<AtomicU32>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
target_rate: u32,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
|
||||
// For streaming starts prefer server-provided duration when available.
|
||||
let effective_dur = if duration_hint > 1.0 {
|
||||
duration_hint
|
||||
} else {
|
||||
decoder
|
||||
.total_duration()
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(duration_hint)
|
||||
};
|
||||
|
||||
let converted = decoder;
|
||||
let dyn_src: DynSource = if target_rate > 0 && sample_rate.get() != target_rate {
|
||||
DynSource::new(UniformSourceIterator::new(
|
||||
converted,
|
||||
channels,
|
||||
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
|
||||
))
|
||||
} else {
|
||||
DynSource::new(converted)
|
||||
};
|
||||
|
||||
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate {
|
||||
target_rate
|
||||
} else {
|
||||
sample_rate.get()
|
||||
};
|
||||
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
let counting = CountingSource::new(notifying, sample_counter);
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
|
||||
Ok(BuiltSource {
|
||||
source: boosted,
|
||||
duration_secs: effective_dur,
|
||||
output_rate,
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
fadeout_samples,
|
||||
})
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//! Output device enumeration with suppressed ALSA stderr noise.
|
||||
#[cfg(unix)]
|
||||
use libc;
|
||||
// `rodio::cpal` is referenced from the included body.
|
||||
|
||||
/// ALSA probes noisy plugins during device queries — suppress stderr on Unix.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::dup2(self.0, 2); libc::close(self.0); }
|
||||
}
|
||||
}
|
||||
let _guard = unsafe {
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
f()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[inline]
|
||||
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
f()
|
||||
}
|
||||
|
||||
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
|
||||
/// busy devices are sometimes omitted from `output_devices()` while playback works.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> {
|
||||
const IFACES: &[&str] = &[
|
||||
"hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40",
|
||||
"surround51", "surround71",
|
||||
];
|
||||
let colon = name.find(':')?;
|
||||
let iface = name[..colon].to_ascii_lowercase();
|
||||
if !IFACES.iter().any(|&i| i == iface.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string();
|
||||
let dev = name
|
||||
.split("DEV=")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
Some((iface, card, dev))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[inline]
|
||||
pub(crate) fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
match (
|
||||
linux_alsa_sink_fingerprint(a),
|
||||
linux_alsa_sink_fingerprint(b),
|
||||
) {
|
||||
(Some(fa), Some(fb)) => fa == fb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
|
||||
pub(crate) fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
|
||||
available
|
||||
.iter()
|
||||
.any(|d| output_devices_logically_same(d, pinned))
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! Tauri commands for output-device listing + selection. Pulled out of
|
||||
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
|
||||
//! enumeration + reopen path.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use super::dev_io::{
|
||||
enumerate_output_device_names, output_devices_logically_same,
|
||||
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
||||
};
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
/// When the saved `selected_device` no longer literally matches any listed
|
||||
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
|
||||
#[tauri::command]
|
||||
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
|
||||
let pinned = state.selected_device.lock().unwrap().clone()?;
|
||||
if pinned.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let list = enumerate_output_device_names();
|
||||
if list.iter().any(|d| d == &pinned) {
|
||||
return None;
|
||||
}
|
||||
let canon = list
|
||||
.iter()
|
||||
.find(|d| output_devices_logically_same(d, &pinned))?
|
||||
.clone();
|
||||
*state.selected_device.lock().unwrap() = Some(canon.clone());
|
||||
Some(canon)
|
||||
}
|
||||
|
||||
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
|
||||
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Returns the names of all available audio output devices on the current host.
|
||||
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
||||
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||
///
|
||||
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
|
||||
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||
#[tauri::command]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
audio_list_devices_for_engine(&state)
|
||||
}
|
||||
|
||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||
#[tauri::command]
|
||||
pub fn audio_default_output_device_name() -> Option<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Switch the audio output device. `device_name = null` → follow system default.
|
||||
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
|
||||
#[tauri::command]
|
||||
pub async fn audio_set_device(
|
||||
device_name: Option<String>,
|
||||
state: State<'_, AudioEngine>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
*state.selected_device.lock().unwrap() = device_name.clone();
|
||||
|
||||
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
state.stream_reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None).ok_or("device open timed out")?;
|
||||
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
|
||||
// Drop active sinks — they were bound to the old stream.
|
||||
if let Some(s) = state.current.lock().unwrap().sink.take() { s.stop(); }
|
||||
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
|
||||
|
||||
app.emit("audio:device-changed", ()).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
//! Poll default output device and pinned-device presence; reopen stream when needed.
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use super::dev_io::output_enumeration_includes_pinned;
|
||||
|
||||
/// What to tell the frontend after a successful stream reopen.
|
||||
pub(crate) enum ReopenNotify {
|
||||
/// Normal path — same as `audio_set_device`.
|
||||
DeviceChanged,
|
||||
/// Pinned device unplugged (Windows/macOS only); Rust cleared the pin — clear Settings + restart playback.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
DeviceReset,
|
||||
}
|
||||
|
||||
/// Opens a new CPAL/rodio output stream with the given rate and device name (same path as
|
||||
/// manual device switch). Used by the device watcher and Windows suspend/resume notifications.
|
||||
pub(crate) async fn reopen_output_stream(
|
||||
app: &tauri::AppHandle,
|
||||
device_name: Option<String>,
|
||||
notify: ReopenNotify,
|
||||
) -> bool {
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||
let stream_handle = engine.stream_handle.clone();
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
if reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let Some(handle) = new_handle else {
|
||||
return false;
|
||||
};
|
||||
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
if let Some(s) = fading_out.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
match notify {
|
||||
ReopenNotify::DeviceChanged => {
|
||||
app.emit("audio:device-changed", ()).ok();
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
ReopenNotify::DeviceReset => {
|
||||
app.emit("audio:device-reset", ()).ok();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let selected_device = engine.selected_device.clone();
|
||||
let samples_played = engine.samples_played.clone();
|
||||
let current = engine.current.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut pinned_miss_count: u32 = 0;
|
||||
// Fallback recovery when OS sleep/resume notifications are missed: if playback is
|
||||
// "running" but sample counter is flat for too long, reopen output stream.
|
||||
// To avoid false positives during normal playback, arm this watchdog only
|
||||
// after a suspiciously long poll gap (e.g. process resumed after sleep).
|
||||
let mut last_samples_seen: u64 = 0;
|
||||
let mut stalled_since: Option<Instant> = None;
|
||||
let mut last_stall_recover_at: Option<Instant> = None;
|
||||
let mut last_poll_at = Instant::now();
|
||||
let mut watchdog_armed_until: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let now = Instant::now();
|
||||
let poll_gap = now.saturating_duration_since(last_poll_at);
|
||||
last_poll_at = now;
|
||||
if poll_gap >= Duration::from_secs(15) {
|
||||
let armed_until = now + Duration::from_secs(120);
|
||||
watchdog_armed_until = Some(armed_until);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: watchdog armed for 120s (poll gap {:?}, likely sleep/resume)",
|
||||
poll_gap
|
||||
);
|
||||
}
|
||||
let watchdog_armed = watchdog_armed_until.is_some_and(|until| now < until);
|
||||
|
||||
// ── Fallback stall detector (works even if sleep/resume signal was missed) ──
|
||||
let mut should_recover_stall = false;
|
||||
let mut stall_for = Duration::ZERO;
|
||||
{
|
||||
let samples_now = samples_played.load(Ordering::Relaxed);
|
||||
let cur = current.lock().unwrap();
|
||||
let active = cur
|
||||
.sink
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_paused() && !s.empty());
|
||||
|
||||
if !watchdog_armed {
|
||||
if stalled_since.take().is_some() {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: watchdog disarmed, clearing stall candidate"
|
||||
);
|
||||
}
|
||||
last_samples_seen = samples_now;
|
||||
} else if !active || samples_now != last_samples_seen {
|
||||
if stalled_since.take().is_some() {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stall candidate cleared (active={active}, samples_delta={})",
|
||||
samples_now as i128 - last_samples_seen as i128
|
||||
);
|
||||
}
|
||||
stalled_since = None;
|
||||
last_samples_seen = samples_now;
|
||||
} else {
|
||||
let since = stalled_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() < Duration::from_millis(100) {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stall candidate started (samples={}, active={active})",
|
||||
samples_now
|
||||
);
|
||||
}
|
||||
stall_for = since.elapsed();
|
||||
let cooldown_ok = last_stall_recover_at
|
||||
.map(|t| t.elapsed() >= Duration::from_secs(20))
|
||||
.unwrap_or(true);
|
||||
if stall_for >= Duration::from_secs(8) && cooldown_ok {
|
||||
should_recover_stall = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_recover_stall {
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let samples_now = samples_played.load(Ordering::Relaxed);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: output stalled for {:?} (samples={}) — reopening stream, pinned={:?}",
|
||||
stall_for,
|
||||
samples_now,
|
||||
pinned
|
||||
);
|
||||
if reopen_output_stream(&app, pinned, ReopenNotify::DeviceChanged).await {
|
||||
last_stall_recover_at = Some(Instant::now());
|
||||
stalled_since = None;
|
||||
last_samples_seen = samples_played.load(Ordering::Relaxed);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stalled-output recovery succeeded"
|
||||
);
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stalled-output reopen timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
|
||||
}
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
let host = rodio::cpal::default_host();
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let available: Vec<String> = host
|
||||
.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list almost always means a transient enumeration failure, not
|
||||
// that every output device vanished. Treating it as "pinned missing"
|
||||
// caused false audio:device-reset (UI jumped back to system default)
|
||||
// when switching to external USB / class-compliant interfaces.
|
||||
if available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
// ALSA/cpal often omit the active HDMI/USB sink from enumeration for the
|
||||
// whole session — any miss counter eventually tripped audio:device-reset.
|
||||
// Clearing the pin is left to the user (Settings → System Default) or
|
||||
// to a future explicit error signal from the output stream.
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 2 (non-Linux): pinned device disappeared from enumeration ─
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if let Some(ref dev_name) = pinned {
|
||||
if !output_enumeration_includes_pinned(&available, dev_name) {
|
||||
pinned_miss_count += 1;
|
||||
if pinned_miss_count < 3 {
|
||||
continue;
|
||||
}
|
||||
crate::app_eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default");
|
||||
pinned_miss_count = 0;
|
||||
*selected_device.lock().unwrap() = None;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let reopened = reopen_output_stream(&app, None, ReopenNotify::DeviceReset).await;
|
||||
if !reopened {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out (pinned disconnect)");
|
||||
}
|
||||
|
||||
last_default = current_default;
|
||||
} else {
|
||||
pinned_miss_count = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 1: no pinned device, system default changed ──────────────
|
||||
if current_default == last_default {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_default = current_default.clone();
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
|
||||
// Debounce: give the OS time to finish configuring the new device.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
//! `AudioEngine` / `AudioCurrent`, stream thread, and HTTP client refresh.
|
||||
#[cfg(unix)]
|
||||
use libc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>,
|
||||
/// Sample rate the output stream was last opened at (updated on every re-open).
|
||||
pub stream_sample_rate: Arc<AtomicU32>,
|
||||
/// The rate the device was opened at on cold start — used to restore the
|
||||
/// stream when Hi-Res is toggled off while a hi-res rate is active.
|
||||
pub device_default_rate: u32,
|
||||
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
|
||||
/// thread to re-open the output device. `device_name = None` → system default.
|
||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>,
|
||||
/// User-selected output device name (None = follow system default).
|
||||
pub selected_device: Arc<Mutex<Option<String>>>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
pub http_client: Arc<RwLock<reqwest::Client>>,
|
||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||
pub eq_enabled: Arc<AtomicBool>,
|
||||
pub eq_pre_gain: Arc<AtomicU32>,
|
||||
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
/// Last fully downloaded manual-stream track bytes (same playback identity),
|
||||
/// used to recover seek/replay without waiting for network again.
|
||||
pub(crate) stream_completed_cache: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
/// True when the currently playing source supports seeking (in-memory bytes
|
||||
/// or `RangedHttpSource`); false for the legacy non-seekable streaming
|
||||
/// fallback (`AudioStreamReader`). `audio_seek` rejects with a "not
|
||||
/// seekable" error when false so the frontend restart-fallback can engage.
|
||||
pub(crate) current_is_seekable: Arc<AtomicBool>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>,
|
||||
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
|
||||
/// When true, audio_play chains sources to the existing Sink instead of
|
||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||
pub gapless_enabled: Arc<AtomicBool>,
|
||||
/// 0=off, 1=replaygain, 2=loudness (future runtime loudness engine).
|
||||
pub normalization_engine: Arc<AtomicU32>,
|
||||
/// Target loudness in LUFS for loudness engine (future use).
|
||||
pub normalization_target_lufs: Arc<AtomicU32>,
|
||||
/// Extra attenuation (dB) when no loudness DB row exists at decode bind; also seeds streaming heuristics (Settings).
|
||||
pub loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
/// Info about the next-up chained track (gapless mode).
|
||||
/// The progress task reads this when `current_source_done` fires.
|
||||
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
/// Atomic sample counter — incremented by CountingSource in the audio thread.
|
||||
/// Progress task reads this for drift-free position tracking.
|
||||
pub samples_played: Arc<AtomicU64>,
|
||||
/// Sample rate of the currently playing source (for samples → seconds).
|
||||
pub current_sample_rate: Arc<AtomicU32>,
|
||||
/// Channel count of the currently playing source.
|
||||
pub current_channels: Arc<AtomicU32>,
|
||||
/// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless
|
||||
/// auto-advance. Commands arriving within 500 ms are rejected as ghost commands.
|
||||
pub gapless_switch_at: Arc<AtomicU64>,
|
||||
/// Active radio session state. None for regular (non-radio) tracks.
|
||||
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
||||
pub(crate) radio_state: Mutex<Option<crate::audio::stream::RadioLiveState>>,
|
||||
/// URL last committed to `AudioCurrent` — used so `audio_update_replay_gain` can
|
||||
/// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null`
|
||||
/// (otherwise `compute_gain` would treat that as unity gain and playback "jumps").
|
||||
pub(crate) current_playback_url: Arc<Mutex<Option<String>>>,
|
||||
/// Subsonic song id last passed from JS with `audio_play` (trimmed). Used
|
||||
/// for loudness/waveform cache when the URL is `psysonic-local://…`.
|
||||
pub(crate) current_analysis_track_id: Arc<Mutex<Option<String>>>,
|
||||
/// While a `RangedHttpSource` download task is filling the buffer for this
|
||||
/// `(track_id, play_generation)`, skip `analysis_enqueue_seed_from_url` for the
|
||||
/// same id — otherwise a parallel full GET + Symphonia competes with playback
|
||||
/// decode (ALSA underruns). The ranged task clears this on exit; `gen` avoids a
|
||||
/// late drop clearing a newer play of the same track.
|
||||
pub(crate) ranged_loudness_seed_hold: Arc<Mutex<Option<(String, u64)>>>,
|
||||
/// Secondary sink dedicated to track previews. Runs on the same `OutputStream`
|
||||
/// as the main sink (rodio mixes both internally) so we don't open a second
|
||||
/// device handle — important on ALSA-exclusive hardware.
|
||||
pub(crate) preview_sink: Arc<Mutex<Option<Arc<Player>>>>,
|
||||
/// Cancel token for the active preview. Bumped on every `audio_preview_play`
|
||||
/// and `audio_preview_stop` so that orphan timer/progress tasks bail out.
|
||||
pub(crate) preview_gen: Arc<AtomicU64>,
|
||||
/// True when `audio_preview_play` paused the main sink and should resume it
|
||||
/// on preview end. False if the main sink was already paused (or empty).
|
||||
pub(crate) preview_main_resume: Arc<AtomicBool>,
|
||||
/// Subsonic song id of the currently playing preview. Echoed back in
|
||||
/// `audio:preview-end` so the frontend can clear UI state for that row.
|
||||
pub(crate) preview_song_id: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
pub sink: Option<Arc<Player>>,
|
||||
pub duration_secs: f64,
|
||||
pub seek_offset: f64,
|
||||
pub play_started: Option<Instant>,
|
||||
pub paused_at: Option<f64>,
|
||||
pub replay_gain_linear: f32,
|
||||
pub base_volume: f32,
|
||||
/// Crossfade: trigger for sample-level fade-out of the current source.
|
||||
pub fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
/// Crossfade: total fade samples (set before triggering).
|
||||
pub fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
}
|
||||
|
||||
impl AudioCurrent {
|
||||
pub fn position(&self) -> f64 {
|
||||
if let Some(p) = self.paused_at {
|
||||
return p;
|
||||
}
|
||||
if let Some(t) = self.play_started {
|
||||
let elapsed = t.elapsed().as_secs_f64();
|
||||
(self.seek_offset + elapsed).min(self.duration_secs.max(0.001))
|
||||
} else {
|
||||
self.seek_offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an output device at `desired_rate` Hz (0 = device default).
|
||||
///
|
||||
/// `device_name`: exact name from `audio_list_devices`. `None` → system default.
|
||||
/// Falls back to the system default if the named device is not found.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. Exact rate match in the device's supported config ranges.
|
||||
/// 2. Highest available rate (for hardware that doesn't support the source rate).
|
||||
/// 3. Device default.
|
||||
/// 4. System default (last resort).
|
||||
///
|
||||
/// Returns `(stream_handle, actual_sample_rate)`.
|
||||
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
// Suppress ALSA stderr noise while enumerating devices on Unix.
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
|
||||
}
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
|
||||
let host = rodio::cpal::default_host();
|
||||
|
||||
// Resolve the target device: explicit name first, then (on Linux) prefer
|
||||
// a "pipewire" or "pulse" ALSA alias before falling back to cpal's system
|
||||
// default. On PipeWire-based distros the raw ALSA `default` alias can
|
||||
// route to a null sink at app-start (issue #234 on Debian 13): the stream
|
||||
// opens cleanly, progress ticks run, no audio reaches the user. The
|
||||
// named-alias path goes through pipewire-alsa's real sink and just works.
|
||||
// On systems where neither alias exists (pure ALSA, macOS, Windows),
|
||||
// `find_by_name` returns None and we drop through to `default_output_device`
|
||||
// unchanged — no regression.
|
||||
let find_by_name = |name: &str| -> Option<_> {
|
||||
host.output_devices().ok()?.find(|d| {
|
||||
d.description()
|
||||
.ok()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.as_deref()
|
||||
== Some(name)
|
||||
})
|
||||
};
|
||||
|
||||
let device = device_name
|
||||
.and_then(find_by_name)
|
||||
.or_else(|| {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ find_by_name("pipewire").or_else(|| find_by_name("pulse")) }
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ None }
|
||||
})
|
||||
.or_else(|| host.default_output_device());
|
||||
|
||||
if let Some(device) = device {
|
||||
if desired_rate > 0 {
|
||||
if let Ok(supported) = device.supported_output_configs() {
|
||||
let configs: Vec<_> = supported.collect();
|
||||
|
||||
// 1. Exact rate match — prefer more channels (stereo > mono).
|
||||
let exact = configs.iter()
|
||||
.filter(|c| {
|
||||
c.min_sample_rate() <= desired_rate
|
||||
&& desired_rate <= c.max_sample_rate()
|
||||
})
|
||||
.max_by_key(|c| c.channels());
|
||||
|
||||
if exact.is_some() {
|
||||
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
|
||||
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
|
||||
{
|
||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
|
||||
return (Arc::new(handle), desired_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. No exact match — use the highest supported rate.
|
||||
let best = configs.iter()
|
||||
.max_by_key(|c| c.max_sample_rate());
|
||||
|
||||
if let Some(cfg) = best {
|
||||
let rate = cfg.max_sample_rate();
|
||||
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
|
||||
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
||||
rate, desired_rate
|
||||
);
|
||||
return (Arc::new(handle), rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Device default.
|
||||
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone()).and_then(|b| b.open_stream()) {
|
||||
let rate = device
|
||||
.default_output_config()
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44100);
|
||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
|
||||
return (Arc::new(handle), rate);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Last resort: system default.
|
||||
crate::app_eprintln!("[psysonic] audio stream falling back to system default");
|
||||
let handle = rodio::DeviceSinkBuilder::open_default_sink()
|
||||
.expect("cannot open any audio output device");
|
||||
let rate = rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.default_output_config().ok())
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44100);
|
||||
(Arc::new(handle), rate)
|
||||
}
|
||||
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
// macOS: request a smaller CoreAudio buffer to reduce output latency.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
|
||||
std::env::set_var("COREAUDIO_BUFFER_SIZE", "512");
|
||||
}
|
||||
}
|
||||
|
||||
// Channels: main thread ←→ audio-stream thread.
|
||||
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup.
|
||||
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
|
||||
let (init_tx, init_rx) =
|
||||
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
|
||||
let (reopen_tx, reopen_rx) =
|
||||
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
.spawn(move || {
|
||||
// Set PipeWire / PulseAudio latency hints before the first open.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Match cpal ALSA ~200 ms headroom: larger quantum reduces underruns when
|
||||
// the decoder thread catches up after seek or competes with other work.
|
||||
if std::env::var("PIPEWIRE_LATENCY").is_err() {
|
||||
std::env::set_var("PIPEWIRE_LATENCY", "8192/48000");
|
||||
}
|
||||
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", "170");
|
||||
}
|
||||
}
|
||||
|
||||
// Thread priority is kept at default during standard-mode playback.
|
||||
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
||||
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
||||
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0);
|
||||
let handle = _stream.clone();
|
||||
init_tx.send((handle, rate)).ok();
|
||||
|
||||
// Keep the stream alive and handle sample-rate / device-switch requests.
|
||||
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() {
|
||||
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||
// real-time scheduling to avoid underruns). No escalation for
|
||||
// standard mode — the thread blocks on recv() between reopens so
|
||||
// elevated priority would only waste scheduler budget.
|
||||
if is_hi_res {
|
||||
thread_priority::set_current_thread_priority(
|
||||
thread_priority::ThreadPriority::Max
|
||||
).ok();
|
||||
}
|
||||
|
||||
drop(_stream); // close old stream before opening new one
|
||||
|
||||
// Scale the PipeWire quantum with the sample rate so wall-clock
|
||||
// latency stays roughly constant (≈93 ms) at all rates.
|
||||
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
|
||||
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
|
||||
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
|
||||
// get the same wall-clock quantum as PipeWire.
|
||||
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||
}
|
||||
|
||||
let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
||||
let new_handle = new_stream.clone();
|
||||
_stream = new_stream;
|
||||
reply_tx.send(new_handle).ok();
|
||||
}
|
||||
})
|
||||
.expect("spawn audio stream thread");
|
||||
|
||||
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
|
||||
|
||||
let engine = AudioEngine {
|
||||
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
|
||||
device_default_rate: initial_rate,
|
||||
stream_reopen_tx: reopen_tx,
|
||||
selected_device: Arc::new(Mutex::new(None)),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.use_rustls_tls()
|
||||
.user_agent(crate::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
)),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
stream_completed_cache: Arc::new(Mutex::new(None)),
|
||||
current_is_seekable: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())),
|
||||
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new((-4.5f32).to_bits())),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
current_channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
radio_state: Mutex::new(None),
|
||||
current_playback_url: Arc::new(Mutex::new(None)),
|
||||
current_analysis_track_id: Arc::new(Mutex::new(None)),
|
||||
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
|
||||
preview_sink: Arc::new(Mutex::new(None)),
|
||||
preview_gen: Arc::new(AtomicU64::new(0)),
|
||||
preview_main_resume: Arc::new(AtomicBool::new(false)),
|
||||
preview_song_id: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
}
|
||||
/// `analysis_enqueue_seed_from_url` should bail while this track's ranged HTTP buffer
|
||||
/// is still filling — playback will seed on completion with the same bytes.
|
||||
pub(crate) fn ranged_loudness_backfill_should_defer(engine: &AudioEngine, track_id: &str) -> bool {
|
||||
let tid = track_id.trim();
|
||||
if tid.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(g) = engine.ranged_loudness_seed_hold.lock() else {
|
||||
return false;
|
||||
};
|
||||
matches!(&*g, Some((t, _)) if t.as_str() == tid)
|
||||
}
|
||||
|
||||
/// Subsonic id pinned for the playing source (`audio_play`). Used to prioritize
|
||||
/// HTTP loudness backfill for the track the user is listening to.
|
||||
pub(crate) fn analysis_track_id_is_current_playback(engine: &AudioEngine, track_id: &str) -> bool {
|
||||
let needle = track_id.trim();
|
||||
if needle.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(guard) = engine.current_analysis_track_id.lock() else {
|
||||
return false;
|
||||
};
|
||||
let Some(cur) = guard.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
cur == needle
|
||||
}
|
||||
|
||||
pub(crate) fn audio_http_client(state: &AudioEngine) -> reqwest::Client {
|
||||
state
|
||||
.http_client
|
||||
.read()
|
||||
.map(|c| c.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.use_rustls_tls()
|
||||
.user_agent(ua)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
if let Ok(mut slot) = state.http_client.write() {
|
||||
*slot = client;
|
||||
}
|
||||
}
|
||||
pub(crate) fn analysis_seed_high_priority_for_track(app: &AppHandle, track_id: &str) -> bool {
|
||||
app.try_state::<AudioEngine>()
|
||||
.is_some_and(|e| analysis_track_id_is_current_playback(&e, track_id))
|
||||
}
|
||||
@@ -1,723 +0,0 @@
|
||||
//! URL identity, loudness cache resolution, fetch, gain math, and stream analysis helpers.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use rodio::Player;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use crate::audio::engine::AudioEngine;
|
||||
use crate::audio::ipc::{
|
||||
partial_loudness_should_emit, PartialLoudnessPayload, PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB,
|
||||
};
|
||||
|
||||
pub(crate) fn emit_partial_loudness_from_bytes(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
bytes: &[u8],
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
) {
|
||||
if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}",
|
||||
bytes.len(),
|
||||
PARTIAL_LOUDNESS_MIN_BYTES
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Lightweight fallback based on buffered bytes count to keep CPU low.
|
||||
let mb = bytes.len() as f32 / (1024.0 * 1024.0);
|
||||
let pre_floor = pre_analysis_attenuation_db.clamp(-24.0, 0.0);
|
||||
// Target-derived hint (e.g. -12 LUFS → -1 dB). Old `(hint).clamp(pre, 0)` left
|
||||
// the hint when it lay inside [pre, 0] — e.g. -1 with pre=-6, so AAC/M4A
|
||||
// streaming often sat at -1 dB until full analysis. Combine with user trim:
|
||||
// stricter (more negative) pre wins; milder pre still caps vs the hint.
|
||||
let heuristic_floor = (target_lufs + 11.0).clamp(-6.0, 0.0);
|
||||
let floor_db = if pre_floor < heuristic_floor {
|
||||
pre_floor
|
||||
} else {
|
||||
pre_floor.max(heuristic_floor)
|
||||
};
|
||||
let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0);
|
||||
let track_key = playback_identity(url).unwrap_or_else(|| url.to_string());
|
||||
if !partial_loudness_should_emit(&track_key, gain_db as f32) {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness skip reason=delta-below-threshold gain_db={:.2} threshold_db={:.2} track_id={:?}",
|
||||
gain_db,
|
||||
PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB,
|
||||
playback_identity(url)
|
||||
);
|
||||
return;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}",
|
||||
bytes.len(),
|
||||
gain_db,
|
||||
target_lufs,
|
||||
playback_identity(url)
|
||||
);
|
||||
let _ = app.emit(
|
||||
"analysis:loudness-partial",
|
||||
PartialLoudnessPayload {
|
||||
track_id: playback_identity(url),
|
||||
gain_db: gain_db as f32,
|
||||
target_lufs,
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn provisional_loudness_gain_from_progress(
|
||||
downloaded: usize,
|
||||
total_size: usize,
|
||||
target_lufs: f32,
|
||||
start_db_in: f32,
|
||||
) -> Option<f32> {
|
||||
if total_size == 0 || downloaded == 0 {
|
||||
return None;
|
||||
}
|
||||
let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0);
|
||||
// Move from startup attenuation toward a more realistic late-stream level.
|
||||
// This avoids staying near -2 dB and then jumping hard when final LUFS lands.
|
||||
let start_db = start_db_in.clamp(-24.0, 0.0).min(0.0);
|
||||
let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0);
|
||||
let shaped = progress.powf(0.75);
|
||||
Some(start_db + (end_db - start_db) * shaped)
|
||||
}
|
||||
|
||||
pub(crate) fn content_type_to_hint(ct: &str) -> Option<String> {
|
||||
let ct = ct.to_ascii_lowercase();
|
||||
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
|
||||
else if ct.contains("aac") || ct.contains("aacp") { Some("aac".into()) }
|
||||
else if ct.contains("ogg") { Some("ogg".into()) }
|
||||
else if ct.contains("flac") { Some("flac".into()) }
|
||||
else if ct.contains("wav") || ct.contains("wave") { Some("wav".into()) }
|
||||
else if ct.contains("opus") { Some("opus".into()) }
|
||||
// AAC/ALAC in MP4 — Navidrome/nginx often send `audio/mp4`; without a hint we skipped ranged open.
|
||||
else if ct.contains("audio/mp4") || ct.contains("x-m4a") || ct.contains("/m4a") {
|
||||
Some("m4a".into())
|
||||
}
|
||||
else { None }
|
||||
}
|
||||
|
||||
/// `Content-Disposition: attachment; filename="…"` from some Subsonic proxies.
|
||||
pub(crate) fn format_hint_from_content_disposition(cd: &str) -> Option<String> {
|
||||
fn ext_ok(ext: &str) -> Option<String> {
|
||||
let ext = ext.trim_matches(|c| c == '"' || c == '\'' || c == ' ').split(';').next()?.trim();
|
||||
if !(1..=5).contains(&ext.len()) {
|
||||
return None;
|
||||
}
|
||||
if !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
let e = ext.to_ascii_lowercase();
|
||||
if matches!(
|
||||
e.as_str(),
|
||||
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4" | "aac" | "wav" | "wave" | "ape" | "wv"
|
||||
| "webm" | "mka"
|
||||
) {
|
||||
Some(e)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn ext_from_filename(path: &str) -> Option<String> {
|
||||
let base = path.rsplit('/').next()?.trim_matches(|c| c == '"' || c == ' ');
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ext = base.rsplit('.').next()?;
|
||||
if ext == base {
|
||||
return None;
|
||||
}
|
||||
ext_ok(ext)
|
||||
}
|
||||
for part in cd.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(rest) = part.strip_prefix("filename*=") {
|
||||
// RFC 5987: `charset'lang'value`
|
||||
let value = rest.split("''").nth(1).unwrap_or(rest).trim().trim_matches('"');
|
||||
if let Some(ext) = ext_from_filename(value) {
|
||||
return Some(ext);
|
||||
}
|
||||
} else if let Some(rest) = part.strip_prefix("filename=") {
|
||||
let value = rest.trim().trim_matches('"');
|
||||
if let Some(ext) = ext_from_filename(value) {
|
||||
return Some(ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Subsonic [`song.suffix`](https://www.subsonic.org/pages/api.jsp#getSong) — stream.view URLs
|
||||
/// usually have no file extension; this supplies `format_hint` for ranged open.
|
||||
pub(crate) fn normalize_stream_suffix_for_hint(suffix: Option<&str>) -> Option<String> {
|
||||
let s = suffix?.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let e = s.to_ascii_lowercase();
|
||||
if matches!(
|
||||
e.as_str(),
|
||||
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4" | "aac" | "wav" | "wave" | "ape" | "wv"
|
||||
| "webm" | "mka"
|
||||
) {
|
||||
Some(e)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Max prefix length for an optional `Range` probe GET when ranged open needs a format hint.
|
||||
pub(crate) const STREAM_FORMAT_SNIFF_PROBE_BYTES: usize = 256 * 1024;
|
||||
|
||||
fn id3v2_tag_len(data: &[u8]) -> usize {
|
||||
if data.len() >= 10 && data[0..3] == *b"ID3" {
|
||||
let size = ((data[6] as usize & 0x7f) << 21)
|
||||
| ((data[7] as usize & 0x7f) << 14)
|
||||
| ((data[8] as usize & 0x7f) << 7)
|
||||
| (data[9] as usize & 0x7f);
|
||||
10usize.saturating_add(size)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn adts_frame_sync(b0: u8, b1: u8) -> bool {
|
||||
b0 == 0xff && (b1 & 0xf6) == 0xf0
|
||||
}
|
||||
|
||||
fn mp3_frame_sync(b0: u8, b1: u8) -> bool {
|
||||
b0 == 0xff && (b1 & 0xe0) == 0xe0
|
||||
}
|
||||
|
||||
/// Magic-byte sniff on the start of an HTTP body when headers / Subsonic suffix / path
|
||||
/// did not yield a Symphonia [`Hint`] extension (needed for `RangedHttpSource`).
|
||||
pub(crate) fn sniff_stream_format_extension(data: &[u8]) -> Option<String> {
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if data.len() >= 4 && data[0..4] == *b"fLaC" {
|
||||
return Some("flac".into());
|
||||
}
|
||||
if data.len() >= 4 && data[0..4] == *b"OggS" {
|
||||
return Some("ogg".into());
|
||||
}
|
||||
if data.len() >= 12 && data[0..4] == *b"RIFF" && data[8..12] == *b"WAVE" {
|
||||
return Some("wav".into());
|
||||
}
|
||||
// ISO-BMFF — `ftyp` inside a box; scan a small window (large `free`/`skip` before `ftyp` is rare but exists).
|
||||
let scan = data.len().min(4096).saturating_sub(4);
|
||||
for i in 0..=scan {
|
||||
if data[i..i + 4] == *b"ftyp" {
|
||||
return Some("m4a".into());
|
||||
}
|
||||
}
|
||||
// EBML — WebM / Matroska (.mka)
|
||||
if data.len() >= 4 && data[0] == 0x1a && data[1] == 0x45 && data[2] == 0xdf && data[3] == 0xa3 {
|
||||
return Some("mka".into());
|
||||
}
|
||||
// AAC ADTS
|
||||
let id3 = id3v2_tag_len(data);
|
||||
if id3 < data.len().saturating_sub(2) && adts_frame_sync(data[id3], data[id3 + 1]) {
|
||||
return Some("aac".into());
|
||||
}
|
||||
if data.len() >= 2 && adts_frame_sync(data[0], data[1]) {
|
||||
return Some("aac".into());
|
||||
}
|
||||
// MPEG layer III / II — after ID3
|
||||
let off = id3;
|
||||
if off + 2 <= data.len() && mp3_frame_sync(data[off], data[off + 1]) {
|
||||
return Some("mp3".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
// ─── Event payloads ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ProgressPayload {
|
||||
pub current_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subsonic `buildStreamUrl()` uses a fresh random salt on every call, so two
|
||||
/// URLs for the same track differ in `t`/`s` query params. Compare a stable key.
|
||||
pub(crate) fn playback_identity(url: &str) -> Option<String> {
|
||||
if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
return Some(format!("local:{path}"));
|
||||
}
|
||||
if !url.contains("stream.view") {
|
||||
return None;
|
||||
}
|
||||
let q = url.split('?').nth(1)?;
|
||||
for pair in q.split('&') {
|
||||
if let Some(v) = pair.strip_prefix("id=") {
|
||||
let v = v.split('&').next().unwrap_or(v);
|
||||
return Some(format!("stream:{v}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Stable id for analysis cache rows and `analysis:waveform-updated`.
|
||||
/// Prefer the Subsonic track id from the frontend: `psysonic-local://` URLs
|
||||
/// only map to `local:path` in `playback_identity`, which does not match
|
||||
/// `analysis_get_waveform_for_track(trackId)` or the UI's `currentTrack.id`.
|
||||
pub(crate) fn analysis_cache_track_id(logical_track_id: Option<&str>, url: &str) -> Option<String> {
|
||||
let logical = logical_track_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
logical.or_else(|| playback_identity(url))
|
||||
}
|
||||
|
||||
pub(crate) fn same_playback_target(a_url: &str, b_url: &str) -> bool {
|
||||
match (playback_identity(a_url), playback_identity(b_url)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => a_url == b_url,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ResolveLoudnessCacheOpts {
|
||||
/// When false, skip `get_latest_waveform_for_track` — `audio_update_replay_gain` runs
|
||||
/// on every partial-LUFS tick; loudness gain does not depend on waveform, and the extra
|
||||
/// SQLite read was pure overhead on the IPC path.
|
||||
pub(crate) touch_waveform: bool,
|
||||
/// When false, omit `cache-miss` / `cache-invalid` debug lines (still log hits and errors).
|
||||
pub(crate) log_soft_misses: bool,
|
||||
}
|
||||
|
||||
impl Default for ResolveLoudnessCacheOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
touch_waveform: true,
|
||||
log_soft_misses: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_loudness_gain_from_cache(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
target_lufs: f32,
|
||||
logical_track_id: Option<&str>,
|
||||
) -> Option<f32> {
|
||||
resolve_loudness_gain_from_cache_impl(
|
||||
app,
|
||||
url,
|
||||
target_lufs,
|
||||
logical_track_id,
|
||||
ResolveLoudnessCacheOpts::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_loudness_gain_from_cache_impl(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
target_lufs: f32,
|
||||
logical_track_id: Option<&str>,
|
||||
opts: ResolveLoudnessCacheOpts,
|
||||
) -> Option<f32> {
|
||||
// Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`)
|
||||
// are applied in `audio_update_replay_gain` via `loudness_gain_db_or_startup(..., true, _)`.
|
||||
let Some(track_id) = analysis_cache_track_id(logical_track_id, url) else {
|
||||
if opts.log_soft_misses {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=no-identity url_len={}",
|
||||
url.len()
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let Some(cache) = app.try_state::<crate::analysis_cache::AnalysisCache>() else {
|
||||
if opts.log_soft_misses {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=no-analysis-cache track_id={}",
|
||||
track_id
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if opts.touch_waveform {
|
||||
// Bind / preload: verify waveform context exists alongside loudness lookup.
|
||||
let _ = cache.get_latest_waveform_for_track(&track_id);
|
||||
}
|
||||
match cache.get_latest_loudness_for_track(&track_id) {
|
||||
Ok(Some(row)) if row.integrated_lufs.is_finite() => {
|
||||
let recommended = crate::analysis_cache::recommended_gain_for_target(
|
||||
row.integrated_lufs,
|
||||
row.true_peak,
|
||||
target_lufs as f64,
|
||||
) as f32;
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache track_id={} gain_db={:.2} target_lufs={:.2} integrated_lufs={:.2} updated_at={}",
|
||||
track_id,
|
||||
recommended,
|
||||
target_lufs,
|
||||
row.integrated_lufs,
|
||||
row.updated_at
|
||||
);
|
||||
Some(recommended)
|
||||
}
|
||||
Ok(Some(row)) => {
|
||||
if opts.log_soft_misses {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache-invalid track_id={} integrated_lufs={}",
|
||||
track_id,
|
||||
row.integrated_lufs
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
Ok(None) => {
|
||||
if opts.log_soft_misses {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache-miss track_id={}",
|
||||
track_id
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache-error track_id={} err={}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typical integrated LUFS (streaming pivot) when SQLite has no row yet — so target changes
|
||||
/// still move gain before real analysis completes.
|
||||
const LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS: f64 = -14.0;
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn loudness_gain_placeholder_until_cache(target_lufs: f32, pre_analysis_attenuation_db: f32) -> f32 {
|
||||
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
|
||||
// `true_peak = 0.0` skips the headroom cap until integrated measurement exists.
|
||||
let pivot = crate::analysis_cache::recommended_gain_for_target(
|
||||
LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS,
|
||||
0.0,
|
||||
f64::from(target_lufs),
|
||||
) as f32;
|
||||
(pivot + pre).clamp(-24.0, 24.0)
|
||||
}
|
||||
|
||||
/// LUFS gain after a single `resolve_loudness_gain_from_cache` result (`None` = miss).
|
||||
/// Keeps `audio_update_replay_gain` / `audio_play` from resolving twice on the same URL.
|
||||
/// Until a cache row exists, follow current target (see [`loudness_gain_placeholder_until_cache`]).
|
||||
pub(crate) fn loudness_gain_db_after_resolve(
|
||||
resolved_from_cache: Option<f32>,
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
allow_js_when_uncached: bool,
|
||||
js_gain_db: Option<f32>,
|
||||
) -> Option<f32> {
|
||||
let uncached = loudness_gain_placeholder_until_cache(target_lufs, pre_analysis_attenuation_db);
|
||||
match resolved_from_cache {
|
||||
Some(g) => Some(g),
|
||||
None => {
|
||||
if allow_js_when_uncached {
|
||||
match js_gain_db {
|
||||
Some(r) if r.is_finite() => Some(r),
|
||||
_ => Some(uncached),
|
||||
}
|
||||
} else {
|
||||
Some(uncached)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved gain inputs that both `audio_play` and `audio_chain_preload` need
|
||||
/// before calling [`compute_gain`]. Bundles the engine state reads + cache
|
||||
/// resolution in one shot so the call sites don't drift apart on subtle
|
||||
/// behaviour (e.g. one accidentally skipping the post-resolve step for
|
||||
/// LUFS mode).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct TrackGainInputs {
|
||||
pub(crate) target_lufs: f32,
|
||||
pub(crate) norm_mode: u32,
|
||||
/// Pre-resolve cache value — kept around for logging in `audio_play`.
|
||||
pub(crate) cache_loudness_db: Option<f32>,
|
||||
/// Value to feed into `compute_gain` — for LUFS mode this is the
|
||||
/// post-`loudness_gain_db_after_resolve` value, otherwise the raw cache
|
||||
/// resolution (or `None` when not in normalisation mode).
|
||||
pub(crate) effective_loudness_db: Option<f32>,
|
||||
}
|
||||
|
||||
/// Read engine state + resolve the loudness cache for a track that's about to
|
||||
/// start playing. JS-supplied `loudness_gain_db` is **not** consulted at bind
|
||||
/// time (only post-cache via `audio_update_replay_gain`).
|
||||
pub(crate) fn resolve_track_gain_inputs(
|
||||
state: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
logical_track_id: Option<&str>,
|
||||
js_loudness_gain_db: Option<f32>,
|
||||
) -> TrackGainInputs {
|
||||
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 cache_loudness_db = resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id);
|
||||
let effective_loudness_db = if norm_mode == 2 {
|
||||
loudness_gain_db_after_resolve(
|
||||
cache_loudness_db,
|
||||
target_lufs,
|
||||
pre_analysis_db,
|
||||
false,
|
||||
js_loudness_gain_db,
|
||||
)
|
||||
} else {
|
||||
cache_loudness_db
|
||||
};
|
||||
TrackGainInputs {
|
||||
target_lufs,
|
||||
norm_mode,
|
||||
cache_loudness_db,
|
||||
effective_loudness_db,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn loudness_pre_analysis_db_for_engine(state: &AudioEngine) -> f32 {
|
||||
f32::from_bits(
|
||||
state
|
||||
.loudness_pre_analysis_attenuation_db
|
||||
.load(Ordering::Relaxed),
|
||||
)
|
||||
.clamp(-24.0, 0.0)
|
||||
.min(0.0)
|
||||
}
|
||||
|
||||
/// Take (consume) completed manual-stream bytes if they correspond to `url`.
|
||||
pub fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option<Vec<u8>> {
|
||||
let mut guard = state.stream_completed_cache.lock().unwrap();
|
||||
if guard
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, url))
|
||||
{
|
||||
return guard.take().map(|p| p.data);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetch track bytes from the preload cache or via HTTP.
|
||||
pub(crate) async fn fetch_data(
|
||||
url: &str,
|
||||
state: &AudioEngine,
|
||||
gen: u64,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
// Check completed streamed-track cache first (manual streaming fallback cache).
|
||||
let streamed_cached = {
|
||||
let mut streamed = state.stream_completed_cache.lock().unwrap();
|
||||
if streamed.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
||||
streamed.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(data) = streamed_cached {
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
// Check preload cache next.
|
||||
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(data) = cached {
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
// Offline cache — local file written by download_track_offline.
|
||||
if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
let data = tokio::fs::read(path).await.map_err(|e| e.to_string())?;
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
let response = crate::audio::engine::audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let ct = response.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("-");
|
||||
let server_hdr = response.headers()
|
||||
.get("server")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("-");
|
||||
// Strip auth params from URL before logging.
|
||||
let safe_url = url.split('?').next().unwrap_or(url);
|
||||
crate::app_deprintln!(
|
||||
"[audio] fetch {} → {} | content-type: {} | server: {}",
|
||||
safe_url, status, ct, server_hdr
|
||||
);
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None); // superseded
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
// Stream the body, checking gen between chunks so a rapid manual skip can
|
||||
// abort a superseded download mid-flight and free bandwidth for the new one.
|
||||
let hint = response.content_length().unwrap_or(0) as usize;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut data = Vec::with_capacity(hint);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None); // superseded — abort
|
||||
}
|
||||
data.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`,
|
||||
/// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local`
|
||||
/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform /
|
||||
/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP.
|
||||
pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
|
||||
app: &AppHandle,
|
||||
cache_track_id: Option<&str>,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
bytes: &[u8],
|
||||
) {
|
||||
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
if bytes.is_empty() || bytes.len() > crate::audio::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
let track_id = track_id.to_string();
|
||||
let bytes = bytes.to_vec();
|
||||
let app = app.clone();
|
||||
let gen_arc = gen_arc.clone();
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}",
|
||||
track_id,
|
||||
bytes.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
tokio::spawn(async move {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await {
|
||||
crate::app_eprintln!(
|
||||
"[analysis] in-memory play path seed failed for {}: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
|
||||
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
|
||||
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
|
||||
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
|
||||
pub(crate) const MASTER_HEADROOM: f32 = 0.891_254;
|
||||
pub(crate) const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024;
|
||||
pub(crate) const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 900;
|
||||
|
||||
pub(crate) fn compute_gain(
|
||||
normalization_engine: u32,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
volume: f32,
|
||||
) -> (f32, f32) {
|
||||
let gain_linear = match normalization_engine {
|
||||
2 => loudness_gain_db
|
||||
.map(|db| 10f32.powf(db / 20.0))
|
||||
.unwrap_or(1.0),
|
||||
1 => replay_gain_db
|
||||
.map(|db| 10f32.powf((db + pre_gain_db) / 20.0))
|
||||
.unwrap_or_else(|| 10f32.powf(fallback_db / 20.0)),
|
||||
_ => 1.0,
|
||||
};
|
||||
let peak = if normalization_engine == 1 {
|
||||
replay_gain_peak.unwrap_or(1.0).max(0.001)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let gain_linear = gain_linear.min(1.0 / peak);
|
||||
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
(gain_linear, effective)
|
||||
}
|
||||
|
||||
pub(crate) fn normalization_engine_name(mode: u32) -> &'static str {
|
||||
match mode {
|
||||
1 => "replaygain",
|
||||
2 => "loudness",
|
||||
_ => "off",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gain_linear_to_db(gain_linear: f32) -> Option<f32> {
|
||||
if gain_linear.is_finite() && gain_linear > 0.0 {
|
||||
Some(20.0 * gain_linear.log10())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `audio:normalization-state` “Now dB” for the UI: effective applied gain, including
|
||||
/// loudness pre-analysis trim from settings when no cache row exists yet (matches audible level).
|
||||
pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
|
||||
gain_linear_to_db(gain_linear)
|
||||
}
|
||||
|
||||
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
if (to - from).abs() < 0.002 {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
let delta = (to - from).abs();
|
||||
// Stretch large corrections to avoid audible "step down" moments.
|
||||
let (steps, step_ms): (usize, u64) = if delta > 0.30 {
|
||||
(24, 35)
|
||||
} else if delta > 0.18 {
|
||||
(18, 30)
|
||||
} else if delta > 0.10 {
|
||||
(14, 24)
|
||||
} else {
|
||||
(8, 16)
|
||||
};
|
||||
for i in 1..=steps {
|
||||
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Deduped emits for normalization UI and partial loudness analysis.
|
||||
use serde::Serialize;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PartialLoudnessPayload {
|
||||
pub(crate) track_id: Option<String>,
|
||||
pub(crate) gain_db: f32,
|
||||
pub(crate) target_lufs: f32,
|
||||
pub(crate) is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NormalizationStatePayload {
|
||||
pub(crate) engine: String,
|
||||
pub(crate) current_gain_db: Option<f32>,
|
||||
pub(crate) target_lufs: f32,
|
||||
}
|
||||
|
||||
/// Last `audio:normalization-state` emit, kept so we can suppress duplicate
|
||||
/// payloads. The frontend already debounces this event, but on Windows
|
||||
/// (WebView2) the IPC pipe is the bottleneck — every echo we skip here is
|
||||
/// renderer-thread time we don't pay.
|
||||
pub(crate) static LAST_NORM_STATE_EMIT: OnceLock<Mutex<Option<NormalizationStatePayload>>> = OnceLock::new();
|
||||
|
||||
pub(crate) fn norm_state_lock() -> &'static Mutex<Option<NormalizationStatePayload>> {
|
||||
LAST_NORM_STATE_EMIT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub(crate) fn norm_state_changed(prev: &NormalizationStatePayload, next: &NormalizationStatePayload) -> bool {
|
||||
if prev.engine != next.engine { return true; }
|
||||
if (prev.target_lufs - next.target_lufs).abs() >= 0.02 { return true; }
|
||||
match (prev.current_gain_db, next.current_gain_db) {
|
||||
(None, None) => false,
|
||||
(Some(a), Some(b)) => (a - b).abs() >= 0.05,
|
||||
_ => true, // None ↔ Some transition is significant
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_emit_normalization_state(app: &AppHandle, payload: NormalizationStatePayload) {
|
||||
let mut guard = norm_state_lock().lock().unwrap();
|
||||
let should_emit = match guard.as_ref() {
|
||||
Some(prev) => norm_state_changed(prev, &payload),
|
||||
None => true,
|
||||
};
|
||||
if !should_emit { return; }
|
||||
*guard = Some(payload.clone());
|
||||
drop(guard);
|
||||
let _ = app.emit("audio:normalization-state", payload);
|
||||
}
|
||||
|
||||
/// Last `analysis:loudness-partial` gain emitted per track-identity, used to
|
||||
/// suppress emits whose gain hasn't moved meaningfully (≥ 0.1 dB). The partial
|
||||
/// heuristic in `emit_partial_loudness_from_bytes` and the ranged-progress curve
|
||||
/// both produce values that drift by hundredths of a dB even on identical input,
|
||||
/// so the time-based throttle alone is not enough to keep the loop quiet.
|
||||
pub(crate) static LAST_PARTIAL_LOUDNESS_EMIT: OnceLock<Mutex<std::collections::HashMap<String, f32>>> = OnceLock::new();
|
||||
pub(crate) const PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB: f32 = 0.1;
|
||||
|
||||
pub(crate) fn partial_loudness_should_emit(track_key: &str, gain_db: f32) -> bool {
|
||||
let mut guard = LAST_PARTIAL_LOUDNESS_EMIT
|
||||
.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
.lock()
|
||||
.unwrap();
|
||||
let prev = guard.get(track_key).copied();
|
||||
if let Some(p) = prev {
|
||||
if (p - gain_db).abs() < PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
guard.insert(track_key.to_string(), gain_db);
|
||||
true
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
//! Audio-stage settings commands: volume, replay-gain / loudness normalization,
|
||||
//! 10-band EQ, crossfade, gapless.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_update_replay_gain(
|
||||
volume: f32,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
|
||||
let url_for_loudness = if norm_mode == 2 {
|
||||
state.current_playback_url.lock().unwrap().clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let logical_for_loudness = state
|
||||
.current_analysis_track_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| (*g).clone())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
// If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db`
|
||||
// for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`).
|
||||
let cache_loudness = url_for_loudness.as_deref().and_then(|u| {
|
||||
resolve_loudness_gain_from_cache_impl(
|
||||
&app,
|
||||
u,
|
||||
target_lufs,
|
||||
logical_for_loudness.as_deref(),
|
||||
ResolveLoudnessCacheOpts {
|
||||
touch_waveform: false,
|
||||
log_soft_misses: false,
|
||||
},
|
||||
)
|
||||
});
|
||||
let effective_loudness_db = if norm_mode == 2 {
|
||||
match url_for_loudness.as_deref() {
|
||||
Some(_u) => loudness_gain_db_after_resolve(
|
||||
cache_loudness,
|
||||
target_lufs,
|
||||
pre_analysis_db,
|
||||
true,
|
||||
loudness_gain_db,
|
||||
),
|
||||
None => {
|
||||
loudness_gain_db.or(Some(loudness_gain_placeholder_until_cache(
|
||||
target_lufs,
|
||||
pre_analysis_db,
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loudness_gain_db
|
||||
};
|
||||
let (gain_linear, effective) = compute_gain(
|
||||
norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_update_replay_gain engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective={:.3}",
|
||||
normalization_engine_name(norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
loudness_gain_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
drop(cur);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
|
||||
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
|
||||
for (i, &gain) in gains.iter().enumerate() {
|
||||
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let mode = match engine.as_str() {
|
||||
"replaygain" => 1,
|
||||
"loudness" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
state.normalization_engine.store(mode, Ordering::Relaxed);
|
||||
let target = target_lufs.clamp(-30.0, -8.0);
|
||||
state
|
||||
.normalization_target_lufs
|
||||
.store(target.to_bits(), Ordering::Relaxed);
|
||||
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
|
||||
state
|
||||
.loudness_pre_analysis_attenuation_db
|
||||
.store(pre.to_bits(), Ordering::Relaxed);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2} pre_analysis_db={:.2}",
|
||||
engine,
|
||||
normalization_engine_name(mode),
|
||||
target,
|
||||
pre
|
||||
);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(mode).to_string(),
|
||||
// At mode-switch time the effective track gain may not be recalculated yet.
|
||||
// Emit `None` and let audio_play/audio_update_replay_gain publish actual value.
|
||||
current_gain_db: None,
|
||||
target_lufs: target,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//! Audio playback: Symphonia decode, rodio output, HTTP radio/streaming, gapless, previews.
|
||||
//!
|
||||
//! Implementation is split into submodules (`sources`, `decode`, `stream`, `commands`, …)
|
||||
//! for navigation; behavior matches the historical single `audio.rs` file.
|
||||
|
||||
pub mod autoeq_commands;
|
||||
mod codec;
|
||||
pub mod commands;
|
||||
mod decode;
|
||||
mod dev_io;
|
||||
pub mod device_commands;
|
||||
pub mod mix_commands;
|
||||
mod play_input;
|
||||
pub mod preload_commands;
|
||||
pub(crate) mod progress_task;
|
||||
pub mod radio_commands;
|
||||
pub mod transport_commands;
|
||||
mod device_watcher;
|
||||
mod engine;
|
||||
mod power_resume;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
mod state;
|
||||
mod stream;
|
||||
|
||||
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
|
||||
pub use device_watcher::start_device_watcher;
|
||||
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
|
||||
pub use helpers::take_stream_completed_for_url;
|
||||
|
||||
/// Register platform-specific listeners so the output stream is reopened after sleep/resume
|
||||
/// when the device name may be unchanged (Windows WASAPI, Linux PipeWire, …).
|
||||
pub fn register_post_sleep_audio_recovery(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
power_notify_win::register(app);
|
||||
#[cfg(target_os = "linux")]
|
||||
power_notify_linux::register(app);
|
||||
// macOS intentionally falls through for now: we only ship native resume hooks
|
||||
// where we have verified regressions (Windows WASAPI, Linux logind/PipeWire).
|
||||
// macOS currently relies on the generic device watcher path.
|
||||
#[cfg(all(
|
||||
not(target_os = "windows"),
|
||||
not(target_os = "linux")
|
||||
))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
pub(crate) use engine::{analysis_track_id_is_current_playback, ranged_loudness_backfill_should_defer};
|
||||
@@ -1,543 +0,0 @@
|
||||
//! Source-selection logic for `audio_play`: given a URL + various caches +
|
||||
//! Subsonic hints, decide whether to play from in-memory bytes, a seekable
|
||||
//! local file, a seekable RangedHttpSource, or a non-seekable streaming reader.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::{HeapCons, HeapRb};
|
||||
use symphonia::core::io::MediaSource;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
|
||||
spawn_analysis_seed_from_in_memory_bytes, same_playback_target,
|
||||
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
};
|
||||
use super::stream::{
|
||||
ranged_download_task, track_download_task, AudioStreamReader,
|
||||
LocalFileSource, RangedHttpSource, LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
|
||||
RADIO_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
|
||||
};
|
||||
|
||||
/// What `audio_play` will hand to `build_source` / `build_streaming_source`.
|
||||
pub(super) 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>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Inputs `audio_play` has already computed before source selection.
|
||||
pub(super) struct PlayInputContext<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub duration_hint: f64,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub format_hint: Option<&'a str>,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
/// `Some(bytes)` when manual-skip onto a pre-chained track reuses bytes
|
||||
/// from the chained-info block.
|
||||
pub reuse_chained_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Resolves the play input for `audio_play` honouring (in priority order):
|
||||
/// 1. Reused chained bytes — manual skip onto pre-chained track.
|
||||
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
|
||||
/// 3. Remote HTTP without preload/stream-cache hit — try ranged HTTP, fall
|
||||
/// back to non-seekable AudioStreamReader.
|
||||
/// 4. Preload/stream-cache hit — replay in-memory bytes via `fetch_data`.
|
||||
///
|
||||
/// Returns `Ok(None)` when the operation was superseded by a later
|
||||
/// `audio_play` call (generation bump) — caller should bail out silently.
|
||||
pub(super) async fn select_play_input(
|
||||
ctx: PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
if let Some(d) = ctx.reuse_chained_bytes {
|
||||
spawn_analysis_seed_from_in_memory_bytes(
|
||||
app,
|
||||
ctx.cache_id_for_tasks,
|
||||
ctx.gen,
|
||||
&state.generation,
|
||||
&d,
|
||||
);
|
||||
return Ok(Some(PlayInput::Bytes(d)));
|
||||
}
|
||||
|
||||
let stream_cache_hit = {
|
||||
let streamed = state.stream_completed_cache.lock().unwrap();
|
||||
streamed
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, ctx.url))
|
||||
};
|
||||
let preloaded_hit = {
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
preloaded
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, ctx.url))
|
||||
};
|
||||
let is_local = ctx.url.starts_with("psysonic-local://");
|
||||
|
||||
if is_local && !stream_cache_hit && !preloaded_hit {
|
||||
return Ok(Some(open_local_file_input(&ctx, state, app)?));
|
||||
}
|
||||
if !stream_cache_hit && !preloaded_hit && !is_local {
|
||||
return open_ranged_or_streaming_input(&ctx, state, app).await;
|
||||
}
|
||||
|
||||
// Preloaded or stream-cache hit → replay in-memory bytes.
|
||||
let data = match fetch_data(ctx.url, state, ctx.gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Ok(None), // superseded while downloading
|
||||
};
|
||||
spawn_analysis_seed_from_in_memory_bytes(
|
||||
app,
|
||||
ctx.cache_id_for_tasks,
|
||||
ctx.gen,
|
||||
&state.generation,
|
||||
&data,
|
||||
);
|
||||
Ok(Some(PlayInput::Bytes(data)))
|
||||
}
|
||||
|
||||
/// `psysonic-local://<path>` → seekable `LocalFileSource`. Spawns a
|
||||
/// background CPU-seed for the analysis cache when the file is small
|
||||
/// enough (skipped if the cache already has a row for this track).
|
||||
fn open_local_file_input(
|
||||
ctx: &PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlayInput, String> {
|
||||
let path = ctx.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(seed_id) = ctx.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 = ctx.gen;
|
||||
let gen_arc_seed = state.generation.clone();
|
||||
let seed_id = seed_id.to_string();
|
||||
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 };
|
||||
Ok(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: local_hint,
|
||||
tag: "local-file",
|
||||
})
|
||||
}
|
||||
|
||||
/// Manual or auto-advance starts that aren't already cached: try ranged HTTP
|
||||
/// (seekable) first, fall back to a non-seekable `AudioStreamReader` if the
|
||||
/// server doesn't advertise byte-range support or a length.
|
||||
async fn open_ranged_or_streaming_input(
|
||||
ctx: &PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != ctx.gen {
|
||||
return Ok(None); // 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(format_hint_from_content_disposition)
|
||||
})
|
||||
.or_else(|| normalize_stream_suffix_for_hint(ctx.stream_format_suffix))
|
||||
.or_else(|| ctx.format_hint.map(|s| s.to_string()));
|
||||
|
||||
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(ctx.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 <= super::stream::TRACK_STREAM_PROMOTE_MAX_BYTES)
|
||||
.then_some(state.ranged_loudness_seed_hold.clone());
|
||||
tokio::spawn(ranged_download_task(
|
||||
ctx.gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(state),
|
||||
app.clone(),
|
||||
ctx.duration_hint,
|
||||
ctx.url.to_string(),
|
||||
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(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
loudness_hold_for_defer,
|
||||
));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
};
|
||||
return Ok(Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: stream_hint,
|
||||
tag: "ranged-stream",
|
||||
}));
|
||||
}
|
||||
|
||||
// Legacy non-seekable streaming reader fallback.
|
||||
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(
|
||||
ctx.gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(state),
|
||||
app.clone(),
|
||||
ctx.url.to_string(),
|
||||
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(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
));
|
||||
|
||||
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: ctx.gen,
|
||||
source_tag: "track-stream",
|
||||
eof_when_empty: Some(done),
|
||||
pos: 0,
|
||||
};
|
||||
Ok(Some(PlayInput::Streaming {
|
||||
reader,
|
||||
format_hint: stream_hint,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Pulled out of the format_hint extraction block in `audio_play` — 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.
|
||||
pub(super) fn url_format_hint(url: &str) -> Option<String> {
|
||||
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())
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(super) struct PlaybackSource {
|
||||
pub(super) built: BuiltSource,
|
||||
pub(super) is_seekable: bool,
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(super) struct SinkSwapInputs {
|
||||
pub(super) sink: Arc<rodio::Player>,
|
||||
pub(super) duration_secs: f64,
|
||||
pub(super) volume: f32,
|
||||
pub(super) gain_linear: f32,
|
||||
pub(super) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(super) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) crossfade_enabled: bool,
|
||||
pub(super) actual_fade_secs: f32,
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(super) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
use std::time::Instant;
|
||||
|
||||
let SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
} = inputs;
|
||||
|
||||
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(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
pub(super) async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// 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 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,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia { reader, format_hint: media_hint, tag } => {
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_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,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
)
|
||||
}
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), stream_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,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
)
|
||||
}
|
||||
}?;
|
||||
Ok(PlaybackSource { built, is_seekable })
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Linux: subscribe to logind `PrepareForSleep` on the system bus — `start == false` means resume
|
||||
//! completed (systemd says the boolean is true when going to sleep, false when waking).
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
let res = std::thread::Builder::new()
|
||||
.name("psysonic-logind-sleep".into())
|
||||
.spawn(move || run_listener(app));
|
||||
|
||||
if let Err(e) = res {
|
||||
crate::app_eprintln!("[psysonic] could not spawn logind listener: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_listener(app: AppHandle) {
|
||||
use zbus::blocking::{Connection, MessageIterator};
|
||||
use zbus::message::Type;
|
||||
use zbus::MatchRule;
|
||||
|
||||
let conn = match Connection::system() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] D-Bus system bus unavailable — post-sleep audio recovery disabled: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let rule: zbus::MatchRule = match (|| -> zbus::Result<_> {
|
||||
Ok(MatchRule::builder()
|
||||
.msg_type(Type::Signal)
|
||||
.path("/org/freedesktop/login1")?
|
||||
.interface("org.freedesktop.login1.Manager")?
|
||||
.member("PrepareForSleep")?
|
||||
.build())
|
||||
})() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] MatchRule for logind PrepareForSleep failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut iter = match MessageIterator::for_match_rule(rule, &conn, Some(32)) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind signal subscription failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
crate::app_eprintln!("[psysonic] logind PrepareForSleep listener registered (post-sleep audio recovery)");
|
||||
|
||||
loop {
|
||||
let Some(result) = iter.next() else {
|
||||
break;
|
||||
};
|
||||
let msg = match result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind message stream error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let start: bool = match msg.body().deserialize() {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if start {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Windows: `PowerRegisterSuspendResumeNotification` — resume from sleep without a default-device rename.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use windows::Win32::{
|
||||
Foundation::{ERROR_SUCCESS, HANDLE},
|
||||
System::Power::{PowerRegisterSuspendResumeNotification, DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS},
|
||||
UI::WindowsAndMessaging::{
|
||||
DEVICE_NOTIFY_CALLBACK, PBT_APMRESUMEAUTOMATIC, PBT_APMRESUMESUSPEND, PBT_APMRESUMESTANDBY,
|
||||
},
|
||||
};
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
unsafe extern "system" fn power_suspend_resume_callback(
|
||||
context: *const c_void,
|
||||
event_type: u32,
|
||||
_setting: *const c_void,
|
||||
) -> u32 {
|
||||
if context.is_null() {
|
||||
return 0;
|
||||
}
|
||||
if !matches!(
|
||||
event_type,
|
||||
PBT_APMRESUMEAUTOMATIC | PBT_APMRESUMESUSPEND | PBT_APMRESUMESTANDBY
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let app = unsafe { &*(context as *const AppHandle) }.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
// Intentionally leaked for process lifetime: Win32 callback receives this pointer
|
||||
// on each suspend/resume notification and may outlive this function scope.
|
||||
let app_leak = Box::into_raw(Box::new(app));
|
||||
|
||||
let params = Box::new(DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS {
|
||||
Callback: Some(power_suspend_resume_callback),
|
||||
Context: app_leak as *mut c_void,
|
||||
});
|
||||
// Intentionally leaked for process lifetime: the power subsystem keeps the
|
||||
// subscribe-parameters pointer after successful registration.
|
||||
let params_ptr = Box::into_raw(params);
|
||||
|
||||
let mut registration: *mut c_void = std::ptr::null_mut();
|
||||
let err = unsafe {
|
||||
PowerRegisterSuspendResumeNotification(
|
||||
DEVICE_NOTIFY_CALLBACK,
|
||||
HANDLE(params_ptr as *mut _),
|
||||
&mut registration as *mut *mut c_void,
|
||||
)
|
||||
};
|
||||
|
||||
if err != ERROR_SUCCESS {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] PowerRegisterSuspendResumeNotification failed: {:?} — post-sleep audio recovery disabled",
|
||||
err
|
||||
);
|
||||
unsafe {
|
||||
drop(Box::from_raw(params_ptr));
|
||||
drop(Box::from_raw(app_leak));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
crate::app_eprintln!("[psysonic] Windows power suspend/resume notifications registered for audio");
|
||||
// `registration` is an opaque handle returned by Win32 API. It does not own
|
||||
// Rust resources, so dropping the local copy is fine; callback context is
|
||||
// intentionally leaked above for process-lifetime notifications.
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! Reopen CPAL/rodio output after system sleep/resume when the old stream can be silent
|
||||
//! while the reported default device name is unchanged (Windows WASAPI, Linux PipeWire/ALSA, etc.).
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::device_watcher::{reopen_output_stream, ReopenNotify};
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
const DEBOUNCE: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Returns false if this resume should be ignored (coalesce bursts from the OS).
|
||||
pub(crate) fn debounce_allow_resume_reopen() -> bool {
|
||||
let mut g = RESUME_REOPEN_DEBOUNCE.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
if let Some(t) = *g {
|
||||
if now.duration_since(t) < DEBOUNCE {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*g = Some(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Delay so the audio stack re-enumerates before we open a new stream.
|
||||
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
let device_name = match app.try_state::<AudioEngine>() {
|
||||
Some(e) => e.selected_device.lock().unwrap().clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio: stream reopen failed or timed out after system resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Background audio_preload: fetch the next track's bytes ahead of time
|
||||
//! and seed the analysis cache. Distinct from `audio_chain_preload`
|
||||
//! (which constructs the gapless source chain) and `audio_play` (which
|
||||
//! starts playback). All three live in this audio submodule.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||
use super::state::PreloadedTrack;
|
||||
|
||||
#[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(())
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
//! Short preview playback on a secondary sink (same output stream).
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::MASTER_HEADROOM;
|
||||
use super::sources::PriorityBoostSource;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct PreviewProgressPayload {
|
||||
id: String,
|
||||
elapsed: f64,
|
||||
duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct PreviewEndPayload {
|
||||
id: String,
|
||||
/// "natural" = 30 s timer / source ended; "user" = explicit stop;
|
||||
/// "interrupted" = a new preview superseded this one.
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
/// Pause main sink and remember whether to resume it after preview ends.
|
||||
/// Mirrors `audio_pause` semantics so progress timestamps stay consistent.
|
||||
pub(crate) fn preview_pause_main(state: &AudioEngine) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() && !sink.empty() {
|
||||
let pos = cur.position();
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
state.preview_main_resume.store(true, Ordering::Release);
|
||||
} else {
|
||||
state.preview_main_resume.store(false, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
state.preview_main_resume.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel any active preview and clear the resume marker. Called from every
|
||||
/// command that brings the main sink back to life under its own steam
|
||||
/// (`audio_play`, `audio_play_radio`, `audio_resume`) — without this the
|
||||
/// preview would keep playing in parallel and the watchdog would later try
|
||||
/// to resume a main sink that's already running, double-mixing the audio.
|
||||
pub(crate) fn preview_clear_for_new_main_playback(state: &AudioEngine, app: &AppHandle) {
|
||||
// Order matters: clear the resume marker BEFORE bumping the generation
|
||||
// so the watchdog — if it wakes between our writes — sees no work to do
|
||||
// and bails without resuming main behind our back.
|
||||
state.preview_main_resume.store(false, Ordering::Release);
|
||||
state.preview_gen.fetch_add(1, Ordering::SeqCst);
|
||||
let sink = state.preview_sink.lock().unwrap().take();
|
||||
let id = state.preview_song_id.lock().unwrap().take();
|
||||
if let Some(s) = sink { s.stop(); }
|
||||
if let Some(id) = id {
|
||||
app.emit("audio:preview-end", PreviewEndPayload {
|
||||
id,
|
||||
reason: "interrupted",
|
||||
}).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume main sink iff `preview_pause_main` paused it. No-op if main was
|
||||
/// already paused/empty before preview started.
|
||||
pub(crate) fn preview_resume_main(state: &AudioEngine) {
|
||||
if !state.preview_main_resume.swap(false, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
|
||||
/// a `format=flac` query param for `.opus` files (server transcodes); for
|
||||
/// everything else we guess from the URL's `format=` value or fall back to None.
|
||||
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
||||
url.split('?')
|
||||
.nth(1)?
|
||||
.split('&')
|
||||
.find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preview_play(
|
||||
id: String,
|
||||
url: String,
|
||||
start_sec: f64,
|
||||
duration_sec: f64,
|
||||
volume: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let gen = state.preview_gen.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Tear down any existing preview before pausing main (so a rapid preview
|
||||
// swap doesn't double-pause and double-resume the main sink).
|
||||
let prev_sink = state.preview_sink.lock().unwrap().take();
|
||||
let prev_id = state.preview_song_id.lock().unwrap().take();
|
||||
if let Some(s) = prev_sink { s.stop(); }
|
||||
if let Some(prev) = prev_id {
|
||||
app.emit("audio:preview-end", PreviewEndPayload {
|
||||
id: prev,
|
||||
reason: "interrupted",
|
||||
}).ok();
|
||||
}
|
||||
|
||||
// Pause main if and only if we don't already hold a "main was playing"
|
||||
// marker from a superseded preview. swap_or-style: only pause if the flag
|
||||
// is currently false.
|
||||
if !state.preview_main_resume.load(Ordering::Acquire) {
|
||||
preview_pause_main(&state);
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
// Dedicated client with a generous timeout. The shared `audio_http_client`
|
||||
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
|
||||
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
|
||||
// ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still
|
||||
// bounds how long the preview plays once the bytes are in memory, so a
|
||||
// long download just means a longer "loading" spinner before audio starts.
|
||||
let preview_http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(crate::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(&state));
|
||||
let bytes = preview_http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
// A newer preview started while we were downloading — bail.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ───────────────────────────────────────────────────────────────
|
||||
let hint = preview_format_hint_from_url(&url);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// ── Build source pipeline ────────────────────────────────────────────────
|
||||
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
||||
// before the seek made take_duration's wall-clock counter tick from
|
||||
// sink.append() while try_seek was still iterating the decoder to
|
||||
// mid-track — the preview window consumed itself before audio actually
|
||||
// arrived at the speaker (~25% of duration silent on FLAC/MP3 mid-track
|
||||
// starts). Symphonia FLAC without SEEKTABLE may fail try_seek; preview
|
||||
// then plays from 0, which is acceptable.
|
||||
// No EQ / no crossfade / no ReplayGain — preview stays simple.
|
||||
let mut source = decoder;
|
||||
if start_sec > 0.5 {
|
||||
let _ = source.try_seek(Duration::from_secs_f64(start_sec));
|
||||
}
|
||||
let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0));
|
||||
let source = source.take_duration(dur);
|
||||
let source = PriorityBoostSource::new(source);
|
||||
|
||||
// ── Build secondary sink on the existing OutputStream ────────────────────
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(source);
|
||||
|
||||
*state.preview_sink.lock().unwrap() = Some(sink.clone());
|
||||
*state.preview_song_id.lock().unwrap() = Some(id.clone());
|
||||
|
||||
app.emit("audio:preview-start", id.clone()).ok();
|
||||
|
||||
// ── Spawn watchdog: progress emits + auto-end ────────────────────────────
|
||||
let preview_gen_arc = state.preview_gen.clone();
|
||||
let preview_sink_arc = state.preview_sink.clone();
|
||||
let preview_song_arc = state.preview_song_id.clone();
|
||||
let preview_resume_arc = state.preview_main_resume.clone();
|
||||
let main_current = state.current.clone();
|
||||
let app_for_task = app.clone();
|
||||
let id_for_task = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let started = Instant::now();
|
||||
let mut last_emit = Instant::now() - Duration::from_millis(300);
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
// Cancel: another preview started or audio_preview_stop bumped the gen.
|
||||
if preview_gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
let elapsed = started.elapsed().as_secs_f64();
|
||||
let dur_secs = dur.as_secs_f64();
|
||||
|
||||
if last_emit.elapsed() >= Duration::from_millis(250) {
|
||||
last_emit = Instant::now();
|
||||
app_for_task.emit("audio:preview-progress", PreviewProgressPayload {
|
||||
id: id_for_task.clone(),
|
||||
elapsed: elapsed.min(dur_secs),
|
||||
duration: dur_secs,
|
||||
}).ok();
|
||||
}
|
||||
|
||||
// Natural end: timer expired OR sink drained early (decode error,
|
||||
// short track, etc.).
|
||||
let drained = match preview_sink_arc.lock().unwrap().as_ref() {
|
||||
Some(s) => s.empty(),
|
||||
None => true,
|
||||
};
|
||||
if elapsed >= dur_secs || drained {
|
||||
// Re-check generation under the cleanup lock to avoid racing
|
||||
// a fresh preview that bumped the counter.
|
||||
if preview_gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
if let Some(s) = preview_sink_arc.lock().unwrap().take() { s.stop(); }
|
||||
let cleared_id = preview_song_arc.lock().unwrap().take()
|
||||
.unwrap_or_else(|| id_for_task.clone());
|
||||
|
||||
// Resume main if we paused it.
|
||||
if preview_resume_arc.swap(false, Ordering::AcqRel) {
|
||||
let mut cur = main_current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app_for_task.emit("audio:preview-end", PreviewEndPayload {
|
||||
id: cleared_id,
|
||||
reason: "natural",
|
||||
}).ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, true);
|
||||
}
|
||||
|
||||
/// Like `audio_preview_stop` but leaves the main sink paused even if it had
|
||||
/// been paused by `preview_pause_main`. Used by the player-bar Stop button so
|
||||
/// "stop everything" actually goes silent — without this the engine would
|
||||
/// auto-resume main playback the moment the preview ends and the user perceives
|
||||
/// the click as having no effect.
|
||||
#[tauri::command]
|
||||
pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, false);
|
||||
}
|
||||
|
||||
/// Update the preview sink volume while a preview is in flight. Mirrors
|
||||
/// `audio_set_volume` for the main sink. The frontend already folds in any
|
||||
/// LUFS pre-analysis attenuation before calling, just like it does at preview
|
||||
/// start, so the engine just clamps and applies the master headroom. No-op
|
||||
/// when no preview is active.
|
||||
#[tauri::command]
|
||||
pub fn audio_preview_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
if let Some(sink) = state.preview_sink.lock().unwrap().as_ref() {
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn preview_stop_inner(app: &AppHandle, state: &AudioEngine, resume_main: bool) {
|
||||
state.preview_gen.fetch_add(1, Ordering::SeqCst);
|
||||
let sink = state.preview_sink.lock().unwrap().take();
|
||||
let id = state.preview_song_id.lock().unwrap().take();
|
||||
if let Some(s) = sink { s.stop(); }
|
||||
|
||||
if resume_main {
|
||||
preview_resume_main(state);
|
||||
} else {
|
||||
state.preview_main_resume.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
if let Some(id) = id {
|
||||
app.emit("audio:preview-end", PreviewEndPayload {
|
||||
id,
|
||||
reason: "user",
|
||||
}).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
//! Per-generation progress + ended-detection task. Spawned once per
|
||||
//! `audio_play` / `audio_play_radio` invocation, the task ticks at 100 ms,
|
||||
//! emits `audio:progress` (throttled), handles the gapless transition
|
||||
//! when the current source exhausts and a chained successor is queued,
|
||||
//! and finally emits `audio:ended` when no successor exists.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::engine::AudioCurrent;
|
||||
use super::helpers::{ramp_sink_volume, ProgressPayload, MASTER_HEADROOM};
|
||||
use super::state::ChainedInfo;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
//! Live internet-radio playback. Distinct from main track playback: no
|
||||
//! gapless chain, no seek, no replay-gain, no preload.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::{HeapCons, HeapRb};
|
||||
use rodio::{Player, Source};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{content_type_to_hint, MASTER_HEADROOM};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::sources::{
|
||||
CountingSource, DynSource, EqSource, EqualPowerFadeIn, NotifyingSource,
|
||||
PriorityBoostSource, TriggeredFadeOut,
|
||||
};
|
||||
use super::stream::{
|
||||
radio_download_task, AudioStreamReader, RadioLiveState, RadioSharedFlags,
|
||||
RADIO_BUF_CAPACITY, RADIO_READ_TIMEOUT_SECS,
|
||||
};
|
||||
|
||||
/// Play a live internet radio stream.
|
||||
///
|
||||
/// Sends `Icy-MetaData: 1` to request inline ICY metadata.
|
||||
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
|
||||
/// and `radio:metadata` whenever the StreamTitle changes.
|
||||
#[tauri::command]
|
||||
pub async fn audio_play_radio(
|
||||
url: String,
|
||||
volume: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Cancel any active preview so it doesn't keep playing alongside radio.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Abort any previous radio task before stopping the sink.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() { old.stop(); }
|
||||
}
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); }
|
||||
|
||||
// ── Open initial HTTP connection ──────────────────────────────────────────
|
||||
let response = audio_http_client(&state)
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let m = format!("radio: connection failed: {e}");
|
||||
app.emit("audio:error", &m).ok();
|
||||
m
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let m = format!("radio: HTTP {}", response.status());
|
||||
app.emit("audio:error", &m).ok();
|
||||
return Err(m);
|
||||
}
|
||||
|
||||
let fmt_hint = content_type_to_hint(
|
||||
response.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
|
||||
// ── Build 4 MB lock-free SPSC ring buffer ─────────────────────────────────
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (prod, cons) = rb.split();
|
||||
|
||||
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
let flags = Arc::new(RadioSharedFlags {
|
||||
is_paused: AtomicBool::new(false),
|
||||
is_hard_paused: AtomicBool::new(false),
|
||||
new_cons_tx: Mutex::new(new_cons_tx),
|
||||
});
|
||||
|
||||
// ── Spawn download task ───────────────────────────────────────────────────
|
||||
let task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
Some(response),
|
||||
audio_http_client(&state),
|
||||
url.clone(),
|
||||
prod,
|
||||
flags.clone(),
|
||||
app.clone(),
|
||||
));
|
||||
|
||||
*state.radio_state.lock().unwrap() = Some(RadioLiveState {
|
||||
url: url.clone(),
|
||||
gen,
|
||||
task,
|
||||
flags: flags.clone(),
|
||||
});
|
||||
|
||||
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
|
||||
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: "radio",
|
||||
eof_when_empty: None,
|
||||
pos: 0,
|
||||
};
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
||||
let dyn_src = DynSource::new(decoder);
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
|
||||
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((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(boosted);
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() { old.stop(); }
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = 0.0; // sentinel: live stream
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = 1.0;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(fadeout_trigger);
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
}
|
||||
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
|
||||
state.current_sample_rate.store(sample_rate.get(), Ordering::Relaxed);
|
||||
state.current_channels.store(channels.get() as u32, Ordering::Relaxed);
|
||||
|
||||
app.emit("audio:playing", 0.0f64).ok();
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
//! Rodio `Source` wrappers: EQ, type erasure, fades, end-of-source notify, sample counter.
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
||||
use rodio::Source;
|
||||
|
||||
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||
|
||||
const EQ_BANDS_HZ: [f32; 10] = [31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
const EQ_Q: f32 = 1.41;
|
||||
const EQ_CHECK_INTERVAL: usize = 1024;
|
||||
|
||||
pub(crate) struct EqSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_rate: rodio::SampleRate,
|
||||
channels: rodio::ChannelCount,
|
||||
gains: Arc<[AtomicU32; 10]>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
pre_gain: Arc<AtomicU32>,
|
||||
filters: [[DirectForm2Transposed<f32>; 2]; 10],
|
||||
current_gains: [f32; 10],
|
||||
sample_counter: usize,
|
||||
channel_idx: usize,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> EqSource<S> {
|
||||
pub(crate) fn new(inner: S, gains: Arc<[AtomicU32; 10]>, enabled: Arc<AtomicBool>, pre_gain: Arc<AtomicU32>) -> Self {
|
||||
let sample_rate = inner.sample_rate();
|
||||
let channels = inner.channels();
|
||||
let filters = std::array::from_fn(|band| {
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
|
||||
std::array::from_fn(|_| {
|
||||
let coeffs = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(0.0),
|
||||
(sample_rate.get() as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
).unwrap_or_else(|_| Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(0.0),
|
||||
(sample_rate.get() as f32).hz(),
|
||||
1000.0f32.hz(),
|
||||
EQ_Q,
|
||||
).unwrap());
|
||||
DirectForm2Transposed::<f32>::new(coeffs)
|
||||
})
|
||||
});
|
||||
Self {
|
||||
inner, sample_rate, channels, gains, enabled, pre_gain,
|
||||
filters,
|
||||
current_gains: [0.0; 10],
|
||||
sample_counter: 0,
|
||||
channel_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_if_needed(&mut self) {
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
if (gain_db - self.current_gains[band]).abs() > 0.01 {
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate.get() as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
for ch in 0..2 {
|
||||
self.filters[band][ch].update_coefficients(coeffs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for EqSource<S> {
|
||||
type Item = f32;
|
||||
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next()?;
|
||||
|
||||
if self.sample_counter % EQ_CHECK_INTERVAL == 0 {
|
||||
self.refresh_if_needed();
|
||||
}
|
||||
self.sample_counter = self.sample_counter.wrapping_add(1);
|
||||
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
self.channel_idx = (self.channel_idx + 1) % self.channels.get() as usize;
|
||||
return Some(sample);
|
||||
}
|
||||
|
||||
let ch = self.channel_idx.min(1);
|
||||
self.channel_idx = (self.channel_idx + 1) % self.channels.get() as usize;
|
||||
|
||||
let pre_gain_db = f32::from_bits(self.pre_gain.load(Ordering::Relaxed));
|
||||
let pre_gain_factor = 10_f32.powf(pre_gain_db / 20.0);
|
||||
let mut s = sample * pre_gain_factor;
|
||||
for band in 0..10 {
|
||||
s = self.filters[band][ch].run(s);
|
||||
}
|
||||
Some(s.clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.channels }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Reset biquad filter state to avoid glitches after seek.
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate.get() as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
for ch in 0..2 {
|
||||
self.filters[band][ch] = DirectForm2Transposed::<f32>::new(coeffs);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.channel_idx = 0;
|
||||
self.sample_counter = 0;
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DynSource — type-erased Source wrapper ───────────────────────────────────
|
||||
//
|
||||
// Allows chaining differently-typed sources (with trimming applied) into a
|
||||
// single concrete type accepted by EqSource<S: Source<Item=f32>>.
|
||||
|
||||
pub(crate) struct DynSource {
|
||||
inner: Box<dyn Source<Item = f32> + Send>,
|
||||
channels: rodio::ChannelCount,
|
||||
sample_rate: rodio::SampleRate,
|
||||
}
|
||||
|
||||
impl DynSource {
|
||||
pub(crate) fn new(src: impl Source<Item = f32> + Send + 'static) -> Self {
|
||||
let channels = src.channels();
|
||||
let sample_rate = src.sample_rate();
|
||||
Self { inner: Box::new(src), channels, sample_rate }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for DynSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> { self.inner.next() }
|
||||
}
|
||||
|
||||
impl Source for DynSource {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.channels }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EqualPowerFadeIn — per-sample sin(t·π/2) fade-in envelope ───────────────
|
||||
//
|
||||
// Applied to every new track:
|
||||
// • Crossfade: fade_dur = crossfade_secs → symmetric equal-power fade-in
|
||||
// • Hard cut: fade_dur = 5 ms → micro-fade eliminates DC-click
|
||||
// • Gapless: fade_dur = 0 → unity gain (no modification)
|
||||
//
|
||||
// gain(t) = sin(t · π/2), t ∈ [0, 1)
|
||||
// At t = 0 gain = 0, at t = 1 gain = 1.
|
||||
// Equal-power property: cos²+sin² = 1 → combined with cos fade-out on Track A
|
||||
// the total perceived loudness stays constant across the crossfade.
|
||||
|
||||
pub(crate) struct EqualPowerFadeIn<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_count: u64,
|
||||
fade_samples: u64,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> EqualPowerFadeIn<S> {
|
||||
pub(crate) fn new(inner: S, fade_dur: Duration) -> Self {
|
||||
let sample_rate = inner.sample_rate();
|
||||
let channels = inner.channels().get() as u64;
|
||||
let fade_samples = if fade_dur.is_zero() {
|
||||
0
|
||||
} else {
|
||||
(fade_dur.as_secs_f64() * sample_rate.get() as f64 * channels as f64) as u64
|
||||
};
|
||||
Self { inner, sample_count: 0, fade_samples }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next()?;
|
||||
let gain = if self.fade_samples == 0 || self.sample_count >= self.fade_samples {
|
||||
1.0
|
||||
} else {
|
||||
let t = self.sample_count as f32 / self.fade_samples as f32;
|
||||
(t * std::f32::consts::FRAC_PI_2).sin()
|
||||
};
|
||||
self.sample_count += 1;
|
||||
Some((sample * gain).clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// For mid-track seeks: skip straight to unity gain so the new position
|
||||
// plays at full volume immediately — no audible fade-in glitch.
|
||||
// For seeks to the very start (< 100 ms): keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
if pos.as_millis() < 100 {
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TriggeredFadeOut — sample-level cos(t·π/2) fade-out triggered externally ─
|
||||
//
|
||||
// Every track source is wrapped with this. It passes through at unity gain
|
||||
// until `trigger` is set to true, at which point it reads `fade_total_samples`
|
||||
// and applies a cos(t·π/2) envelope:
|
||||
// gain(t) = cos(t · π/2), t ∈ [0, 1]
|
||||
// At t = 0 gain = 1, at t = 1 gain = 0.
|
||||
// After the fade completes, returns None to exhaust the source.
|
||||
//
|
||||
// Combined with EqualPowerFadeIn (sin curve) on Track B, this gives a
|
||||
// symmetric constant-power crossfade: sin²+cos² = 1.
|
||||
|
||||
pub(crate) struct TriggeredFadeOut<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
trigger: Arc<AtomicBool>,
|
||||
fade_total_samples: Arc<AtomicU64>,
|
||||
fade_progress: u64,
|
||||
fading: bool,
|
||||
cached_total: u64,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> TriggeredFadeOut<S> {
|
||||
pub(crate) fn new(inner: S, trigger: Arc<AtomicBool>, fade_total_samples: Arc<AtomicU64>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
trigger,
|
||||
fade_total_samples,
|
||||
fade_progress: 0,
|
||||
fading: false,
|
||||
cached_total: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
// Check trigger on first fade sample only (avoid atomic load per sample).
|
||||
if !self.fading && self.trigger.load(Ordering::Relaxed) {
|
||||
self.fading = true;
|
||||
self.cached_total = self.fade_total_samples.load(Ordering::Relaxed).max(1);
|
||||
self.fade_progress = 0;
|
||||
}
|
||||
|
||||
if self.fading {
|
||||
if self.fade_progress >= self.cached_total {
|
||||
// Fade complete — exhaust the source.
|
||||
return None;
|
||||
}
|
||||
let sample = self.inner.next()?;
|
||||
let t = self.fade_progress as f32 / self.cached_total as f32;
|
||||
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
|
||||
self.fade_progress += 1;
|
||||
Some((sample * gain).clamp(-1.0, 1.0))
|
||||
} else {
|
||||
self.inner.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for TriggeredFadeOut<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// If we seek back during a fade, cancel the fade.
|
||||
if self.fading {
|
||||
self.fading = false;
|
||||
self.trigger.store(false, Ordering::Relaxed);
|
||||
}
|
||||
self.fade_progress = 0;
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
|
||||
//
|
||||
// This is the key mechanism for gapless: the progress task polls `done` to know
|
||||
// exactly when source N has finished inside the Sink, without relying on
|
||||
// wall-clock estimation or the unreliable `Sink::empty()`.
|
||||
|
||||
pub(crate) struct NotifyingSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
done: Arc<AtomicBool>,
|
||||
signalled: bool,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> NotifyingSource<S> {
|
||||
pub(crate) fn new(inner: S, done: Arc<AtomicBool>) -> Self {
|
||||
Self { inner, done, signalled: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for NotifyingSource<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next();
|
||||
if sample.is_none() && !self.signalled {
|
||||
self.signalled = true;
|
||||
self.done.store(true, Ordering::SeqCst);
|
||||
}
|
||||
sample
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// If we seek backwards the source is no longer exhausted.
|
||||
self.signalled = false;
|
||||
self.done.store(false, Ordering::SeqCst);
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CountingSource — atomic sample counter for drift-free position tracking ─
|
||||
//
|
||||
// Wraps the outermost source and increments a shared AtomicU64 on every sample.
|
||||
// The progress task reads this counter and divides by (sample_rate * channels)
|
||||
// to get the exact playback position — no wall-clock drift.
|
||||
|
||||
pub(crate) struct CountingSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
counter: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> CountingSource<S> {
|
||||
pub(crate) fn new(inner: S, counter: Arc<AtomicU64>) -> Self {
|
||||
Self { inner, counter }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for CountingSource<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next();
|
||||
if sample.is_some() {
|
||||
self.counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
sample
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Reset counter only after confirming the inner seek succeeded.
|
||||
// If we reset first and the seek fails, the counter ends up at the
|
||||
// new position while the decoder is still at the old one — causing
|
||||
// a permanent desync between displayed time and actual audio.
|
||||
let result = self.inner.try_seek(pos);
|
||||
if result.is_ok() {
|
||||
let samples = (pos.as_secs_f64() * self.inner.sample_rate().get() as f64
|
||||
* self.inner.channels().get() as f64) as u64;
|
||||
self.counter.store(samples, Ordering::Relaxed);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PriorityBoostSource — promote the calling thread on first sample ────────
|
||||
//
|
||||
// rodio's `Sink` runs `Source::next` inside the cpal output-stream callback.
|
||||
// On Windows that callback is the WASAPI render thread, which by default has
|
||||
// only normal priority — when WebView2 / DWM / GPU work spikes the system,
|
||||
// the audio thread gets preempted and underruns produce audible click /
|
||||
// stutter. This wrapper sets the MMCSS "Pro Audio" task class on the first
|
||||
// `next()` call so the kernel keeps the render thread on a real-time class
|
||||
// alongside other audio applications. On Linux/macOS the wrapper compiles to
|
||||
// a no-op — those platforms already promote their audio threads externally
|
||||
// (PipeWire/rtkit, CoreAudio).
|
||||
//
|
||||
// Idempotent across track changes: each new track instantiates a fresh
|
||||
// PriorityBoostSource, but `AvSetMmThreadCharacteristicsW` can be called
|
||||
// repeatedly on the same thread.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn promote_thread_to_pro_audio() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::Threading::AvSetMmThreadCharacteristicsW;
|
||||
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// Null-terminated UTF-16 task name, lifetime-pinned for the call.
|
||||
let task: [u16; 10] = [
|
||||
b'P' as u16, b'r' as u16, b'o' as u16, b' ' as u16,
|
||||
b'A' as u16, b'u' as u16, b'd' as u16, b'i' as u16,
|
||||
b'o' as u16, 0,
|
||||
];
|
||||
let mut idx: u32 = 0;
|
||||
let result = unsafe { AvSetMmThreadCharacteristicsW(PCWSTR(task.as_ptr()), &mut idx) };
|
||||
|
||||
if result.is_ok() && !LOGGED.swap(true, Ordering::Relaxed) {
|
||||
// First-time log: not in the hot path on subsequent track starts.
|
||||
// Logging is file IO (blocking) but we only run it once per process
|
||||
// lifetime, on the very first render-callback invocation.
|
||||
crate::app_eprintln!("[psysonic] WASAPI render thread promoted to MMCSS \"Pro Audio\"");
|
||||
}
|
||||
// Handle leaks intentionally — promotion lasts until the thread exits,
|
||||
// which matches the WASAPI render-thread lifetime.
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[inline(always)]
|
||||
fn promote_thread_to_pro_audio() {}
|
||||
|
||||
pub(crate) struct PriorityBoostSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
promoted: bool,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> PriorityBoostSource<S> {
|
||||
pub(crate) fn new(inner: S) -> Self {
|
||||
Self { inner, promoted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for PriorityBoostSource<S> {
|
||||
type Item = f32;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if !self.promoted {
|
||||
self.promoted = true;
|
||||
promote_thread_to_pro_audio();
|
||||
}
|
||||
self.inner.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for PriorityBoostSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! Small shared structs for preload / gapless chain metadata.
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) struct PreloadedTrack {
|
||||
pub(crate) url: String,
|
||||
pub(crate) data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Info about the track that has been appended (chained) to the current Sink
|
||||
/// but whose source has not yet started playing (gapless mode only).
|
||||
pub(crate) struct ChainedInfo {
|
||||
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
||||
pub(crate) url: String,
|
||||
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
|
||||
/// them instead of re-downloading after dropping the Sink queue.
|
||||
pub(crate) raw_bytes: Arc<Vec<u8>>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) replay_gain_linear: f32,
|
||||
pub(crate) base_volume: f32,
|
||||
/// Set by NotifyingSource when this chained track's source is exhausted.
|
||||
pub(crate) source_done: Arc<AtomicBool>,
|
||||
/// Atomic sample counter for this chained source (swapped into
|
||||
/// samples_played on transition).
|
||||
pub(crate) sample_counter: Arc<AtomicU64>,
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//! ICY (Shoutcast/Icecast) inline-metadata state machine.
|
||||
//!
|
||||
//! Streams embed metadata every `metaint` audio bytes:
|
||||
//!
|
||||
//! ┌──────────────────────┬───┬─────────────┐
|
||||
//! │ audio × metaint │ N │ meta × N×16 │ (repeating)
|
||||
//! └──────────────────────┴───┴─────────────┘
|
||||
//!
|
||||
//! N = 0 → no metadata this block. Metadata bytes are stripped so only
|
||||
//! pure audio reaches the ring buffer and Symphonia never sees text bytes.
|
||||
|
||||
pub(crate) enum IcyState {
|
||||
/// Forwarding audio bytes; `remaining` counts down to the next boundary.
|
||||
ReadingAudio { remaining: usize },
|
||||
/// Next byte is the metadata length multiplier N.
|
||||
ReadingLengthByte,
|
||||
/// Accumulating N×16 metadata bytes.
|
||||
ReadingMetadata { remaining: usize, buf: Vec<u8> },
|
||||
}
|
||||
|
||||
pub(crate) struct IcyInterceptor {
|
||||
state: IcyState,
|
||||
metaint: usize,
|
||||
}
|
||||
|
||||
impl IcyInterceptor {
|
||||
pub(crate) fn new(metaint: usize) -> Self {
|
||||
Self { metaint, state: IcyState::ReadingAudio { remaining: metaint } }
|
||||
}
|
||||
|
||||
/// Feed a raw HTTP chunk.
|
||||
/// Appends only audio bytes to `audio_out`.
|
||||
/// Returns `Some(IcyMeta)` when a StreamTitle is extracted.
|
||||
pub(crate) fn process(&mut self, input: &[u8], audio_out: &mut Vec<u8>) -> Option<IcyMeta> {
|
||||
let mut extracted: Option<IcyMeta> = None;
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
match &mut self.state {
|
||||
IcyState::ReadingAudio { remaining } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
audio_out.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
self.state = IcyState::ReadingLengthByte;
|
||||
}
|
||||
}
|
||||
IcyState::ReadingLengthByte => {
|
||||
let len_n = input[i] as usize;
|
||||
i += 1;
|
||||
self.state = if len_n == 0 {
|
||||
IcyState::ReadingAudio { remaining: self.metaint }
|
||||
} else {
|
||||
IcyState::ReadingMetadata {
|
||||
remaining: len_n * 16,
|
||||
buf: Vec::with_capacity(len_n * 16),
|
||||
}
|
||||
};
|
||||
}
|
||||
IcyState::ReadingMetadata { remaining, buf } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
buf.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
let bytes = std::mem::take(buf);
|
||||
extracted = parse_icy_meta(&bytes);
|
||||
self.state = IcyState::ReadingAudio { remaining: self.metaint };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extracted
|
||||
}
|
||||
}
|
||||
|
||||
/// ICY metadata parsed from a raw metadata block.
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct IcyMeta {
|
||||
pub title: String,
|
||||
/// `true` when `StreamUrl='0'` — indicates a CDN-injected ad/promo.
|
||||
pub is_ad: bool,
|
||||
}
|
||||
|
||||
/// Extract `StreamTitle` and `StreamUrl` from a raw ICY metadata block.
|
||||
/// Tolerates null padding and non-UTF-8 bytes (lossy conversion).
|
||||
fn parse_icy_meta(raw: &[u8]) -> Option<IcyMeta> {
|
||||
let s = String::from_utf8_lossy(raw);
|
||||
let s = s.trim_end_matches('\0');
|
||||
|
||||
const TITLE_TAG: &str = "StreamTitle='";
|
||||
let title_start = s.find(TITLE_TAG)? + TITLE_TAG.len();
|
||||
let title_rest = &s[title_start..];
|
||||
// find (not rfind) — rfind would skip past StreamUrl and corrupt the title
|
||||
let title_end = title_rest.find("';")?;
|
||||
let title = title_rest[..title_end].trim().to_string();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
const URL_TAG: &str = "StreamUrl='";
|
||||
let stream_url = s.find(URL_TAG).map(|pos| {
|
||||
let rest = &s[pos + URL_TAG.len()..];
|
||||
let end = rest.find("';").unwrap_or(rest.len());
|
||||
rest[..end].trim().to_string()
|
||||
}).unwrap_or_default();
|
||||
|
||||
Some(IcyMeta { title, is_ad: stream_url == "0" })
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//! `LocalFileSource` — seekable `MediaSource` backed directly by `std::fs::File`.
|
||||
//!
|
||||
//! Used for `psysonic-local://` URLs (offline library + hot playback cache hits).
|
||||
//! Lets Symphonia read on-demand from disk during the probe (~64 KB) instead of
|
||||
//! the previous behaviour of `tokio::fs::read` blocking until the entire file
|
||||
//! (often 100+ MB for hi-res FLAC) was loaded into RAM. Track-start is instant.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
use symphonia::core::io::MediaSource;
|
||||
|
||||
pub(crate) struct LocalFileSource {
|
||||
pub(crate) file: std::fs::File,
|
||||
pub(crate) len: u64,
|
||||
}
|
||||
|
||||
impl Read for LocalFileSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.file.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for LocalFileSource {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.file.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for LocalFileSource {
|
||||
fn is_seekable(&self) -> bool { true }
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! HTTP-backed and file-backed `MediaSource` implementations plus their
|
||||
//! background download tasks.
|
||||
//!
|
||||
//! Submodule layout:
|
||||
//! - `icy` — Shoutcast/Icecast inline-metadata state machine
|
||||
//! - `reader` — `AudioStreamReader` (ringbuf → `std::io::Read` shim)
|
||||
//! - `local_file` — `LocalFileSource` (file-backed, seekable)
|
||||
//! - `ranged_http` — `RangedHttpSource` (seekable HTTP) + `ranged_download_task`
|
||||
//! - `radio` — radio session state + `radio_download_task`
|
||||
//! - `track_stream` — `track_download_task` (one-shot non-ranged HTTP)
|
||||
|
||||
mod icy;
|
||||
mod local_file;
|
||||
mod radio;
|
||||
mod ranged_http;
|
||||
mod reader;
|
||||
mod track_stream;
|
||||
|
||||
pub(crate) use local_file::LocalFileSource;
|
||||
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
|
||||
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use reader::AudioStreamReader;
|
||||
pub(crate) use track_stream::track_download_task;
|
||||
|
||||
// ── Shared tuning constants ──────────────────────────────────────────────────
|
||||
|
||||
/// 256 KB on the heap — ≈16 s at 128 kbps, ≈6 s at 320 kbps.
|
||||
/// Small enough that stale audio drains within a few seconds on reconnect;
|
||||
/// large enough to absorb brief network hiccups without stuttering.
|
||||
pub(crate) const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
||||
/// Minimum ring buffer for on-demand track streaming starts.
|
||||
pub(crate) const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024;
|
||||
/// Cap ring buffer growth when content-length is known.
|
||||
pub(crate) const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024;
|
||||
/// Max bytes kept in memory to promote a completed streamed track for fast replay/seek recovery.
|
||||
pub(crate) const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
/// Hot/offline `psysonic-local://` files are read from disk for waveform/LUFS seeding — not the
|
||||
/// same heap pressure as retaining a full HTTP capture. FLAC/DSD tracks often exceed 64 MiB;
|
||||
/// using the stream-promote cap here skipped analysis entirely (empty seekbar).
|
||||
pub(crate) const LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES: usize = 512 * 1024 * 1024;
|
||||
/// Consecutive body-stream failures tolerated for track streaming before abort.
|
||||
pub(crate) const TRACK_STREAM_MAX_RECONNECTS: u32 = 3;
|
||||
/// Seconds at stall threshold while paused before hard-disconnect.
|
||||
pub(crate) const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
||||
/// AudioStreamReader timeout: if no audio bytes arrive for this long → EOF.
|
||||
pub(crate) const RADIO_READ_TIMEOUT_SECS: u64 = 15;
|
||||
/// Sleep interval when ring buffer is empty (prevents CPU spin).
|
||||
pub(crate) const RADIO_YIELD_MS: u64 = 2;
|
||||
@@ -1,187 +0,0 @@
|
||||
//! Live internet-radio session state and the async HTTP download task.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 'outer loop — reconnect on TCP drop (up to MAX_CONSECUTIVE_FAILURES)
|
||||
//! 'inner loop — read HTTP chunks → ICY interceptor → push audio to ring buffer
|
||||
//!
|
||||
//! Hard-pause detection: if push_slice() returns 0 (buffer full) AND sink is
|
||||
//! paused AND that condition persists for `RADIO_HARD_PAUSE_SECS` → disconnect.
|
||||
//! Sets `is_hard_paused = true` so audio_resume knows it must reconnect.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use ringbuf::HeapCons;
|
||||
use ringbuf::HeapProd;
|
||||
use ringbuf::traits::{Observer, Producer};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::icy::IcyInterceptor;
|
||||
use super::{RADIO_BUF_CAPACITY, RADIO_HARD_PAUSE_SECS};
|
||||
|
||||
pub(crate) struct RadioSharedFlags {
|
||||
/// Set by audio_pause; cleared by audio_resume.
|
||||
pub(crate) is_paused: AtomicBool,
|
||||
/// Set by download task on hard disconnect; cleared on resume-reconnect.
|
||||
pub(crate) is_hard_paused: AtomicBool,
|
||||
/// Delivers a fresh HeapCons<u8> to AudioStreamReader on reconnect.
|
||||
pub(crate) new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapCons<u8>>>,
|
||||
}
|
||||
|
||||
/// Live state for the current radio session, stored in AudioEngine.
|
||||
/// Dropping this struct aborts the HTTP download task immediately.
|
||||
pub(crate) struct RadioLiveState {
|
||||
pub url: String,
|
||||
pub gen: u64,
|
||||
pub task: tokio::task::JoinHandle<()>,
|
||||
pub flags: Arc<RadioSharedFlags>,
|
||||
}
|
||||
|
||||
impl Drop for RadioLiveState {
|
||||
fn drop(&mut self) { self.task.abort(); }
|
||||
}
|
||||
|
||||
pub(crate) async fn radio_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
mut initial_response: Option<reqwest::Response>,
|
||||
http_client: reqwest::Client,
|
||||
url: String,
|
||||
mut prod: HeapProd<u8>,
|
||||
flags: Arc<RadioSharedFlags>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let mut bytes_total: u64 = 0;
|
||||
// Counts consecutive failures (reset on each successful chunk).
|
||||
// laut.fm and similar CDNs force-reconnect every ~700 KB; this is normal.
|
||||
let mut reconnect_count: u32 = 0;
|
||||
const MAX_CONSECUTIVE_FAILURES: u32 = 5;
|
||||
let mut audio_scratch: Vec<u8> = Vec::with_capacity(65_536);
|
||||
|
||||
'outer: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Obtain response (initial or reconnect) ────────────────────────────
|
||||
let response = match initial_response.take() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
if reconnect_count >= MAX_CONSECUTIVE_FAILURES {
|
||||
crate::app_eprintln!("[radio] {MAX_CONSECUTIVE_FAILURES} consecutive failures — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
match http_client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
crate::app_eprintln!("[radio] reconnected ({bytes_total} B so far)");
|
||||
r
|
||||
}
|
||||
Ok(r) => {
|
||||
crate::app_eprintln!("[radio] reconnect: HTTP {} — giving up", r.status());
|
||||
break 'outer;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[radio] reconnect error: {e} — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse ICY metaint from each response (consistent across reconnects).
|
||||
let metaint: Option<usize> = response
|
||||
.headers()
|
||||
.get("icy-metaint")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok());
|
||||
let mut icy = metaint.map(IcyInterceptor::new);
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
// Stall timer: tracks how long push_slice() returns 0 while paused.
|
||||
let mut stall_since: Option<std::time::Instant> = None;
|
||||
|
||||
'inner: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Back-pressure + hard-pause detection ──────────────────────────
|
||||
if prod.is_full() {
|
||||
if flags.is_paused.load(Ordering::Relaxed) {
|
||||
let since = stall_since.get_or_insert(std::time::Instant::now());
|
||||
if since.elapsed() >= Duration::from_secs(RADIO_HARD_PAUSE_SECS) {
|
||||
let fill_pct = ((1.0
|
||||
- prod.vacant_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
||||
* 100.0) as u32;
|
||||
crate::app_eprintln!(
|
||||
"[radio] hard pause: {fill_pct}% full, \
|
||||
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
|
||||
);
|
||||
flags.is_hard_paused.store(true, Ordering::Release);
|
||||
return; // Drop HeapProd → TCP connection released.
|
||||
}
|
||||
} else {
|
||||
stall_since = None;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
continue 'inner;
|
||||
}
|
||||
stall_since = None;
|
||||
|
||||
// ── Read HTTP chunk ───────────────────────────────────────────────
|
||||
match byte_stream.next().await {
|
||||
Some(Ok(chunk)) => {
|
||||
bytes_total += chunk.len() as u64;
|
||||
// Successful data → reset consecutive-failure counter.
|
||||
reconnect_count = 0;
|
||||
audio_scratch.clear();
|
||||
|
||||
if let Some(ref mut interceptor) = icy {
|
||||
if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) {
|
||||
let label = if meta.is_ad { "[Ad]" } else { "" };
|
||||
crate::app_eprintln!("[radio] ICY StreamTitle: {}{}", label, meta.title);
|
||||
let _ = app.emit("radio:metadata", &meta);
|
||||
}
|
||||
} else {
|
||||
audio_scratch.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Push with per-chunk back-pressure: yield 5 ms if full mid-chunk.
|
||||
let mut offset = 0;
|
||||
while offset < audio_scratch.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
let pushed = prod.push_slice(&audio_scratch[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
offset += pushed;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
reconnect_count += 1;
|
||||
crate::app_eprintln!("[radio] stream error: {e} → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
None => {
|
||||
reconnect_count += 1;
|
||||
crate::app_eprintln!("[radio] stream ended cleanly → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
}
|
||||
} // 'inner
|
||||
|
||||
// Do NOT swap the ring buffer here. The remaining bytes in the buffer
|
||||
// are still valid audio and will drain naturally during reconnect.
|
||||
// Clearing it would cause an immediate underrun/glitch.
|
||||
// The buffer is kept small (RADIO_BUF_CAPACITY) so stale audio drains
|
||||
// within a few seconds rather than minutes.
|
||||
} // 'outer
|
||||
|
||||
crate::app_eprintln!("[radio] download task done ({bytes_total} B total)");
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
//! `RangedHttpSource` — seekable HTTP-backed `MediaSource`, plus its
|
||||
//! background `ranged_download_task` linear filler.
|
||||
//!
|
||||
//! Pre-allocates a `Vec<u8>` of total track size. The download task fills it
|
||||
//! linearly from offset 0 via streaming HTTP. `Read` blocks (with timeout)
|
||||
//! until requested bytes are downloaded; `Seek` only updates the cursor.
|
||||
//!
|
||||
//! Reports `is_seekable=true` so Symphonia performs time-based seeks via the
|
||||
//! format reader. Backward seeks: instant (data in buffer). Forward seeks
|
||||
//! beyond `downloaded_to`: `Read` blocks until the linear download catches up.
|
||||
//!
|
||||
//! Requires the server to respond with both `Content-Length` and
|
||||
//! `Accept-Ranges: bytes` so reconnects can resume via HTTP `Range`.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use symphonia::core::io::MediaSource;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
RADIO_READ_TIMEOUT_SECS, RADIO_YIELD_MS, TRACK_STREAM_MAX_RECONNECTS,
|
||||
TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
};
|
||||
|
||||
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
|
||||
struct RangedLoudnessSeedHoldClear {
|
||||
slot: Arc<Mutex<Option<(String, u64)>>>,
|
||||
tid: String,
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
impl Drop for RangedLoudnessSeedHoldClear {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut g) = self.slot.lock() {
|
||||
if matches!(&*g, Some((t, gen)) if t == &self.tid && *gen == self.gen) {
|
||||
*g = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RangedHttpSource {
|
||||
/// Pre-allocated buffer of total size. Filled linearly from offset 0.
|
||||
pub(crate) buf: Arc<Mutex<Vec<u8>>>,
|
||||
/// Bytes contiguously downloaded from offset 0.
|
||||
pub(crate) downloaded_to: Arc<AtomicUsize>,
|
||||
pub(crate) total_size: u64,
|
||||
pub(crate) pos: u64,
|
||||
/// Set when the download task terminates (success or hard error).
|
||||
pub(crate) done: Arc<AtomicBool>,
|
||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
}
|
||||
|
||||
impl Read for RangedHttpSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream read EOF: superseded before first read (gen={} cur={} pos={}/{})",
|
||||
self.gen, self.gen_arc.load(Ordering::SeqCst), self.pos, self.total_size
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
if self.pos >= self.total_size {
|
||||
return Ok(0);
|
||||
}
|
||||
let max_read = ((self.total_size - self.pos) as usize).min(buf.len());
|
||||
if max_read == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let target_end = self.pos + max_read as u64;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream read EOF: superseded mid-wait (gen={} cur={} pos={}/{} dl={})",
|
||||
self.gen, self.gen_arc.load(Ordering::SeqCst), self.pos, self.total_size,
|
||||
self.downloaded_to.load(Ordering::SeqCst)
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
let dl = self.downloaded_to.load(Ordering::SeqCst) as u64;
|
||||
if dl >= target_end {
|
||||
break;
|
||||
}
|
||||
// Download finished but our cursor is past downloaded_to (e.g. seek
|
||||
// beyond a partial download that aborted). Return what we have.
|
||||
if self.done.load(Ordering::SeqCst) {
|
||||
if dl > self.pos {
|
||||
let avail = (dl - self.pos) as usize;
|
||||
let src = self.buf.lock().unwrap();
|
||||
let start = self.pos as usize;
|
||||
buf[..avail].copy_from_slice(&src[start..start + avail]);
|
||||
drop(src);
|
||||
self.pos += avail as u64;
|
||||
return Ok(avail);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream read EOF: download done with no data ahead of cursor (pos={}/{} dl={})",
|
||||
self.pos, self.total_size, dl
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"ranged-http: no data within timeout",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
||||
}
|
||||
|
||||
let src = self.buf.lock().unwrap();
|
||||
let start = self.pos as usize;
|
||||
let end = start + max_read;
|
||||
buf[..max_read].copy_from_slice(&src[start..end]);
|
||||
drop(src);
|
||||
self.pos += max_read as u64;
|
||||
Ok(max_read)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for RangedHttpSource {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
let new_pos: i64 = match pos {
|
||||
SeekFrom::Start(p) => p as i64,
|
||||
SeekFrom::Current(p) => self.pos as i64 + p,
|
||||
SeekFrom::End(p) => self.total_size as i64 + p,
|
||||
};
|
||||
if new_pos < 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"ranged-http: seek before start",
|
||||
));
|
||||
}
|
||||
self.pos = (new_pos as u64).min(self.total_size);
|
||||
Ok(self.pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for RangedHttpSource {
|
||||
fn is_seekable(&self) -> bool { true }
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.total_size) }
|
||||
}
|
||||
|
||||
/// Linear downloader for `RangedHttpSource`: fills the pre-allocated buffer
|
||||
/// from offset 0 to total_size. Reconnects via HTTP Range from the current
|
||||
/// `downloaded` offset on transient errors. On completion (full track) the
|
||||
/// data is promoted to `stream_completed_cache` for fast replay.
|
||||
pub(crate) async fn ranged_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
app: AppHandle,
|
||||
_duration_hint: f64,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
downloaded_to: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_engine: Arc<AtomicU32>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
cache_track_id: Option<String>,
|
||||
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
|
||||
// track; `None` for large files where ranged skips seed (needs backfill).
|
||||
loudness_seed_hold: Option<Arc<Mutex<Option<(String, u64)>>>>,
|
||||
) {
|
||||
let _ranged_loudness_hold_clear = match (loudness_seed_hold.as_ref(), cache_track_id.as_ref()) {
|
||||
(Some(slot), Some(tid)) => {
|
||||
let t = tid.clone();
|
||||
{
|
||||
let mut g = slot.lock().unwrap();
|
||||
*g = Some((t.clone(), gen));
|
||||
}
|
||||
Some(RangedLoudnessSeedHoldClear {
|
||||
slot: Arc::clone(slot),
|
||||
tid: t,
|
||||
gen,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let total_size = buf.lock().unwrap().len();
|
||||
let mut downloaded: usize = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let dl_started = Instant::now();
|
||||
let mut next_progress_mb: usize = 0;
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged dl start: total={} KiB (~{:.2} MiB)",
|
||||
total_size.saturating_div(1024),
|
||||
total_size as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
|
||||
'outer: loop {
|
||||
let response = if let Some(r) = next_response.take() {
|
||||
r
|
||||
} else {
|
||||
let mut req = http_client.get(&url);
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] ranged reconnect failed after {} attempts: {}",
|
||||
reconnects, err
|
||||
);
|
||||
break 'outer;
|
||||
}
|
||||
reconnects += 1;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
};
|
||||
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||
crate::app_eprintln!(
|
||||
"[audio] ranged reconnect returned {}, expected 206",
|
||||
response.status()
|
||||
);
|
||||
break 'outer;
|
||||
}
|
||||
if downloaded == 0 && !response.status().is_success() {
|
||||
crate::app_eprintln!("[audio] ranged HTTP {}", response.status());
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged dl superseded by skip: track_id={:?} gen={}→{} downloaded={}/{} bytes",
|
||||
cache_track_id, gen, gen_arc.load(Ordering::SeqCst), downloaded, total_size
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] ranged dl error after {} reconnects: {}",
|
||||
reconnects, e
|
||||
);
|
||||
break 'outer;
|
||||
}
|
||||
reconnects += 1;
|
||||
crate::app_eprintln!(
|
||||
"[audio] ranged dl error (attempt {}/{}): {} — reconnecting",
|
||||
reconnects, TRACK_STREAM_MAX_RECONNECTS, e
|
||||
);
|
||||
next_response = None;
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
reconnects = 0;
|
||||
let writable = total_size.saturating_sub(downloaded);
|
||||
if writable == 0 {
|
||||
break;
|
||||
}
|
||||
let n = chunk.len().min(writable);
|
||||
{
|
||||
let mut b = buf.lock().unwrap();
|
||||
b[downloaded..downloaded + n].copy_from_slice(&chunk[..n]);
|
||||
}
|
||||
downloaded += n;
|
||||
downloaded_to.store(downloaded, Ordering::SeqCst);
|
||||
if downloaded >= crate::audio::helpers::PARTIAL_LOUDNESS_MIN_BYTES
|
||||
&& total_size > 0
|
||||
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::audio::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
||||
{
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
if normalization_engine.load(Ordering::Relaxed) == 2 {
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed))
|
||||
.clamp(-24.0, 0.0);
|
||||
if let Some(provisional_db) =
|
||||
crate::audio::helpers::provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db)
|
||||
{
|
||||
let track_key = crate::audio::helpers::playback_identity(&url).unwrap_or_else(|| url.clone());
|
||||
if crate::audio::ipc::partial_loudness_should_emit(&track_key, provisional_db) {
|
||||
let _ = app.emit(
|
||||
"analysis:loudness-partial",
|
||||
crate::audio::ipc::PartialLoudnessPayload {
|
||||
track_id: crate::audio::helpers::playback_identity(&url),
|
||||
gain_db: provisional_db,
|
||||
target_lufs,
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mb = downloaded / (1024 * 1024);
|
||||
while mb >= next_progress_mb {
|
||||
let pct = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64 * 100.0) as u32
|
||||
} else {
|
||||
0u32
|
||||
};
|
||||
crate::app_deprintln!(
|
||||
"[stream] dl progress: {} MB / {} MB ({}%)",
|
||||
mb,
|
||||
total_size / (1024 * 1024),
|
||||
pct
|
||||
);
|
||||
next_progress_mb = mb + 1;
|
||||
}
|
||||
if downloaded >= total_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Stream ended cleanly (or hit total_size).
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
done.store(true, Ordering::SeqCst);
|
||||
|
||||
if downloaded < total_size {
|
||||
crate::app_eprintln!(
|
||||
"[stream] ranged dl ABORTED: {} / {} bytes in {:.2}s ({} reconnects, track_id={:?})",
|
||||
downloaded,
|
||||
total_size,
|
||||
dl_started.elapsed().as_secs_f64(),
|
||||
reconnects,
|
||||
cache_track_id
|
||||
);
|
||||
} else {
|
||||
crate::app_deprintln!(
|
||||
"[stream] dl done: {} / {} bytes in {:.2}s ({} reconnects)",
|
||||
downloaded,
|
||||
total_size,
|
||||
dl_started.elapsed().as_secs_f64(),
|
||||
reconnects
|
||||
);
|
||||
}
|
||||
|
||||
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
if let Some(ref tid) = cache_track_id {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: HTTP buffer full track_id={} size_mib={:.2} — cloning {} bytes then full-track analysis (cpu-seed queue; this task awaits completion)",
|
||||
tid,
|
||||
total_size as f64 / (1024.0 * 1024.0),
|
||||
total_size
|
||||
);
|
||||
}
|
||||
let t_clone = Instant::now();
|
||||
let data = buf.lock().unwrap().clone();
|
||||
if total_size > 32 * 1024 * 1024 {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: buffer cloned in_ms={}",
|
||||
t_clone.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
|
||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
//! `AudioStreamReader` — bridges an SPSC ring buffer (`HeapCons<u8>`) into the
|
||||
//! synchronous `std::io::Read` interface that Symphonia requires.
|
||||
//!
|
||||
//! Designed to run inside `tokio::task::spawn_blocking`.
|
||||
//!
|
||||
//! - Empty buffer: sleeps `RADIO_YIELD_MS` ms, retries. Never busy-spins.
|
||||
//! - Timeout: after `RADIO_READ_TIMEOUT_SECS` with no data → `TimedOut`.
|
||||
//! - Generation: if `gen_arc` != `self.gen` → `Ok(0)` (EOF; new track started).
|
||||
//! - Reconnect: `audio_resume` sends a fresh `HeapCons` via `new_cons_rx`.
|
||||
//! On the next read() we drain the channel (keep latest) and swap.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ringbuf::HeapCons;
|
||||
use ringbuf::traits::{Consumer, Observer};
|
||||
use symphonia::core::io::MediaSource;
|
||||
|
||||
use super::{RADIO_READ_TIMEOUT_SECS, RADIO_YIELD_MS};
|
||||
|
||||
pub(crate) struct AudioStreamReader {
|
||||
pub(crate) cons: Mutex<HeapCons<u8>>,
|
||||
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
|
||||
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
|
||||
/// No real contention: only the audio thread ever calls read().
|
||||
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapCons<u8>>>,
|
||||
pub(crate) deadline: std::time::Instant,
|
||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
/// Diagnostic tag for logs ("radio" or "track-stream").
|
||||
pub(crate) source_tag: &'static str,
|
||||
/// Optional completion marker: when true and the ring buffer is empty,
|
||||
/// return EOF immediately (used by one-shot track streaming).
|
||||
pub(crate) eof_when_empty: Option<Arc<AtomicBool>>,
|
||||
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
||||
pub(crate) pos: u64,
|
||||
}
|
||||
|
||||
impl Read for AudioStreamReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
// EOF guard: new track started.
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
// Drain reconnect channel; keep only the most recently delivered consumer
|
||||
// so a double-tap of resume doesn't leave stale data in place.
|
||||
let mut newest: Option<HeapCons<u8>> = None;
|
||||
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
|
||||
newest = Some(c);
|
||||
}
|
||||
if let Some(c) = newest {
|
||||
*self.cons.lock().unwrap() = c;
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
}
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
let available = self.cons.lock().unwrap().occupied_len();
|
||||
if available > 0 {
|
||||
let n = buf.len().min(available);
|
||||
let read = self.cons.lock().unwrap().pop_slice(&mut buf[..n]);
|
||||
self.pos += read as u64;
|
||||
// Reset deadline: data arrived, so connection is alive.
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
return Ok(read);
|
||||
}
|
||||
if self
|
||||
.eof_when_empty
|
||||
.as_ref()
|
||||
.is_some_and(|done| done.load(Ordering::SeqCst))
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
if std::time::Instant::now() >= self.deadline {
|
||||
crate::app_eprintln!(
|
||||
"[{}] AudioStreamReader: {}s without data → EOF",
|
||||
self.source_tag,
|
||||
RADIO_READ_TIMEOUT_SECS
|
||||
);
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("{}: no data received", self.source_tag),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for AudioStreamReader {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
match pos {
|
||||
SeekFrom::Current(0) => Ok(self.pos),
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
format!("{} stream is not seekable", self.source_tag),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for AudioStreamReader {
|
||||
fn is_seekable(&self) -> bool { false }
|
||||
fn byte_len(&self) -> Option<u64> { None }
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
//! One-shot HTTP downloader for non-ranged track streaming.
|
||||
//!
|
||||
//! Pushes response chunks into an SPSC ring buffer consumed by `AudioStreamReader`.
|
||||
//! Terminates when:
|
||||
//! - generation changes (track superseded),
|
||||
//! - response stream ends, or
|
||||
//! - response emits an error.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use ringbuf::HeapProd;
|
||||
use ringbuf::traits::Producer;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES};
|
||||
|
||||
pub(crate) async fn track_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
app: AppHandle,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
mut prod: HeapProd<u8>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_engine: Arc<AtomicU32>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
cache_track_id: Option<String>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let mut capture: Vec<u8> = Vec::new();
|
||||
let mut capture_over_limit = false;
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
'outer: loop {
|
||||
let response = if let Some(r) = next_response.take() {
|
||||
r
|
||||
} else {
|
||||
let mut req = http_client.get(&url);
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming reconnect failed after {} attempts: {}",
|
||||
reconnects, err
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
};
|
||||
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming reconnect returned {}, expected 206 for range resume",
|
||||
response.status()
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
if downloaded == 0 && !response.status().is_success() {
|
||||
crate::app_eprintln!("[audio] streaming HTTP {}", response.status());
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
crate::app_deprintln!(
|
||||
"[stream] track-stream dl superseded by skip: track_id={:?} gen={}→{}",
|
||||
cache_track_id, gen, gen_arc.load(Ordering::SeqCst)
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming download error after {} reconnects: {}",
|
||||
reconnects, e
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming download error (attempt {}/{}): {} — reconnecting",
|
||||
reconnects,
|
||||
TRACK_STREAM_MAX_RECONNECTS,
|
||||
e
|
||||
);
|
||||
next_response = None;
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
reconnects = 0;
|
||||
let mut offset = 0;
|
||||
while offset < chunk.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let pushed = prod.push_slice(&chunk[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
if !capture_over_limit {
|
||||
if capture.len().saturating_add(pushed) <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
let from = offset;
|
||||
let to = offset + pushed;
|
||||
capture.extend_from_slice(&chunk[from..to]);
|
||||
} else {
|
||||
capture.clear();
|
||||
capture_over_limit = true;
|
||||
}
|
||||
}
|
||||
if !capture_over_limit
|
||||
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::audio::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
||||
{
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
if normalization_engine.load(Ordering::Relaxed) == 2 {
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let pre_db = f32::from_bits(
|
||||
loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed),
|
||||
)
|
||||
.clamp(-24.0, 0.0);
|
||||
crate::audio::helpers::emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
|
||||
}
|
||||
}
|
||||
offset += pushed;
|
||||
downloaded += pushed as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !capture_over_limit && !capture.is_empty() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — full-track analysis (cpu-seed queue)",
|
||||
track_id,
|
||||
capture.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
if let Err(e) =
|
||||
crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: capture,
|
||||
});
|
||||
}
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
//! Transport-control Tauri commands: pause / resume / stop / seek.
|
||||
//! These don't drive playback startup — they mutate state on an already-running
|
||||
//! sink (or coordinate radio reconnect for cold-resume).
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, TryLockError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::HeapRb;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() {
|
||||
let pos = cur.position();
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
}
|
||||
}
|
||||
// Notify the download task so it can start measuring the hard-pause stall timer.
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume playback.
|
||||
///
|
||||
/// **Warm resume** (`is_hard_paused = false`): download task is still running,
|
||||
/// buffer has buffered audio. `sink.play()` suffices.
|
||||
///
|
||||
/// **Cold resume** (`is_hard_paused = true`): TCP was dropped. A fresh 4 MB
|
||||
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
|
||||
/// swaps it in on the next `read()`), and a new download task is spawned.
|
||||
#[tauri::command]
|
||||
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
|
||||
// If a preview is running, cancel it first — otherwise sink.play() on the
|
||||
// main sink would mix on top of the preview sink.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Detect radio hard-disconnect.
|
||||
let reconnect_info = {
|
||||
let guard = state.radio_state.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|rs| rs.flags.is_hard_paused.load(Ordering::Acquire))
|
||||
.map(|rs| (rs.url.clone(), rs.gen, rs.flags.clone()))
|
||||
};
|
||||
|
||||
if let Some((url, gen, flags)) = reconnect_info {
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (new_prod, new_cons) = rb.split();
|
||||
|
||||
// Send new consumer to AudioStreamReader (non-blocking; unbounded channel).
|
||||
let ok = flags.new_cons_tx.lock().unwrap().send(new_cons).is_ok();
|
||||
|
||||
if ok {
|
||||
let new_task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
None, // task performs its own fresh GET
|
||||
audio_http_client(&state),
|
||||
url,
|
||||
new_prod,
|
||||
flags.clone(),
|
||||
app,
|
||||
));
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_mut() {
|
||||
let old = std::mem::replace(&mut rs.task, new_task);
|
||||
old.abort(); // ensure any lingering old task is gone
|
||||
rs.flags.is_hard_paused.store(false, Ordering::Release);
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!("[radio] resume: AudioStreamReader gone — skipping reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
// Resume the rodio Sink (works for both warm and cold resume).
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.current_playback_url.lock().unwrap() = None;
|
||||
*state.current_analysis_track_id.lock().unwrap() = None;
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
// Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the
|
||||
// queue is exhausted; clearing here dropped the full ranged buffer and forced a
|
||||
// re-download on replay. The slot is only consumed on `take`/overwrite for another URL.
|
||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
cur.duration_secs = 0.0;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
|
||||
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
|
||||
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
|
||||
{
|
||||
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 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reject seek up-front for non-seekable streaming sources so the frontend's
|
||||
// restart-fallback engages instead of rolling the dice on the format reader
|
||||
// (which can consume the ring buffer to EOF for forward seeks → next song).
|
||||
if !state.current_is_seekable.load(Ordering::SeqCst) {
|
||||
crate::app_deprintln!("[seek] rejected → not-seekable source (legacy stream)");
|
||||
return Err("source is not seekable".into());
|
||||
}
|
||||
crate::app_deprintln!("[seek] target={:.2}s", seconds);
|
||||
|
||||
let lock_current_with_timeout = |timeout_ms: u64| {
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
match state.current.try_lock() {
|
||||
Ok(guard) => break Ok(guard),
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
if Instant::now() >= deadline {
|
||||
break Err("audio seek busy".to_string());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(TryLockError::Poisoned(_)) => {
|
||||
break Err("audio state lock poisoned".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Seeking back invalidates any pending gapless chain.
|
||||
let cur_pos = {
|
||||
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
cur.position()
|
||||
};
|
||||
if seconds < cur_pos - 1.0 {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
let seek_seconds = seconds.max(0.0);
|
||||
let seek_duration = Duration::from_secs_f64(seek_seconds);
|
||||
let seek_generation = state.generation.load(Ordering::SeqCst);
|
||||
let sink = {
|
||||
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
match cur.sink.as_ref() {
|
||||
Some(sink) => Arc::clone(sink),
|
||||
None => return Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = sink.try_seek(seek_duration).map_err(|e| e.to_string());
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(AUDIO_SEEK_TIMEOUT_MS)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
return Err("audio seek timeout".into());
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err("audio seek worker disconnected".into());
|
||||
}
|
||||
}
|
||||
|
||||
// If playback switched while seek was in flight, skip timestamp updates.
|
||||
if state.generation.load(Ordering::SeqCst) != seek_generation {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
if cur.sink.is_none() { return Ok(()); }
|
||||
|
||||
if cur.paused_at.is_some() {
|
||||
cur.paused_at = Some(seek_seconds);
|
||||
} else {
|
||||
cur.seek_offset = seek_seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
pub mod cli;
|
||||
mod discord;
|
||||
mod lib_commands;
|
||||
@@ -12,9 +11,7 @@ pub use psysonic_core::user_agent::{
|
||||
default_subsonic_wire_user_agent, runtime_subsonic_wire_user_agent, subsonic_wire_user_agent,
|
||||
};
|
||||
pub use psysonic_analysis::{analysis_cache, analysis_runtime};
|
||||
// `crate::submit_analysis_cpu_seed` shorthand kept so the audio modules don't
|
||||
// have to spell out `psysonic_analysis::analysis_runtime::...` until M3.
|
||||
pub(crate) use psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed;
|
||||
pub use psysonic_audio as audio;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod taskbar_win;
|
||||
mod tray_runtime;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{Emitter, Manager};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use tauri::tray::MouseButtonState;
|
||||
|
||||
use crate::audio;
|
||||
use crate::tray_runtime::{
|
||||
tray_state_icon, TrayMenuItems, TrayMenuItemsState, TrayMenuLabels, TrayMenuLabelsState,
|
||||
TrayPlaybackState, TrayState, TrayTooltip,
|
||||
@@ -355,16 +352,7 @@ pub(crate) fn toggle_tray_icon(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
|
||||
/// Called before process exit on macOS to ensure audio stops immediately.
|
||||
pub(crate) fn stop_audio_engine(app: &tauri::AppHandle) {
|
||||
let engine = app.state::<audio::AudioEngine>();
|
||||
engine.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*engine.chained_info.lock().unwrap() = None;
|
||||
drop(engine.radio_state.lock().unwrap().take());
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
}
|
||||
pub(crate) use crate::audio::stop_audio_engine;
|
||||
|
||||
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
|
||||
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
|
||||
|
||||
Reference in New Issue
Block a user