mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(autodj): smooth skip and interrupt blend transitions (#1128)
This commit is contained in:
@@ -72,6 +72,9 @@ pub async fn audio_play(
|
||||
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
|
||||
// B's fade (today's behaviour). Always clamped to A's measured remaining.
|
||||
outgoing_fade_secs_override: Option<f32>,
|
||||
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
|
||||
// while a track is playing. Optional; only honoured when `manual` is true.
|
||||
manual_autodj_blend: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -236,8 +239,10 @@ pub async fn audio_play(
|
||||
},
|
||||
);
|
||||
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
|
||||
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
|
||||
let crossfade_enabled =
|
||||
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
|
||||
// Per-transition override (dynamic crossfade) caps the fade for this swap;
|
||||
// otherwise fall back to the global crossfade length. Both clamped the same.
|
||||
let crossfade_secs_val = crossfade_secs_override
|
||||
|
||||
@@ -66,6 +66,9 @@ pub struct AudioEngine {
|
||||
/// the engine from starting a still-buffering next track and fading over it
|
||||
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
|
||||
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
|
||||
/// AutoDJ interrupt prep: `audio_begin_outgoing_fade` volume-ducked the
|
||||
/// outgoing sink; block normalization/volume ramps until the handoff swap.
|
||||
pub(crate) interrupt_outgoing_duck_active: Arc<AtomicBool>,
|
||||
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.
|
||||
@@ -482,6 +485,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
|
||||
@@ -805,6 +805,19 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
|
||||
gain_linear_to_db(gain_linear)
|
||||
}
|
||||
|
||||
static SINK_VOLUME_RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Cancel any in-flight sink-volume ramp (new ramp wins).
|
||||
pub(crate) fn cancel_sink_volume_ramp() {
|
||||
SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Audible sink multiplier — may differ from `base_volume * replay_gain` after
|
||||
/// interrupt prep or a mid-ramp correction.
|
||||
pub(crate) fn sink_volume_now(sink: &Player) -> f32 {
|
||||
sink.volume().clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -812,8 +825,7 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let my_gen = SINK_VOLUME_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.
|
||||
@@ -826,18 +838,46 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
} 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));
|
||||
}
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
/// Linear sink-volume ramp over an explicit wall-clock duration (interrupt prep).
|
||||
pub(crate) fn ramp_sink_volume_over_secs(sink: Arc<Player>, from: f32, to: f32, secs: 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;
|
||||
}
|
||||
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let secs = secs.clamp(0.1, 12.0);
|
||||
let step_ms: u64 = 20;
|
||||
let steps = ((secs * 1000.0) / step_ms as f32).round().max(1.0) as usize;
|
||||
std::thread::spawn(move || {
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
fn ramp_sink_volume_steps(
|
||||
sink: Arc<Player>,
|
||||
from: f32,
|
||||
to: f32,
|
||||
steps: usize,
|
||||
step_ms: u64,
|
||||
my_gen: u64,
|
||||
) {
|
||||
for i in 1..=steps {
|
||||
if SINK_VOLUME_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));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,9 +13,9 @@ 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 prev_effective = sink_volume_now(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);
|
||||
}
|
||||
@@ -105,11 +105,19 @@ pub fn audio_update_replay_gain(
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
|
||||
// ramp A back to full gain before the handoff swap.
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
drop(cur);
|
||||
@@ -143,6 +151,23 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Duck the current sink over `fade_secs` without exhausting its source (which
|
||||
/// would spuriously emit `audio:ended` before the interrupt handoff).
|
||||
#[tauri::command]
|
||||
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
|
||||
let fade_secs = fade_secs.clamp(0.1, 12.0);
|
||||
let cur = state.current.lock().unwrap();
|
||||
let Some(sink) = cur.sink.as_ref() else {
|
||||
return;
|
||||
};
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(true, Ordering::Relaxed);
|
||||
cancel_sink_volume_ramp();
|
||||
let from = sink_volume_now(sink);
|
||||
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
|
||||
}
|
||||
|
||||
/// AutoDJ: when `true`, the progress task stops firing its autonomous
|
||||
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
|
||||
/// (only when the next track is actually playable). When `false`, the engine's
|
||||
|
||||
@@ -96,6 +96,43 @@ pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
|
||||
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
|
||||
fn handoff_old_sink_fade_out(
|
||||
state: &State<'_, AudioEngine>,
|
||||
old_sink: Option<Arc<rodio::Player>>,
|
||||
old_fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
fade_secs: f32,
|
||||
cleanup_secs: f32,
|
||||
) {
|
||||
let Some(old) = old_sink else {
|
||||
return;
|
||||
};
|
||||
if fade_secs <= 0.0 {
|
||||
old.stop();
|
||||
return;
|
||||
}
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (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);
|
||||
}
|
||||
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(cleanup_secs.max(fade_secs + 0.1));
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -138,30 +175,29 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
// Trigger sample-level fade-out on Track A via TriggeredFadeOut —
|
||||
// unless `outgoing_fade_secs` is 0 (scenario A): then A keeps full
|
||||
// engine gain and its own recorded fade carries it down while B
|
||||
// rises underneath. Either way the old sink is kept alive until B's
|
||||
// fade-in window elapses, so A plays out its tail before being
|
||||
// dropped.
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (outgoing_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);
|
||||
}
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
|
||||
// still keep the old sink alive until B's fade-in window elapses.
|
||||
handoff_old_sink_fade_out(
|
||||
state,
|
||||
old_sink,
|
||||
old_fadeout_trigger,
|
||||
old_fadeout_samples,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
|
||||
);
|
||||
} else if let Some(old) = old_sink {
|
||||
// Prep already volume-ducked A; scenario-A keeps sample gain at 1.0
|
||||
// so clamp the handoff sink or A blasts over B's fade-in.
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
old.set_volume(0.0);
|
||||
}
|
||||
|
||||
// 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.max(outgoing_fade_secs) + 0.5);
|
||||
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() {
|
||||
@@ -172,4 +208,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ mod tests {
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0)),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
|
||||
Reference in New Issue
Block a user