feat(autodj): smooth skip and interrupt blend transitions (#1128)

This commit is contained in:
cucadmuh
2026-06-19 04:52:15 +03:00
committed by GitHub
parent d50c9c444d
commit 4225146a16
50 changed files with 1118 additions and 208 deletions
@@ -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)),
+51 -11
View File
@@ -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)),
+1
View File
@@ -735,6 +735,7 @@ pub fn run() {
audio::preview::audio_preview_set_volume,
audio::mix_commands::audio_set_crossfade,
audio::mix_commands::audio_set_gapless,
audio::mix_commands::audio_begin_outgoing_fade,
audio::mix_commands::audio_set_autodj_suppress,
audio::mix_commands::audio_set_normalization,
audio::device_commands::audio_list_devices,