From 7e8acd86d6933b03b33a42cf23c204a81d10fd57 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 8 May 2026 15:50:07 +0200 Subject: [PATCH] refactor(audio): extract sink swap + crossfade handoff into helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the atomic sink-swap block (~50 LOC) out of audio_play into play_input::swap_in_new_sink. The helper takes a SinkSwapInputs struct (sink, duration_secs, volume, gain_linear, fadeout trigger + samples, crossfade flag, measured fade seconds) and: 1. Locks state.current, atomically replaces sink + duration + seek/play timestamps + volume + replay-gain + fade-out handles, returns the old sink + its old fade-out handles. 2. If crossfade is on: stores total fade samples, flips trigger atomic on the old TriggeredFadeOut, parks the old sink in fading_out_sink, spawns a small task that drops it after `fade_secs + 0.5 s`. 3. If crossfade is off: stops the old sink immediately. Behaviour-preserving — same lock scope, same atomic ordering, same cleanup-task lifetime. audio/commands.rs: 600 → 563 LOC. With this the audio_play body has shrunk from the original ~760 LOC monolith to a top-level orchestration of named helper calls. --- src-tauri/src/audio/commands.rs | 63 ++++++------------------- src-tauri/src/audio/play_input.rs | 78 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 50 deletions(-) diff --git a/src-tauri/src/audio/commands.rs b/src-tauri/src/audio/commands.rs index 9b02a4cb..b26f82a0 100644 --- a/src-tauri/src/audio/commands.rs +++ b/src-tauri/src/audio/commands.rs @@ -3,7 +3,7 @@ //! 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, Instant}; +use std::time::Duration; use futures_util::StreamExt; use rodio::Player; @@ -15,7 +15,8 @@ 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, url_format_hint, PlayInputContext, + 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; @@ -351,54 +352,16 @@ pub async fn audio_play( sink.play(); } - // Atomically swap sinks — extract old sink + its fade-out trigger. - 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(built.fadeout_trigger); - cur.fadeout_samples = Some(built.fadeout_samples); - (old, old_fo_trigger, old_fo_samples) - }; - - // Handle old sink: symmetric crossfade or immediate stop. - 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(); - } + 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(); diff --git a/src-tauri/src/audio/play_input.rs b/src-tauri/src/audio/play_input.rs index 3fd9265d..2a562780 100644 --- a/src-tauri/src/audio/play_input.rs +++ b/src-tauri/src/audio/play_input.rs @@ -393,6 +393,84 @@ pub(super) struct PlaybackSource { pub(super) is_seekable: bool, } +/// State + decisions audio_play computed before the sink swap. +pub(super) struct SinkSwapInputs { + pub(super) sink: Arc, + pub(super) duration_secs: f64, + pub(super) volume: f32, + pub(super) gain_linear: f32, + pub(super) fadeout_trigger: Arc, + pub(super) fadeout_samples: Arc, + 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.