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:
Psychotoxical
2026-05-09 13:33:44 +02:00
parent ff456dd823
commit 41e75663f1
35 changed files with 126 additions and 54 deletions
@@ -0,0 +1,219 @@
//! 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;
}
}
});
}