mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
a6ee0668c8
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
682 lines
30 KiB
Rust
682 lines
30 KiB
Rust
//! 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, Runtime};
|
||
|
||
use super::engine::AudioCurrent;
|
||
use super::helpers::{ramp_sink_volume, ProgressPayload, MASTER_HEADROOM};
|
||
use super::playback_rate::{effective_duration_secs, effective_position_secs, PlaybackRateAtomics};
|
||
use super::state::ChainedInfo;
|
||
|
||
/// Sink for the three progress events the task emits. Production wraps an
|
||
/// `AppHandle<R>` (any Tauri runtime) via the blanket impl below; tests pass
|
||
/// a `MockProgressEmitter` that records every call.
|
||
///
|
||
/// Pulled out of `spawn_progress_task` so the timer-driven loop can be
|
||
/// exercised against a mock emitter under `#[tokio::test(start_paused = true)]`
|
||
/// without a live Tauri app.
|
||
pub trait ProgressEmitter: Send + Sync + 'static {
|
||
fn emit_progress(&self, payload: ProgressPayload);
|
||
fn emit_track_switched(&self, duration_secs: f64);
|
||
fn emit_ended(&self);
|
||
}
|
||
|
||
impl<R: Runtime> ProgressEmitter for AppHandle<R> {
|
||
fn emit_progress(&self, payload: ProgressPayload) {
|
||
let _ = Emitter::emit(self, "audio:progress", payload);
|
||
}
|
||
fn emit_track_switched(&self, duration_secs: f64) {
|
||
let _ = Emitter::emit(self, "audio:track_switched", duration_secs);
|
||
}
|
||
fn emit_ended(&self) {
|
||
let _ = Emitter::emit(self, "audio:ended", ());
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||
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>,
|
||
autodj_suppress_arc: Arc<AtomicBool>,
|
||
initial_done: Arc<AtomicBool>,
|
||
emitter: E,
|
||
analysis_app: Option<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>>>,
|
||
stream_playback_armed: Arc<AtomicBool>,
|
||
playback_rate: PlaybackRateAtomics,
|
||
) {
|
||
// 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;
|
||
|
||
// Watchdog ceiling for the duration-hint near-end timer. Without crossfade,
|
||
// audio:ended fires from the sample-accurate `current_done` signal (see the
|
||
// exhaustion branch below), so this timer only matters as a fallback for a
|
||
// source that never signals exhaustion (stalled or malformed decoder). ~8 s
|
||
// past the point where near-end counting starts — far longer than any
|
||
// healthy track runs past its (floored) duration hint, so it never clips a
|
||
// real tail.
|
||
const END_WATCHDOG_TICKS: u32 = 80;
|
||
|
||
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);
|
||
emitter.emit_ended();
|
||
break;
|
||
}
|
||
|
||
let chained = chained_arc.lock().unwrap().take();
|
||
if let Some(info) = chained {
|
||
if let Some(app) = analysis_app.clone() {
|
||
crate::analysis_dispatch::spawn_gapless_transition_analysis(&app, &info);
|
||
}
|
||
|
||
// 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.
|
||
emitter.emit_track_switched(info.duration_secs);
|
||
near_end_ticks = 0;
|
||
continue;
|
||
}
|
||
// Current source exhausted and no chain queued — this is the
|
||
// real, sample-accurate end of the track. Emit audio:ended now.
|
||
// The duration_hint-based near-end timer below would otherwise
|
||
// clip up to ~1 s off the tail: the Subsonic hint is floored to
|
||
// whole seconds while the decoded audio runs slightly longer.
|
||
// The timer stays only as the crossfade trigger and as a
|
||
// watchdog for sources that never signal exhaustion.
|
||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||
emitter.emit_ended();
|
||
break;
|
||
}
|
||
|
||
// ── 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 (base_dur, paused_at) = {
|
||
let cur = current_arc.lock().unwrap();
|
||
(cur.duration_secs, cur.paused_at)
|
||
};
|
||
let dur = effective_duration_secs(base_dur, &playback_rate);
|
||
let is_paused = paused_at.is_some();
|
||
|
||
let pos_raw = if !stream_playback_armed.load(Ordering::Relaxed) {
|
||
0.0
|
||
} else if let Some(p) = paused_at {
|
||
p
|
||
} else {
|
||
effective_position_secs(samples / divisor, &playback_rate)
|
||
.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 = is_paused != last_progress_emit_paused
|
||
|| now.duration_since(last_progress_emit_at) >= Duration::from_millis(PROGRESS_EMIT_MIN_MS)
|
||
|| (pos - last_progress_emit_pos).abs() >= PROGRESS_EMIT_MIN_DELTA_SECS;
|
||
if should_emit_progress {
|
||
let buffering = !stream_playback_armed.load(Ordering::Relaxed);
|
||
emitter.emit_progress(ProgressPayload {
|
||
current_time: pos,
|
||
duration: dur,
|
||
buffering,
|
||
});
|
||
last_progress_emit_at = now;
|
||
last_progress_emit_pos = pos;
|
||
last_progress_emit_paused = is_paused;
|
||
}
|
||
|
||
if is_paused {
|
||
continue;
|
||
}
|
||
|
||
// AutoDJ may suppress the autonomous crossfade trigger so JS drives
|
||
// every advance (gated on the next track being playable). Treat it
|
||
// like crossfade-off here: only emit `audio:ended` on real source
|
||
// exhaustion (above) or the watchdog — never the early timer.
|
||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed)
|
||
&& !autodj_suppress_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;
|
||
}
|
||
// With crossfade, audio:ended must fire *early* (cf_secs
|
||
// before the real end, source not yet exhausted) so the
|
||
// frontend can start the next track and fade between them
|
||
// — the timer is the intended trigger here. Without
|
||
// crossfade, the real end is detected sample-accurately
|
||
// via `current_done` (handled in the exhaustion branch
|
||
// above), so the timer only acts as a watchdog for a
|
||
// source that never signals exhaustion — emitting on the
|
||
// hint alone would clip up to ~1 s off the tail.
|
||
if cf_enabled || near_end_ticks >= END_WATCHDOG_TICKS {
|
||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||
emitter.emit_ended();
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
near_end_ticks = 0;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// In-memory `ProgressEmitter` that records every event for assertion.
|
||
#[derive(Default)]
|
||
struct MockEmitter {
|
||
progress: Mutex<Vec<ProgressPayload>>,
|
||
track_switched: Mutex<Vec<f64>>,
|
||
ended: std::sync::atomic::AtomicUsize,
|
||
}
|
||
|
||
impl MockEmitter {
|
||
fn progress_count(&self) -> usize {
|
||
self.progress.lock().unwrap().len()
|
||
}
|
||
fn ended_count(&self) -> usize {
|
||
self.ended.load(Ordering::SeqCst)
|
||
}
|
||
fn track_switched_count(&self) -> usize {
|
||
self.track_switched.lock().unwrap().len()
|
||
}
|
||
fn last_progress_time(&self) -> Option<f64> {
|
||
self.progress
|
||
.lock()
|
||
.unwrap()
|
||
.last()
|
||
.map(|p| p.current_time)
|
||
}
|
||
}
|
||
|
||
impl ProgressEmitter for Arc<MockEmitter> {
|
||
fn emit_progress(&self, payload: ProgressPayload) {
|
||
self.progress.lock().unwrap().push(payload);
|
||
}
|
||
fn emit_track_switched(&self, duration_secs: f64) {
|
||
self.track_switched.lock().unwrap().push(duration_secs);
|
||
}
|
||
fn emit_ended(&self) {
|
||
self.ended.fetch_add(1, Ordering::SeqCst);
|
||
}
|
||
}
|
||
|
||
/// Bundle of every Arc<…> the spawn function needs, with sane defaults.
|
||
struct TaskHarness {
|
||
gen: u64,
|
||
gen_counter: Arc<AtomicU64>,
|
||
current: Arc<Mutex<AudioCurrent>>,
|
||
chained: Arc<Mutex<Option<ChainedInfo>>>,
|
||
crossfade_enabled: Arc<AtomicBool>,
|
||
crossfade_secs: Arc<AtomicU32>,
|
||
autodj_suppress: Arc<AtomicBool>,
|
||
done: Arc<AtomicBool>,
|
||
samples_played: Arc<AtomicU64>,
|
||
sample_rate: Arc<AtomicU32>,
|
||
channels: Arc<AtomicU32>,
|
||
gapless_switch_at: Arc<AtomicU64>,
|
||
playback_url: Arc<Mutex<Option<String>>>,
|
||
stream_playback_armed: Arc<AtomicBool>,
|
||
playback_rate: PlaybackRateAtomics,
|
||
}
|
||
|
||
impl TaskHarness {
|
||
fn new(duration_secs: f64) -> Self {
|
||
let current = AudioCurrent {
|
||
sink: None,
|
||
duration_secs,
|
||
seek_offset: 0.0,
|
||
play_started: None,
|
||
paused_at: None,
|
||
replay_gain_linear: 1.0,
|
||
base_volume: 1.0,
|
||
fadeout_trigger: None,
|
||
fadeout_samples: None,
|
||
};
|
||
Self {
|
||
gen: 1,
|
||
gen_counter: Arc::new(AtomicU64::new(1)),
|
||
current: Arc::new(Mutex::new(current)),
|
||
chained: Arc::new(Mutex::new(None)),
|
||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||
autodj_suppress: Arc::new(AtomicBool::new(false)),
|
||
done: Arc::new(AtomicBool::new(false)),
|
||
samples_played: Arc::new(AtomicU64::new(0)),
|
||
sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||
channels: Arc::new(AtomicU32::new(2)),
|
||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||
playback_url: Arc::new(Mutex::new(None)),
|
||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||
playback_rate: PlaybackRateAtomics::new(),
|
||
}
|
||
}
|
||
|
||
fn spawn_with(&self, emitter: Arc<MockEmitter>) {
|
||
spawn_progress_task(
|
||
self.gen,
|
||
self.gen_counter.clone(),
|
||
self.current.clone(),
|
||
self.chained.clone(),
|
||
self.crossfade_enabled.clone(),
|
||
self.crossfade_secs.clone(),
|
||
self.autodj_suppress.clone(),
|
||
self.done.clone(),
|
||
emitter,
|
||
None,
|
||
self.samples_played.clone(),
|
||
self.sample_rate.clone(),
|
||
self.channels.clone(),
|
||
self.gapless_switch_at.clone(),
|
||
self.playback_url.clone(),
|
||
self.stream_playback_armed.clone(),
|
||
self.playback_rate.clone(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── tests ─────────────────────────────────────────────────────────────────
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn progress_emits_buffering_while_stream_not_armed() {
|
||
let h = TaskHarness::new(240.0);
|
||
h.stream_playback_armed.store(false, Ordering::SeqCst);
|
||
h.samples_played.store(441_000, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||
assert!(
|
||
emitter.progress.lock().unwrap().iter().any(|p| p.buffering),
|
||
"progress payload must flag HTTP stream buffering before armed"
|
||
);
|
||
|
||
h.gen_counter.store(99, Ordering::SeqCst);
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn legacy_stream_holds_progress_at_zero_until_armed() {
|
||
let h = TaskHarness::new(240.0);
|
||
h.stream_playback_armed.store(false, Ordering::SeqCst);
|
||
h.samples_played.store(441_000, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||
assert!(
|
||
emitter.last_progress_time().unwrap_or(0.0) < 0.01,
|
||
"progress must stay at 0 while legacy stream is buffering"
|
||
);
|
||
assert!(
|
||
emitter.progress.lock().unwrap().iter().any(|p| p.buffering),
|
||
"progress payload must flag legacy stream buffering"
|
||
);
|
||
|
||
h.stream_playback_armed.store(true, Ordering::SeqCst);
|
||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||
assert!(
|
||
emitter.last_progress_time().unwrap_or(0.0) > 4.0,
|
||
"progress should follow samples once armed (got {:?})",
|
||
emitter.last_progress_time()
|
||
);
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn task_breaks_immediately_when_generation_already_changed() {
|
||
let h = TaskHarness::new(120.0);
|
||
// Bump the generation BEFORE spawn — the first 100 ms tick will see
|
||
// the mismatch and exit the loop without emitting anything.
|
||
h.gen_counter.store(99, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
assert_eq!(emitter.progress_count(), 0);
|
||
assert_eq!(emitter.ended_count(), 0);
|
||
assert_eq!(emitter.track_switched_count(), 0);
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn radio_with_dur_zero_emits_ended_when_done_flag_flips() {
|
||
// Radio streams have duration_secs == 0; the "done" flag is the only
|
||
// exhaustion signal. Loop must emit audio:ended and bump the
|
||
// generation counter.
|
||
//
|
||
// Multi-thread runtime with real time — start_paused under
|
||
// current_thread doesn't reliably drive the spawned task's loop body
|
||
// after tokio::time::advance, even with repeated yields. Real 100 ms
|
||
// sleeps are tolerable because the test only waits one tick.
|
||
let h = TaskHarness::new(0.0);
|
||
h.done.store(true, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
assert_eq!(emitter.ended_count(), 1, "audio:ended must fire");
|
||
assert_eq!(emitter.progress_count(), 0, "no progress emit before exhaustion");
|
||
assert!(
|
||
h.gen_counter.load(Ordering::SeqCst) > h.gen,
|
||
"generation counter must bump so following commands see the new gen"
|
||
);
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn task_emits_progress_payload_with_duration_after_first_tick() {
|
||
let h = TaskHarness::new(120.0);
|
||
// 5 s of playback at 44.1 kHz × 2 ch.
|
||
let played = (5.0 * 44_100.0 * 2.0) as u64;
|
||
h.samples_played.store(played, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
let first_payload = {
|
||
let payloads = emitter.progress.lock().unwrap();
|
||
assert!(!payloads.is_empty(), "first tick must emit progress");
|
||
payloads[0].clone()
|
||
};
|
||
assert_eq!(first_payload.duration, 120.0, "duration_secs propagates verbatim");
|
||
// current_time is computed from samples_played but possibly trimmed by
|
||
// platform output latency — accept anything in [0, duration].
|
||
assert!(first_payload.current_time >= 0.0 && first_payload.current_time <= 120.0);
|
||
|
||
// Stop the task so the test runtime can end.
|
||
h.gen_counter.store(99, Ordering::SeqCst);
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn done_with_chained_info_swaps_to_chain_and_emits_track_switched() {
|
||
let h = TaskHarness::new(120.0);
|
||
// Mark current source exhausted AND queue a chained successor.
|
||
h.done.store(true, Ordering::SeqCst);
|
||
let chain_url = "psysonic-local:///next/track.flac".to_string();
|
||
let chained_done = Arc::new(AtomicBool::new(false));
|
||
let chained_samples = Arc::new(AtomicU64::new(0));
|
||
*h.chained.lock().unwrap() = Some(ChainedInfo {
|
||
url: chain_url.clone(),
|
||
analysis_track_id: None,
|
||
server_id: None,
|
||
raw_bytes: Arc::new(Vec::new()),
|
||
duration_secs: 200.0,
|
||
replay_gain_linear: 1.0,
|
||
base_volume: 1.0,
|
||
source_done: chained_done,
|
||
sample_counter: chained_samples,
|
||
});
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
|
||
assert_eq!(
|
||
emitter.track_switched_count(),
|
||
1,
|
||
"audio:track_switched must fire on gapless transition"
|
||
);
|
||
let switched_dur = emitter.track_switched.lock().unwrap()[0];
|
||
assert_eq!(switched_dur, 200.0, "duration of the chained track");
|
||
|
||
assert_eq!(
|
||
emitter.ended_count(),
|
||
0,
|
||
"audio:ended must NOT fire when a chain is present"
|
||
);
|
||
assert_eq!(
|
||
*h.playback_url.lock().unwrap(),
|
||
Some(chain_url),
|
||
"current_playback_url updated to the chained URL"
|
||
);
|
||
assert!(
|
||
h.gapless_switch_at.load(Ordering::SeqCst) > 0,
|
||
"gapless_switch_at timestamp recorded for ghost-command guard"
|
||
);
|
||
|
||
// Stop the task.
|
||
h.gen_counter.store(99, Ordering::SeqCst);
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn done_without_chain_emits_ended_immediately() {
|
||
// Real track (duration_secs > 0), source exhausted, no chained
|
||
// successor: audio:ended must fire on the sample-accurate done flag —
|
||
// not be deferred to (or clipped by) the duration-hint near-end timer.
|
||
let h = TaskHarness::new(120.0);
|
||
h.done.store(true, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
assert_eq!(emitter.ended_count(), 1, "audio:ended must fire on source exhaustion");
|
||
assert_eq!(emitter.track_switched_count(), 0, "no chain → no track switch");
|
||
assert!(
|
||
h.gen_counter.load(Ordering::SeqCst) > h.gen,
|
||
"generation counter must bump so following commands see the new gen"
|
||
);
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn near_end_without_crossfade_waits_for_source_done() {
|
||
// Position playback past the (floored) duration hint with the source
|
||
// NOT yet exhausted and crossfade off. The duration-hint timer must
|
||
// NOT emit audio:ended — doing so would clip the real tail, since the
|
||
// decoded audio routinely runs slightly longer than the integer hint.
|
||
let h = TaskHarness::new(120.0);
|
||
// samples → pos_raw clamps to dur (120 s), well inside `dur - 1`.
|
||
let played = (120.0 * 44_100.0 * 2.0) as u64;
|
||
h.samples_played.store(played, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
// > 10 ticks: the timer's near-end counter is well past its 1 s mark.
|
||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||
assert_eq!(
|
||
emitter.ended_count(),
|
||
0,
|
||
"audio:ended must NOT fire from the duration-hint timer before the source is done"
|
||
);
|
||
|
||
// Source actually exhausts → audio:ended fires now.
|
||
h.done.store(true, Ordering::SeqCst);
|
||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires once the source is exhausted");
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn near_end_with_crossfade_emits_ended_on_timer() {
|
||
// With crossfade enabled, audio:ended must still fire from the timer
|
||
// ~cf_secs before the real end (the source is NOT exhausted yet) so the
|
||
// frontend can start the next track and fade between them.
|
||
let h = TaskHarness::new(120.0);
|
||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||
h.samples_played.store(played, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
// 10 ticks ≈ 1 s to cross the near-end debounce.
|
||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||
assert_eq!(
|
||
emitter.ended_count(),
|
||
1,
|
||
"crossfade still relies on the timer to fire audio:ended early"
|
||
);
|
||
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||
async fn autodj_suppress_does_not_fire_crossfade_timer() {
|
||
// AutoDJ suppression on: even with crossfade enabled and the position
|
||
// inside the crossfade window, the autonomous timer must NOT emit
|
||
// audio:ended (JS drives the advance, gated on the next track being
|
||
// ready). The real end is still reached via source exhaustion.
|
||
let h = TaskHarness::new(120.0);
|
||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||
h.autodj_suppress.store(true, Ordering::SeqCst);
|
||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||
h.samples_played.store(played, Ordering::SeqCst);
|
||
|
||
let emitter = Arc::new(MockEmitter::default());
|
||
h.spawn_with(emitter.clone());
|
||
|
||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||
assert_eq!(
|
||
emitter.ended_count(),
|
||
0,
|
||
"suppressed AutoDJ must not fire the autonomous crossfade timer"
|
||
);
|
||
|
||
// Source exhausts → audio:ended fires (clean sequential end).
|
||
h.done.store(true, Ordering::SeqCst);
|
||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires on exhaustion");
|
||
}
|
||
}
|