mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(playback): global speed with three strategies (#852)
* feat(playback): global speed with three strategies Add Settings → Audio and player-bar controls for global playback speed (speed with auto pitch correction as default, varispeed, manual pitch shift). Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough. * fix(playback): align seekbar, seek, and progress on content timeline Unify UI timebase across varispeed and preserve strategies: full-track duration, speed-scaled progress for DSP paths, and content-timeline seeks without varispeed scaling. Reset the sample counter after seek so clicks land correctly; restart playback on strategy/enable changes instead of fragile hot-switching. * fix(ui): anchor playback speed popover like volume controls Replace the centered EQ-style modal with a player-bar popover (outside click, Escape, reposition on scroll). Show compact controls in the bar and overflow menu; keep strategy hints and labels in Settings only. * docs(release): CHANGELOG and credits for playback speed (PR #852) * docs(changelog): add playback speed entry for PR #852 * fix(clippy): simplify raw_counter_samples branch for CI Collapse duplicate if branches flagged by clippy::if-same-then-else. * fix(ui): wheel on pitch slider adjusts pitch in speed popover In compact player-bar controls, scroll over the pitch row changes pitch; elsewhere in the panel changes speed. Stop propagation so overflow menu wheel does not tweak volume. * fix(playback): address PR #852 review and drop ineffective dynamic imports Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample counter on live preserve-path speed changes; use neutral rate atomics for radio progress; static-import playerStore in playListenSession (move preview volume sync to previewPlayerVolumeSync side-effect module). * fix(i18n): translate playback-rate strategy labels in all locales Replace leftover English Varispeed/Pitch strings in ru and other non-en settings blocks so popover strategy buttons and hints read natively. * fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo" --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
@@ -26,6 +26,7 @@ url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
id3 = "1.16.4"
|
||||
pitch_shift = "2.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -19,6 +19,7 @@ use super::play_input::{
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
|
||||
PlayInputContext, SinkSwapInputs,
|
||||
};
|
||||
use super::playback_rate::preserve_pitch_will_run;
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
@@ -347,7 +348,11 @@ pub async fn audio_play(
|
||||
// we resume — the buffer is already full and the hardware gets its frames
|
||||
// without an underrun on the very first period.
|
||||
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
|
||||
let needs_prefill = hi_res_enabled && output_rate > 48_000;
|
||||
// Preserve-pitch phase vocoder runs on a worker thread; pre-fill gives the
|
||||
// ring buffer time to build headroom before the cpal callback drains it.
|
||||
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
|
||||
let needs_prefill =
|
||||
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
|
||||
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
|
||||
if needs_prefill || defer_playback_start {
|
||||
sink.pause();
|
||||
@@ -387,12 +392,12 @@ pub async fn audio_play(
|
||||
sink.append(source);
|
||||
|
||||
if needs_prefill {
|
||||
// 500 ms lets rodio decode several seconds of hi-res audio into its
|
||||
// internal buffer while the sink is paused. The hardware sees no gap
|
||||
// because the output is held — it only starts draining after sink.play().
|
||||
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size,
|
||||
// absorbing scheduler jitter and PipeWire graph wake-up latency.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let prefill_ms = if needs_preserve_prefill {
|
||||
800
|
||||
} else {
|
||||
500
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(prefill_ms)).await;
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(()); // skipped during pre-fill — abort silently
|
||||
}
|
||||
@@ -447,6 +452,7 @@ pub async fn audio_play(
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
state.playback_rate.clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -568,6 +574,7 @@ pub async fn audio_chain_preload(
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_next.clone(),
|
||||
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
||||
chain_counter.clone(),
|
||||
|
||||
@@ -17,6 +17,7 @@ use symphonia::core::{
|
||||
};
|
||||
|
||||
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
|
||||
use super::playback_rate::{PlaybackRateAtomics, PlaybackRateSource};
|
||||
use super::sources::*;
|
||||
|
||||
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
|
||||
@@ -546,6 +547,7 @@ pub(crate) fn build_source(
|
||||
eq_gains: Arc<[AtomicU32; 10]>,
|
||||
eq_enabled: Arc<AtomicBool>,
|
||||
eq_pre_gain: Arc<AtomicU32>,
|
||||
playback_rate: PlaybackRateAtomics,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
@@ -616,7 +618,9 @@ pub(crate) fn build_source(
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
|
||||
let rate_dyn = DynSource::new(rate_src);
|
||||
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
@@ -625,7 +629,7 @@ pub(crate) fn build_source(
|
||||
|
||||
Ok(BuiltSource {
|
||||
source: boosted,
|
||||
duration_secs: effective_dur,
|
||||
duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
|
||||
output_rate,
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
@@ -643,6 +647,7 @@ pub(crate) fn build_streaming_source(
|
||||
eq_gains: Arc<[AtomicU32; 10]>,
|
||||
eq_enabled: Arc<AtomicBool>,
|
||||
eq_pre_gain: Arc<AtomicU32>,
|
||||
playback_rate: PlaybackRateAtomics,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
@@ -682,7 +687,9 @@ pub(crate) fn build_streaming_source(
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
|
||||
let rate_dyn = DynSource::new(rate_src);
|
||||
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
@@ -694,7 +701,7 @@ pub(crate) fn build_streaming_source(
|
||||
|
||||
Ok(BuiltSource {
|
||||
source: boosted,
|
||||
duration_secs: effective_dur,
|
||||
duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
|
||||
output_rate,
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
@@ -915,21 +922,29 @@ mod build_source_tests {
|
||||
}
|
||||
|
||||
type EqGains = Arc<[AtomicU32; 10]>;
|
||||
type SourceArgs = (EqGains, Arc<AtomicBool>, Arc<AtomicU32>, Arc<AtomicBool>, Arc<AtomicU64>);
|
||||
type SourceArgs = (
|
||||
EqGains,
|
||||
Arc<AtomicBool>,
|
||||
Arc<AtomicU32>,
|
||||
PlaybackRateAtomics,
|
||||
Arc<AtomicBool>,
|
||||
Arc<AtomicU64>,
|
||||
);
|
||||
|
||||
fn default_source_args() -> SourceArgs {
|
||||
let eq_gains: Arc<[AtomicU32; 10]> =
|
||||
Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits())));
|
||||
let eq_enabled = Arc::new(AtomicBool::new(false));
|
||||
let eq_pre_gain = Arc::new(AtomicU32::new(0f32.to_bits()));
|
||||
let playback_rate = PlaybackRateAtomics::new();
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let sample_counter = Arc::new(AtomicU64::new(0));
|
||||
(eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter)
|
||||
(eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_source_succeeds_for_synthetic_wav() {
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
|
||||
let wav = synthetic_wav_bytes_local(0.4);
|
||||
let built = build_source(
|
||||
wav,
|
||||
@@ -937,6 +952,7 @@ mod build_source_tests {
|
||||
eq_gains,
|
||||
eq_enabled,
|
||||
eq_pre_gain,
|
||||
playback_rate,
|
||||
done_flag,
|
||||
Duration::ZERO,
|
||||
sample_counter,
|
||||
@@ -952,13 +968,14 @@ mod build_source_tests {
|
||||
|
||||
#[test]
|
||||
fn build_source_returns_err_for_garbage_bytes() {
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
|
||||
let result = build_source(
|
||||
vec![0u8; 32],
|
||||
0.0,
|
||||
eq_gains,
|
||||
eq_enabled,
|
||||
eq_pre_gain,
|
||||
playback_rate,
|
||||
done_flag,
|
||||
Duration::ZERO,
|
||||
sample_counter,
|
||||
@@ -971,7 +988,7 @@ mod build_source_tests {
|
||||
|
||||
#[test]
|
||||
fn build_streaming_source_succeeds_for_synthetic_wav() {
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
|
||||
let wav = synthetic_wav_bytes_local(0.4);
|
||||
let decoder = SizedDecoder::new(wav, Some("wav"), false).unwrap();
|
||||
let built = build_streaming_source(
|
||||
@@ -980,6 +997,7 @@ mod build_source_tests {
|
||||
eq_gains,
|
||||
eq_enabled,
|
||||
eq_pre_gain,
|
||||
playback_rate,
|
||||
done_flag,
|
||||
Duration::ZERO,
|
||||
sample_counter,
|
||||
@@ -993,7 +1011,7 @@ mod build_source_tests {
|
||||
|
||||
#[test]
|
||||
fn build_source_with_target_rate_resamples() {
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
|
||||
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
|
||||
let wav = synthetic_wav_bytes_local(0.3);
|
||||
let built = build_source(
|
||||
wav,
|
||||
@@ -1001,6 +1019,7 @@ mod build_source_tests {
|
||||
eq_gains,
|
||||
eq_enabled,
|
||||
eq_pre_gain,
|
||||
playback_rate,
|
||||
done_flag,
|
||||
Duration::from_millis(5),
|
||||
sample_counter,
|
||||
|
||||
@@ -222,6 +222,15 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.seek_offset = snap.current_time_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
engine.samples_played.store(
|
||||
crate::playback_rate::raw_counter_samples_for_content_position(
|
||||
snap.current_time_secs,
|
||||
engine.current_sample_rate.load(Ordering::Relaxed),
|
||||
engine.current_channels.load(Ordering::Relaxed),
|
||||
&engine.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::app_eprintln!("[device-resume] seek failed: {e}");
|
||||
@@ -251,6 +260,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
engine.gapless_switch_at.clone(),
|
||||
engine.current_playback_url.clone(),
|
||||
engine.stream_playback_armed.clone(),
|
||||
engine.playback_rate.clone(),
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct AudioEngine {
|
||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||
pub eq_enabled: Arc<AtomicBool>,
|
||||
pub eq_pre_gain: Arc<AtomicU32>,
|
||||
pub playback_rate: crate::playback_rate::PlaybackRateAtomics,
|
||||
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
/// Last fully downloaded manual-stream track bytes (same playback identity),
|
||||
/// used to recover seek/replay without waiting for network again.
|
||||
@@ -366,6 +367,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
playback_rate: crate::playback_rate::PlaybackRateAtomics::new(),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
stream_completed_cache: Arc::new(Mutex::new(None)),
|
||||
stream_completed_spill: Arc::new(Mutex::new(None)),
|
||||
|
||||
@@ -14,6 +14,8 @@ mod dev_io;
|
||||
pub mod device_commands;
|
||||
pub mod mix_commands;
|
||||
mod play_input;
|
||||
pub mod playback_rate;
|
||||
mod preserve_worker;
|
||||
pub mod preload_commands;
|
||||
pub(crate) mod progress_task;
|
||||
pub mod radio_commands;
|
||||
|
||||
@@ -143,6 +143,89 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_playback_rate(
|
||||
enabled: bool,
|
||||
strategy: String,
|
||||
speed: f32,
|
||||
pitch_semitones: f32,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
use crate::playback_rate::{
|
||||
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
|
||||
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
|
||||
STRATEGY_VARISPEED,
|
||||
};
|
||||
|
||||
let clamped_speed = speed.clamp(0.5, 2.0);
|
||||
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
|
||||
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
|
||||
let old_strat = state.playback_rate.load_strategy();
|
||||
let old_speed = state.playback_rate.load_speed();
|
||||
let was_active = is_effect_active(&state.playback_rate);
|
||||
let new_strat = match strategy.as_str() {
|
||||
"preserve_pitch" => STRATEGY_PRESERVE_PITCH,
|
||||
"speed_corrected" => STRATEGY_SPEED_CORRECTED,
|
||||
_ => STRATEGY_VARISPEED,
|
||||
};
|
||||
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
|
||||
|
||||
let restamp_content = if was_active
|
||||
&& enabled == old_enabled
|
||||
&& uses_preserve_dsp(old_strat)
|
||||
&& new_strat == old_strat
|
||||
&& speed_changed
|
||||
{
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
if sample_rate > 0 && channels > 0 {
|
||||
Some(content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state
|
||||
.playback_rate
|
||||
.enabled
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.strategy
|
||||
.store(new_strat, Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.speed
|
||||
.store(clamped_speed.to_bits(), Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.pitch_semitones
|
||||
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
|
||||
|
||||
if let Some(content_secs) = restamp_content {
|
||||
if is_effect_active(&state.playback_rate) {
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
content_secs,
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
|
||||
@@ -846,6 +846,7 @@ pub(super) async fn build_source_from_play_input(
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
@@ -876,6 +877,7 @@ pub(super) async fn build_source_from_play_input(
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
@@ -896,6 +898,7 @@ pub(super) async fn build_source_from_play_input(
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
//! Global playback speed / pitch strategies (varispeed, speed-corrected, preserve pitch).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::source::SeekError;
|
||||
use rodio::{ChannelCount, SampleRate, Source};
|
||||
|
||||
use crate::preserve_worker::PreserveOffload;
|
||||
|
||||
pub const STRATEGY_VARISPEED: u32 = 0;
|
||||
pub const STRATEGY_PRESERVE_PITCH: u32 = 1;
|
||||
pub const STRATEGY_SPEED_CORRECTED: u32 = 2;
|
||||
|
||||
pub(crate) const PRESERVE_MAKEUP_GAIN: f32 = 1.35;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlaybackRateAtomics {
|
||||
pub enabled: Arc<AtomicBool>,
|
||||
pub strategy: Arc<AtomicU32>,
|
||||
pub speed: Arc<AtomicU32>,
|
||||
pub pitch_semitones: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl Default for PlaybackRateAtomics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: Arc::new(AtomicBool::new(false)),
|
||||
strategy: Arc::new(AtomicU32::new(STRATEGY_SPEED_CORRECTED)),
|
||||
speed: Arc::new(AtomicU32::new(1.0f32.to_bits())),
|
||||
pitch_semitones: Arc::new(AtomicU32::new(0.0f32.to_bits())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackRateAtomics {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn load_speed(&self) -> f32 {
|
||||
f32::from_bits(self.speed.load(Ordering::Relaxed)).clamp(0.5, 2.0)
|
||||
}
|
||||
|
||||
pub fn load_pitch(&self) -> f32 {
|
||||
f32::from_bits(self.pitch_semitones.load(Ordering::Relaxed)).clamp(-12.0, 12.0)
|
||||
}
|
||||
|
||||
pub fn load_strategy(&self) -> u32 {
|
||||
match self.strategy.load(Ordering::Relaxed) {
|
||||
STRATEGY_PRESERVE_PITCH => STRATEGY_PRESERVE_PITCH,
|
||||
STRATEGY_SPEED_CORRECTED => STRATEGY_SPEED_CORRECTED,
|
||||
_ => STRATEGY_VARISPEED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uses_preserve_dsp(strategy: u32) -> bool {
|
||||
strategy == STRATEGY_PRESERVE_PITCH || strategy == STRATEGY_SPEED_CORRECTED
|
||||
}
|
||||
|
||||
pub fn effective_pitch(atomics: &PlaybackRateAtomics) -> f32 {
|
||||
if atomics.load_strategy() == STRATEGY_PRESERVE_PITCH {
|
||||
atomics.load_pitch()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
|
||||
if !atomics.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
let speed = atomics.load_speed();
|
||||
match atomics.load_strategy() {
|
||||
STRATEGY_PRESERVE_PITCH => {
|
||||
(speed - 1.0).abs() > 0.001 || atomics.load_pitch().abs() > 0.001
|
||||
}
|
||||
_ => (speed - 1.0).abs() > 0.001,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when preserve-pitch DSP (background worker) should run for this track.
|
||||
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
|
||||
atomics.enabled.load(Ordering::Relaxed)
|
||||
&& uses_preserve_dsp(atomics.load_strategy())
|
||||
&& is_effect_active(atomics)
|
||||
}
|
||||
|
||||
/// Content timeline length for seek bar / duration labels (always the full track).
|
||||
pub fn effective_duration_secs(base_secs: f64, _atomics: &PlaybackRateAtomics) -> f64 {
|
||||
base_secs
|
||||
}
|
||||
|
||||
/// Map counter-derived seconds to timeline position for UI / near-end checks.
|
||||
pub fn effective_position_secs(raw_secs: f64, atomics: &PlaybackRateAtomics) -> f64 {
|
||||
if !is_effect_active(atomics) {
|
||||
return raw_secs;
|
||||
}
|
||||
if atomics.load_strategy() == STRATEGY_VARISPEED {
|
||||
return raw_secs;
|
||||
}
|
||||
// Preserve DSP outputs at the base sample rate; scale to content timeline.
|
||||
raw_secs * atomics.load_speed() as f64
|
||||
}
|
||||
|
||||
/// Sample-counter position mapped to the content timeline (seek bar / labels).
|
||||
pub(crate) fn content_position_from_samples(
|
||||
samples: u64,
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
atomics: &PlaybackRateAtomics,
|
||||
) -> f64 {
|
||||
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
|
||||
effective_position_secs(samples as f64 / divisor, atomics)
|
||||
}
|
||||
|
||||
/// Counter value that matches `content_position_from_samples` after a content-timeline seek.
|
||||
pub(crate) fn raw_counter_samples_for_content_position(
|
||||
content_secs: f64,
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
atomics: &PlaybackRateAtomics,
|
||||
) -> u64 {
|
||||
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
|
||||
let raw_secs = if is_effect_active(atomics)
|
||||
&& atomics.load_strategy() != STRATEGY_VARISPEED
|
||||
{
|
||||
content_secs / atomics.load_speed().max(0.001) as f64
|
||||
} else {
|
||||
content_secs
|
||||
};
|
||||
(raw_secs * divisor).round() as u64
|
||||
}
|
||||
|
||||
pub(crate) fn preserve_out_samples(speed: f32) -> usize {
|
||||
(128.0f32 / speed.clamp(0.5, 2.0)).round() as usize
|
||||
}
|
||||
|
||||
pub struct PlaybackRateSource<S: Source<Item = f32> + Send + 'static> {
|
||||
inner: Option<S>,
|
||||
base_sample_rate: SampleRate,
|
||||
base_channels: ChannelCount,
|
||||
atomics: PlaybackRateAtomics,
|
||||
offload: Option<PreserveOffload>,
|
||||
handback_rx: Option<mpsc::Receiver<S>>,
|
||||
handback_requested: bool,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> PlaybackRateSource<S> {
|
||||
pub fn new(inner: S, atomics: PlaybackRateAtomics) -> Self {
|
||||
let base_sample_rate = inner.sample_rate();
|
||||
let base_channels = inner.channels();
|
||||
Self {
|
||||
inner: Some(inner),
|
||||
base_sample_rate,
|
||||
base_channels,
|
||||
atomics,
|
||||
offload: None,
|
||||
handback_rx: None,
|
||||
handback_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_handback(&mut self) {
|
||||
let Some(rx) = &self.handback_rx else {
|
||||
return;
|
||||
};
|
||||
if let Ok(inner) = rx.try_recv() {
|
||||
self.inner = Some(inner);
|
||||
self.handback_rx = None;
|
||||
self.handback_requested = false;
|
||||
if let Some(offload) = self.offload.take() {
|
||||
offload.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_handback_if_needed(&mut self) {
|
||||
if self.inner.is_some() || self.handback_requested {
|
||||
return;
|
||||
}
|
||||
if let Some(offload) = &self.offload {
|
||||
offload.request_handback();
|
||||
self.handback_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_offload(&mut self) {
|
||||
if self.offload.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(inner) = self.inner.take() {
|
||||
let (handback_tx, handback_rx) = mpsc::sync_channel(1);
|
||||
self.handback_rx = Some(handback_rx);
|
||||
self.offload = Some(PreserveOffload::spawn(
|
||||
inner,
|
||||
self.atomics.clone(),
|
||||
self.base_sample_rate.get(),
|
||||
self.base_channels.get(),
|
||||
handback_tx,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_sample_rate(&self) -> SampleRate {
|
||||
self.inner
|
||||
.as_ref()
|
||||
.map(Source::sample_rate)
|
||||
.unwrap_or(self.base_sample_rate)
|
||||
}
|
||||
|
||||
fn try_recover_inner_from_offload(&mut self) {
|
||||
if self.inner.is_some() || self.offload.is_none() {
|
||||
return;
|
||||
}
|
||||
self.request_handback_if_needed();
|
||||
self.poll_handback();
|
||||
}
|
||||
|
||||
fn next_from_inner_or_pad(&mut self) -> Option<f32> {
|
||||
self.try_recover_inner_from_offload();
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
return inner.next();
|
||||
}
|
||||
if self
|
||||
.offload
|
||||
.as_ref()
|
||||
.is_some_and(|offload| !offload.is_done())
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> Iterator for PlaybackRateSource<S> {
|
||||
type Item = f32;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if !is_effect_active(&self.atomics) {
|
||||
if let Some(offload) = self.offload.as_mut() {
|
||||
if let Some(s) = offload.pop() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
return self.next_from_inner_or_pad();
|
||||
}
|
||||
|
||||
if uses_preserve_dsp(self.atomics.load_strategy()) {
|
||||
self.ensure_offload();
|
||||
if let Some(s) = self.offload.as_mut().and_then(|o| o.pop()) {
|
||||
return Some(s);
|
||||
}
|
||||
if self
|
||||
.offload
|
||||
.as_ref()
|
||||
.is_some_and(|offload| !offload.is_done())
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Varispeed: decoder must stay in `inner` (never in the preserve worker).
|
||||
if self.offload.is_some() {
|
||||
self.try_recover_inner_from_offload();
|
||||
}
|
||||
self.next_from_inner_or_pad()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> Source for PlaybackRateSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.as_ref()?.current_span_len()
|
||||
}
|
||||
|
||||
fn channels(&self) -> ChannelCount {
|
||||
self.base_channels
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
if is_effect_active(&self.atomics) && self.atomics.load_strategy() == STRATEGY_VARISPEED {
|
||||
let factor = self.atomics.load_speed().max(0.001);
|
||||
SampleRate::new((self.base_sample_rate().get() as f32 * factor).max(1.0) as u32)
|
||||
.unwrap_or(self.base_sample_rate)
|
||||
} else {
|
||||
self.base_sample_rate()
|
||||
}
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.inner.as_ref()?.total_duration()
|
||||
}
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
// UI / transport always pass content-timeline seconds (0..full track).
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
inner.try_seek(pos)?;
|
||||
}
|
||||
if let Some(offload) = self.offload.as_mut() {
|
||||
offload.request_seek(pos);
|
||||
offload.drain();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pitch_shift::{Shifter, TOTAL_F32};
|
||||
|
||||
#[test]
|
||||
fn passthrough_when_disabled() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
assert!(!is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_at_unity() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
assert!(!is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_when_speed_not_one() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_duration_is_content_timeline() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
for strat in [
|
||||
STRATEGY_VARISPEED,
|
||||
STRATEGY_SPEED_CORRECTED,
|
||||
STRATEGY_PRESERVE_PITCH,
|
||||
] {
|
||||
a.strategy.store(strat, Ordering::Relaxed);
|
||||
assert!(
|
||||
(effective_duration_secs(200.0, &a) - 200.0).abs() < 0.001,
|
||||
"strategy {strat}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_varispeed_uses_counter() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!((effective_position_secs(20.0, &a) - 20.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_preserve_scales_with_speed() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!((effective_position_secs(10.0, &a) - 20.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_inactive_is_raw() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
assert!((effective_position_secs(15.0, &a) - 15.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_counter_samples_roundtrip_content_timeline() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(120.0, 44_100, 2, &a);
|
||||
let back = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
assert!((back - 120.0).abs() < 0.05, "roundtrip at 2x preserve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_counter_samples_roundtrip_varispeed() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(90.0, 44_100, 2, &a);
|
||||
let back = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
assert!((back - 90.0).abs() < 0.05, "roundtrip at 2x varispeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_seek_uses_content_timeline() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct SeekSpy {
|
||||
rate: SampleRate,
|
||||
last_seek_secs: Arc<AtomicU64>,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Iterator for SeekSpy {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SeekSpy {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
ChannelCount::new(1).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
self.rate
|
||||
}
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
Some(Duration::from_secs(200))
|
||||
}
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
self.last_seek_secs
|
||||
.store(pos.as_secs_f64().to_bits(), AtomicOrdering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let last = Arc::new(AtomicU64::new(f64::NAN.to_bits()));
|
||||
let spy = SeekSpy {
|
||||
rate: SampleRate::new(44_100).unwrap(),
|
||||
last_seek_secs: last.clone(),
|
||||
remaining: 44_100,
|
||||
};
|
||||
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let mut src = PlaybackRateSource::new(spy, a);
|
||||
src.try_seek(Duration::from_secs(120)).unwrap();
|
||||
let got = f64::from_bits(last.load(AtomicOrdering::Relaxed));
|
||||
assert!(
|
||||
(got - 120.0).abs() < 0.001,
|
||||
"varispeed seek must not scale content position, got {got}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_out_samples_clamped() {
|
||||
assert_eq!(preserve_out_samples(2.0), 64);
|
||||
assert_eq!(preserve_out_samples(0.5), 256);
|
||||
}
|
||||
|
||||
struct FixedRateSource {
|
||||
rate: u32,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Iterator for FixedRateSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for FixedRateSource {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
std::num::NonZero::new(1).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
SampleRate::new(self.rate).unwrap()
|
||||
}
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
Some(Duration::from_secs(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_corrected_uses_preserve_dsp_path() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(uses_preserve_dsp(atomics.load_strategy()));
|
||||
assert!(is_effect_active(&atomics));
|
||||
assert_eq!(effective_pitch(&atomics), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_pitch_respects_manual_pitch() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
|
||||
atomics.pitch_semitones.store(3.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(is_effect_active(&atomics));
|
||||
assert_eq!(effective_pitch(&atomics), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_switch_preserve_to_varispeed_does_not_end_early() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let mut src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 44_100,
|
||||
remaining: 50_000,
|
||||
},
|
||||
atomics.clone(),
|
||||
);
|
||||
for _ in 0..5_000 {
|
||||
assert!(src.next().is_some());
|
||||
}
|
||||
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
|
||||
let mut got = 0usize;
|
||||
for _ in 0..2_000 {
|
||||
if src.next().is_some() {
|
||||
got += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
got > 100,
|
||||
"varispeed should continue after preserve strategy switch, got {got} samples"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_scales_reported_sample_rate() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
let src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 44_100,
|
||||
remaining: 1,
|
||||
},
|
||||
atomics,
|
||||
);
|
||||
assert_eq!(src.sample_rate().get(), 66_150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_propagates_through_dyn_source() {
|
||||
use crate::sources::DynSource;
|
||||
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
atomics.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let rate_src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 48_000,
|
||||
remaining: 1,
|
||||
},
|
||||
atomics,
|
||||
);
|
||||
let dyn_src = DynSource::new(rate_src);
|
||||
assert_eq!(dyn_src.sample_rate().get(), 96_000);
|
||||
}
|
||||
|
||||
fn rms_f32(samples: &[f32]) -> f32 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_pitch_makeup_keeps_level_reasonable() {
|
||||
let sr = 44_100f32;
|
||||
let mut input = [0.0f32; 128];
|
||||
for (i, s) in input.iter_mut().enumerate() {
|
||||
*s = (i as f32 * 0.12).sin() * 0.75;
|
||||
}
|
||||
let in_rms = rms_f32(&input);
|
||||
let mut shifter: Shifter<Box<[f32; TOTAL_F32]>> =
|
||||
Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
for _ in 0..24 {
|
||||
shifter.shift(&input, 4.0, 128, sr);
|
||||
}
|
||||
let dry = shifter.shift(&input, 4.0, 128, sr);
|
||||
let boosted: Vec<f32> = dry
|
||||
.iter()
|
||||
.map(|&s| (s * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0))
|
||||
.collect();
|
||||
let out_rms = rms_f32(&boosted);
|
||||
assert!(out_rms > in_rms * 0.8, "out_rms={out_rms} in_rms={in_rms}");
|
||||
assert!(out_rms < in_rms * 1.25, "out_rms={out_rms} in_rms={in_rms}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_speed_change_represerves_content_position() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let samples = raw_counter_samples_for_content_position(30.0, 44_100, 2, &atomics);
|
||||
let content = content_position_from_samples(samples, 44_100, 2, &atomics);
|
||||
assert!((content - 30.0).abs() < 0.05);
|
||||
|
||||
atomics.speed.store(1.8f32.to_bits(), Ordering::Relaxed);
|
||||
let restamped =
|
||||
raw_counter_samples_for_content_position(content, 44_100, 2, &atomics);
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
|
||||
assert!((after - 30.0).abs() < 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Background worker for preserve-pitch DSP (phase vocoder is too heavy for cpal callback).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use pitch_shift::{Shifter, TOTAL_F32};
|
||||
use ringbuf::traits::{Consumer, Observer, Producer, Split};
|
||||
use ringbuf::{HeapCons, HeapProd, HeapRb};
|
||||
use rodio::Source;
|
||||
|
||||
use crate::playback_rate::{
|
||||
effective_pitch, is_effect_active, preserve_out_samples, PlaybackRateAtomics, PRESERVE_MAKEUP_GAIN,
|
||||
uses_preserve_dsp,
|
||||
};
|
||||
|
||||
const FRAME_BLOCK: usize = 128;
|
||||
const PRESERVE_OUT_MAX: usize = 1023;
|
||||
const PRESERVE_PARAM_EPS_PITCH: f32 = 0.05;
|
||||
const PRESERVE_PARAM_EPS_SPEED: f32 = 0.001;
|
||||
const RB_MIN_CAPACITY: usize = 44_100 * 2 * 2; // ~2 s stereo @ 44.1 kHz
|
||||
const RB_TARGET_FILL: f32 = 0.6;
|
||||
const RB_FILL_HIGH: f32 = 0.88;
|
||||
const FORWARD_BATCH: usize = 4096;
|
||||
const WORKER_IDLE_SLEEP: Duration = Duration::from_millis(1);
|
||||
|
||||
enum WorkerCmd {
|
||||
Seek(Duration),
|
||||
Handback,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
struct PreserveWorkerEnv {
|
||||
atomics: PlaybackRateAtomics,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
capacity: usize,
|
||||
stop: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
cmd_rx: mpsc::Receiver<WorkerCmd>,
|
||||
}
|
||||
|
||||
pub(crate) struct PreserveOffload {
|
||||
cons: HeapCons<f32>,
|
||||
stop: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
cmd_tx: SyncSender<WorkerCmd>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PreserveOffload {
|
||||
pub(crate) fn spawn<S: Source<Item = f32> + Send + 'static>(
|
||||
inner: S,
|
||||
atomics: PlaybackRateAtomics,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
handback_tx: SyncSender<S>,
|
||||
) -> Self {
|
||||
let cap = ((sample_rate as f32 * channels as f32 * 2.5) as usize).max(RB_MIN_CAPACITY);
|
||||
let rb = HeapRb::<f32>::new(cap);
|
||||
let (prod, cons) = rb.split();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let (cmd_tx, cmd_rx) = mpsc::sync_channel::<WorkerCmd>(8);
|
||||
let stop_worker = stop.clone();
|
||||
let done_worker = done.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("psysonic-preserve-pitch".into())
|
||||
.spawn(move || {
|
||||
worker_main(
|
||||
inner,
|
||||
prod,
|
||||
PreserveWorkerEnv {
|
||||
atomics,
|
||||
sample_rate,
|
||||
channels,
|
||||
capacity: cap,
|
||||
stop: stop_worker,
|
||||
done: done_worker,
|
||||
cmd_rx,
|
||||
},
|
||||
handback_tx,
|
||||
);
|
||||
})
|
||||
.expect("spawn preserve-pitch worker");
|
||||
|
||||
Self {
|
||||
cons,
|
||||
stop,
|
||||
done,
|
||||
cmd_tx,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pop(&mut self) -> Option<f32> {
|
||||
self.cons.try_pop()
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.done.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn request_seek(&self, pos: Duration) {
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Seek(pos));
|
||||
}
|
||||
|
||||
pub(crate) fn request_handback(&self) {
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Handback);
|
||||
}
|
||||
|
||||
pub(crate) fn drain(&mut self) {
|
||||
while self.cons.try_pop().is_some() {}
|
||||
}
|
||||
|
||||
pub(crate) fn join(mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PreserveOffload {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PreserveChannelState {
|
||||
shifter: Shifter<Box<[f32; TOTAL_F32]>>,
|
||||
frame: Vec<f32>,
|
||||
}
|
||||
|
||||
impl PreserveChannelState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
shifter: Shifter::new(Box::new([0.0; TOTAL_F32])),
|
||||
frame: Vec::with_capacity(FRAME_BLOCK),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
self.frame.clear();
|
||||
}
|
||||
|
||||
fn reset_shifter(&mut self) {
|
||||
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
}
|
||||
}
|
||||
|
||||
struct PreserveState {
|
||||
channels: Vec<PreserveChannelState>,
|
||||
pending: VecDeque<f32>,
|
||||
channel_idx: usize,
|
||||
last_pitch: f32,
|
||||
last_speed: f32,
|
||||
}
|
||||
|
||||
impl PreserveState {
|
||||
fn for_channels(count: u16) -> Self {
|
||||
let n = count.max(1) as usize;
|
||||
Self {
|
||||
channels: (0..n).map(|_| PreserveChannelState::new()).collect(),
|
||||
pending: VecDeque::new(),
|
||||
channel_idx: 0,
|
||||
last_pitch: f32::NAN,
|
||||
last_speed: f32::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, channels: u16) {
|
||||
let n = channels.max(1) as usize;
|
||||
if self.channels.len() != n {
|
||||
self.channels = (0..n).map(|_| PreserveChannelState::new()).collect();
|
||||
} else {
|
||||
for ch in &mut self.channels {
|
||||
ch.reset();
|
||||
}
|
||||
}
|
||||
self.pending.clear();
|
||||
self.channel_idx = 0;
|
||||
self.last_pitch = f32::NAN;
|
||||
self.last_speed = f32::NAN;
|
||||
}
|
||||
|
||||
fn reset_if_params_changed(&mut self, pitch: f32, speed: f32) {
|
||||
if self.last_pitch.is_nan() {
|
||||
self.last_pitch = pitch;
|
||||
self.last_speed = speed;
|
||||
return;
|
||||
}
|
||||
if (pitch - self.last_pitch).abs() > PRESERVE_PARAM_EPS_PITCH
|
||||
|| (speed - self.last_speed).abs() > PRESERVE_PARAM_EPS_SPEED
|
||||
{
|
||||
for ch in &mut self.channels {
|
||||
ch.reset_shifter();
|
||||
}
|
||||
self.pending.clear();
|
||||
self.last_pitch = pitch;
|
||||
self.last_speed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
fn process_block(&mut self, speed: f32, pitch: f32, sample_rate: f32) {
|
||||
self.reset_if_params_changed(pitch, speed);
|
||||
let out_n = preserve_out_samples(speed).clamp(1, PRESERVE_OUT_MAX);
|
||||
let ch_count = self.channels.len();
|
||||
let mut outs: Vec<&[f32]> = Vec::with_capacity(ch_count);
|
||||
for ch in &mut self.channels {
|
||||
if ch.frame.len() == FRAME_BLOCK {
|
||||
let out = ch.shifter.shift(&ch.frame, pitch, out_n, sample_rate);
|
||||
outs.push(out);
|
||||
ch.frame.clear();
|
||||
}
|
||||
}
|
||||
if outs.len() != ch_count {
|
||||
return;
|
||||
}
|
||||
for i in 0..out_n {
|
||||
for out_slice in &outs {
|
||||
if let Some(&sample) = out_slice.get(i) {
|
||||
self.pending
|
||||
.push_back((sample * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ring_fill(prod: &HeapProd<f32>, capacity: usize) -> f32 {
|
||||
1.0 - prod.vacant_len() as f32 / capacity as f32
|
||||
}
|
||||
|
||||
fn push_pending(prod: &mut HeapProd<f32>, pending: &mut VecDeque<f32>, stop: &AtomicBool) {
|
||||
while let Some(&s) = pending.front() {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if prod.try_push(s).is_ok() {
|
||||
pending.pop_front();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_passthrough<S: Source<Item = f32>>(
|
||||
inner: &mut S,
|
||||
prod: &mut HeapProd<f32>,
|
||||
capacity: usize,
|
||||
stop: &AtomicBool,
|
||||
) -> bool {
|
||||
let target = (capacity as f32 * RB_TARGET_FILL) as usize;
|
||||
let mut pushed = 0usize;
|
||||
while prod.occupied_len() < target && pushed < FORWARD_BATCH {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Some(s) = inner.next() else {
|
||||
return false;
|
||||
};
|
||||
if prod.try_push(s).is_err() {
|
||||
break;
|
||||
}
|
||||
pushed += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn worker_main<S: Source<Item = f32> + Send>(
|
||||
mut inner: S,
|
||||
mut prod: HeapProd<f32>,
|
||||
env: PreserveWorkerEnv,
|
||||
handback_tx: SyncSender<S>,
|
||||
) {
|
||||
let PreserveWorkerEnv {
|
||||
atomics,
|
||||
sample_rate,
|
||||
channels,
|
||||
capacity,
|
||||
stop,
|
||||
done,
|
||||
cmd_rx,
|
||||
} = env;
|
||||
let ch_count = channels.max(1) as usize;
|
||||
let mut preserve = PreserveState::for_channels(channels);
|
||||
let sr = sample_rate as f32;
|
||||
|
||||
'run: while !stop.load(Ordering::Acquire) {
|
||||
if let Ok(cmd) = cmd_rx.try_recv() {
|
||||
match cmd {
|
||||
WorkerCmd::Shutdown => break,
|
||||
WorkerCmd::Handback => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
WorkerCmd::Seek(pos) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let use_preserve = atomics.enabled.load(Ordering::Relaxed)
|
||||
&& uses_preserve_dsp(atomics.load_strategy())
|
||||
&& is_effect_active(&atomics);
|
||||
|
||||
if !use_preserve {
|
||||
preserve.reset(channels);
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let fill = ring_fill(&prod, capacity);
|
||||
if fill >= RB_FILL_HIGH {
|
||||
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
|
||||
Ok(WorkerCmd::Shutdown) => break 'run,
|
||||
Ok(WorkerCmd::Handback) => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
Ok(WorkerCmd::Seek(pos)) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
Err(RecvTimeoutError::Disconnected) => break 'run,
|
||||
}
|
||||
}
|
||||
if !forward_passthrough(&mut inner, &mut prod, capacity, &stop) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let fill = ring_fill(&prod, capacity);
|
||||
if fill >= RB_FILL_HIGH {
|
||||
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
|
||||
Ok(WorkerCmd::Shutdown) => break 'run,
|
||||
Ok(WorkerCmd::Handback) => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
Ok(WorkerCmd::Seek(pos)) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
Err(RecvTimeoutError::Disconnected) => break 'run,
|
||||
}
|
||||
}
|
||||
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
|
||||
if !preserve.pending.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match inner.next() {
|
||||
Some(s) => {
|
||||
let ch = preserve.channel_idx;
|
||||
preserve.channels[ch].frame.push(s);
|
||||
preserve.channel_idx = (ch + 1) % ch_count;
|
||||
if preserve
|
||||
.channels
|
||||
.iter()
|
||||
.all(|c| c.frame.len() >= FRAME_BLOCK)
|
||||
{
|
||||
preserve.process_block(
|
||||
atomics.load_speed(),
|
||||
effective_pitch(&atomics),
|
||||
sr,
|
||||
);
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
done.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::playback_rate::STRATEGY_PRESERVE_PITCH;
|
||||
use rodio::{ChannelCount, SampleRate};
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
struct SineSource {
|
||||
remaining: usize,
|
||||
rate: u32,
|
||||
}
|
||||
|
||||
impl Iterator for SineSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.25)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SineSource {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
std::num::NonZero::new(2).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
SampleRate::new(self.rate).unwrap()
|
||||
}
|
||||
fn total_duration(&self) -> Option<StdDuration> {
|
||||
Some(StdDuration::from_secs(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_prefills_ring_before_done() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
|
||||
atomics.speed.store(1.25f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let src = SineSource {
|
||||
remaining: 44_100 * 2,
|
||||
rate: 44_100,
|
||||
};
|
||||
let (tx, _rx) = mpsc::sync_channel(1);
|
||||
let mut offload = PreserveOffload::spawn(src, atomics, 44_100, 2, tx);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
let mut got = 0usize;
|
||||
for _ in 0..10_000 {
|
||||
if let Some(s) = offload.pop() {
|
||||
got += 1;
|
||||
if got > 500 {
|
||||
break;
|
||||
}
|
||||
let _ = s;
|
||||
} else if offload.is_done() {
|
||||
break;
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
assert!(got > 500, "expected prefetched samples, got {got}");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -68,6 +69,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
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
|
||||
@@ -196,10 +198,11 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
|
||||
// Read playback snapshot under a single lock to minimize contention
|
||||
// with seek/play/pause commands that also touch `current`.
|
||||
let (dur, paused_at) = {
|
||||
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) {
|
||||
@@ -207,7 +210,8 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
} else if let Some(p) = paused_at {
|
||||
p
|
||||
} else {
|
||||
(samples / divisor).min(dur.max(0.001))
|
||||
effective_position_secs(samples / divisor, &playback_rate)
|
||||
.min(dur.max(0.001))
|
||||
};
|
||||
let progress_latency = if is_paused {
|
||||
0.0
|
||||
@@ -333,6 +337,7 @@ mod tests {
|
||||
gapless_switch_at: Arc<AtomicU64>,
|
||||
playback_url: Arc<Mutex<Option<String>>>,
|
||||
stream_playback_armed: Arc<AtomicBool>,
|
||||
playback_rate: PlaybackRateAtomics,
|
||||
}
|
||||
|
||||
impl TaskHarness {
|
||||
@@ -362,6 +367,7 @@ mod tests {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +387,7 @@ mod tests {
|
||||
self.gapless_switch_at.clone(),
|
||||
self.playback_url.clone(),
|
||||
self.stream_playback_armed.clone(),
|
||||
self.playback_rate.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use rodio::{Player, Source};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::playback_rate::PlaybackRateAtomics;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{content_type_to_hint, MASTER_HEADROOM};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
@@ -190,6 +191,7 @@ pub async fn audio_play_radio(
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
PlaybackRateAtomics::default(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -14,7 +14,6 @@ const EQ_CHECK_INTERVAL: usize = 1024;
|
||||
|
||||
pub(crate) struct EqSource<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_rate: rodio::SampleRate,
|
||||
channels: rodio::ChannelCount,
|
||||
gains: Arc<[AtomicU32; 10]>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
@@ -47,7 +46,7 @@ impl<S: Source<Item = f32>> EqSource<S> {
|
||||
})
|
||||
});
|
||||
Self {
|
||||
inner, sample_rate, channels, gains, enabled, pre_gain,
|
||||
inner, channels, gains, enabled, pre_gain,
|
||||
filters,
|
||||
current_gains: [0.0; 10],
|
||||
sample_counter: 0,
|
||||
@@ -57,14 +56,15 @@ impl<S: Source<Item = f32>> EqSource<S> {
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn refresh_if_needed(&mut self) {
|
||||
let sample_rate = self.inner.sample_rate();
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
if (gain_db - self.current_gains[band]).abs() > 0.01 {
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate.get() as f32).hz(),
|
||||
(sample_rate.get() as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
@@ -109,19 +109,20 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
|
||||
impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.channels }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
let sample_rate = self.inner.sample_rate();
|
||||
// Reset biquad filter state to avoid glitches after seek.
|
||||
for band in 0..10 {
|
||||
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
|
||||
self.current_gains[band] = gain_db;
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
|
||||
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
|
||||
if let Ok(coeffs) = Coefficients::<f32>::from_params(
|
||||
FilterType::PeakingEQ(gain_db),
|
||||
(self.sample_rate.get() as f32).hz(),
|
||||
(sample_rate.get() as f32).hz(),
|
||||
freq.hz(),
|
||||
EQ_Q,
|
||||
) {
|
||||
@@ -144,14 +145,12 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
|
||||
pub(crate) struct DynSource {
|
||||
inner: Box<dyn Source<Item = f32> + Send>,
|
||||
channels: rodio::ChannelCount,
|
||||
sample_rate: rodio::SampleRate,
|
||||
}
|
||||
|
||||
impl DynSource {
|
||||
pub(crate) fn new(src: impl Source<Item = f32> + Send + 'static) -> Self {
|
||||
let channels = src.channels();
|
||||
let sample_rate = src.sample_rate();
|
||||
Self { inner: Box::new(src), channels, sample_rate }
|
||||
Self { inner: Box::new(src), channels }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +162,7 @@ impl Iterator for DynSource {
|
||||
impl Source for DynSource {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.channels }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
self.inner.try_seek(pos)
|
||||
|
||||
@@ -11,6 +11,9 @@ use ringbuf::HeapRb;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::playback_rate::{
|
||||
content_position_from_samples, raw_counter_samples_for_content_position,
|
||||
};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
|
||||
|
||||
@@ -19,9 +22,15 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() {
|
||||
let pos = cur.position();
|
||||
let pos = content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
)
|
||||
.min(cur.duration_secs.max(0.001));
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
}
|
||||
}
|
||||
@@ -125,6 +134,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
let state = state.inner();
|
||||
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
|
||||
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
|
||||
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
|
||||
@@ -169,10 +179,12 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
||||
};
|
||||
|
||||
// Seeking back invalidates any pending gapless chain.
|
||||
let cur_pos = {
|
||||
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
cur.position()
|
||||
};
|
||||
let cur_pos = content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
);
|
||||
if seconds < cur_pos - 1.0 {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
}
|
||||
@@ -219,5 +231,14 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
||||
cur.seek_offset = seek_seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
seek_seconds,
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user