From e8e41752a7001df66bf0574f3eb010cf5e0bb2bb Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 22 May 2026 19:59:28 +0300 Subject: [PATCH] feat(playback): global speed with three strategies (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- CHANGELOG.md | 11 + src-tauri/Cargo.lock | 40 ++ src-tauri/crates/psysonic-audio/Cargo.toml | 1 + .../crates/psysonic-audio/src/commands.rs | 21 +- src-tauri/crates/psysonic-audio/src/decode.rs | 39 +- .../psysonic-audio/src/device_resume.rs | 10 + src-tauri/crates/psysonic-audio/src/engine.rs | 2 + src-tauri/crates/psysonic-audio/src/lib.rs | 2 + .../crates/psysonic-audio/src/mix_commands.rs | 83 +++ .../crates/psysonic-audio/src/play_input.rs | 3 + .../psysonic-audio/src/playback_rate.rs | 662 ++++++++++++++++++ .../psysonic-audio/src/preserve_worker.rs | 467 ++++++++++++ .../psysonic-audio/src/progress_task.rs | 11 +- .../psysonic-audio/src/radio_commands.rs | 2 + .../crates/psysonic-audio/src/sources.rs | 21 +- .../psysonic-audio/src/transport_commands.rs | 33 +- src-tauri/src/lib.rs | 1 + src/app/AppShell.tsx | 5 + src/components/PlayerBar.tsx | 5 + .../playerBar/PlayerOverflowMenu.tsx | 5 + .../playerBar/PlayerPlaybackRate.tsx | 81 +++ src/components/settings/AudioTab.tsx | 12 + .../settings/PlayerBarLayoutCustomizer.tsx | 4 +- .../settings/audio/PlaybackRateBlock.tsx | 211 ++++++ src/components/settings/settingsTabs.ts | 1 + src/config/settingsCredits.ts | 1 + src/hooks/usePlaybackRateOrbitSync.ts | 13 + src/hooks/usePlayerBarAnchoredPopover.ts | 63 ++ src/hooks/useUtilityOverflowMenu.ts | 4 +- src/locales/de/player.ts | 1 + src/locales/de/settings.ts | 16 + src/locales/en/player.ts | 1 + src/locales/en/settings.ts | 16 + src/locales/es/player.ts | 1 + src/locales/es/settings.ts | 16 + src/locales/fr/player.ts | 1 + src/locales/fr/settings.ts | 16 + src/locales/nb/player.ts | 1 + src/locales/nb/settings.ts | 16 + src/locales/nl/player.ts | 1 + src/locales/nl/settings.ts | 16 + src/locales/ro/player.ts | 1 + src/locales/ro/settings.ts | 16 + src/locales/ru/player.ts | 1 + src/locales/ru/settings.ts | 16 + src/locales/zh/player.ts | 1 + src/locales/zh/settings.ts | 16 + src/store/playListenSession.test.ts | 8 +- src/store/playListenSession.ts | 10 +- src/store/playbackRateStore.ts | 99 +++ src/store/playerBarLayoutStore.test.ts | 4 +- src/store/playerBarLayoutStore.ts | 2 + src/store/playerStore.ts | 1 - src/store/previewPlayerVolumeSync.ts | 13 + src/store/previewStore.test.ts | 1 + src/store/previewStore.ts | 20 +- src/styles/components/index.css | 1 + src/styles/components/playback-rate.css | 93 +++ src/utils/audio/playbackRateHelpers.test.ts | 43 ++ src/utils/audio/playbackRateHelpers.ts | 74 ++ src/utils/audio/playbackRateRestart.test.ts | 54 ++ src/utils/audio/playbackRateRestart.ts | 70 ++ src/utils/orbit.ts | 1 + src/utils/orbit/sessionActive.ts | 14 + 64 files changed, 2405 insertions(+), 70 deletions(-) create mode 100644 src-tauri/crates/psysonic-audio/src/playback_rate.rs create mode 100644 src-tauri/crates/psysonic-audio/src/preserve_worker.rs create mode 100644 src/components/playerBar/PlayerPlaybackRate.tsx create mode 100644 src/components/settings/audio/PlaybackRateBlock.tsx create mode 100644 src/hooks/usePlaybackRateOrbitSync.ts create mode 100644 src/hooks/usePlayerBarAnchoredPopover.ts create mode 100644 src/store/playbackRateStore.ts create mode 100644 src/store/previewPlayerVolumeSync.ts create mode 100644 src/styles/components/playback-rate.css create mode 100644 src/utils/audio/playbackRateHelpers.test.ts create mode 100644 src/utils/audio/playbackRateHelpers.ts create mode 100644 src/utils/audio/playbackRateRestart.test.ts create mode 100644 src/utils/audio/playbackRateRestart.ts create mode 100644 src/utils/orbit/sessionActive.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3f0c18..5ce70c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Playback speed — global tempo and pitch strategies + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#852](https://github.com/Psychotoxical/psysonic/pull/852)** + +* **Settings → Audio** and player bar: global speed **0.5–2.0×** with three strategies — **Speed** (pitch-corrected, default), **Varispeed**, and **Pitch shift** (manual semitone offset). +* Rust path: preserve-pitch worker (`pitch_shift`) for Speed / Pitch shift; varispeed via sample-rate scaling; seek-restart on strategy or enable changes. +* Unified content timeline for seek bar, elapsed time, and seek; player bar popover anchored like volume (compact controls; full hints in Settings). +* **Orbit:** passthrough **1.0×** while a session is active. Not applied to radio or preview. + + + ### Servers — edit existing profiles **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)** diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b6508c85..0078435a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2790,6 +2790,23 @@ dependencies = [ "autocfg", ] +[[package]] +name = "microfft" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b6673eb0cc536241d6734c2ca45abfdbf90e9e7791c66a36a7ba3c315b76cf" +dependencies = [ + "cfg-if", + "num-complex", + "static_assertions", +] + +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + [[package]] name = "mime" version = "0.3.17" @@ -2922,6 +2939,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -3502,6 +3529,18 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pitch_shift" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0768e7cebce3deed6b8249e3c636f65cdb791e235258694b6d12f71f036ce9d3" +dependencies = [ + "bytemuck", + "microfft", + "micromath", + "num-complex", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -3783,6 +3822,7 @@ dependencies = [ "libc", "lofty", "md5", + "pitch_shift", "psysonic-analysis", "psysonic-core", "reqwest", diff --git a/src-tauri/crates/psysonic-audio/Cargo.toml b/src-tauri/crates/psysonic-audio/Cargo.toml index 898812d1..e53432eb 100644 --- a/src-tauri/crates/psysonic-audio/Cargo.toml +++ b/src-tauri/crates/psysonic-audio/Cargo.toml @@ -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" diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs index faa49613..fd6cbbf5 100644 --- a/src-tauri/crates/psysonic-audio/src/commands.rs +++ b/src-tauri/crates/psysonic-audio/src/commands.rs @@ -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(), diff --git a/src-tauri/crates/psysonic-audio/src/decode.rs b/src-tauri/crates/psysonic-audio/src/decode.rs index 4fc19983..d504c875 100644 --- a/src-tauri/crates/psysonic-audio/src/decode.rs +++ b/src-tauri/crates/psysonic-audio/src/decode.rs @@ -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, eq_pre_gain: Arc, + playback_rate: PlaybackRateAtomics, done_flag: Arc, fade_in_dur: Duration, sample_counter: Arc, @@ -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, eq_pre_gain: Arc, + playback_rate: PlaybackRateAtomics, done_flag: Arc, fade_in_dur: Duration, sample_counter: Arc, @@ -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, Arc, Arc, Arc); + type SourceArgs = ( + EqGains, + Arc, + Arc, + PlaybackRateAtomics, + Arc, + Arc, + ); 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, diff --git a/src-tauri/crates/psysonic-audio/src/device_resume.rs b/src-tauri/crates/psysonic-audio/src/device_resume.rs index bea2bc8a..1fca998d 100644 --- a/src-tauri/crates/psysonic-audio/src/device_resume.rs +++ b/src-tauri/crates/psysonic-audio/src/device_resume.rs @@ -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!( diff --git a/src-tauri/crates/psysonic-audio/src/engine.rs b/src-tauri/crates/psysonic-audio/src/engine.rs index fc9e0468..5632b05c 100644 --- a/src-tauri/crates/psysonic-audio/src/engine.rs +++ b/src-tauri/crates/psysonic-audio/src/engine.rs @@ -32,6 +32,7 @@ pub struct AudioEngine { pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_enabled: Arc, pub eq_pre_gain: Arc, + pub playback_rate: crate::playback_rate::PlaybackRateAtomics, pub(crate) preloaded: Arc>>, /// 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)), diff --git a/src-tauri/crates/psysonic-audio/src/lib.rs b/src-tauri/crates/psysonic-audio/src/lib.rs index 1da1b7da..4856e7a8 100644 --- a/src-tauri/crates/psysonic-audio/src/lib.rs +++ b/src-tauri/crates/psysonic-audio/src/lib.rs @@ -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; diff --git a/src-tauri/crates/psysonic-audio/src/mix_commands.rs b/src-tauri/crates/psysonic-audio/src/mix_commands.rs index 7acf245f..7c449832 100644 --- a/src-tauri/crates/psysonic-audio/src/mix_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/mix_commands.rs @@ -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, diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index ee088141..94428d0d 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -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(), diff --git a/src-tauri/crates/psysonic-audio/src/playback_rate.rs b/src-tauri/crates/psysonic-audio/src/playback_rate.rs new file mode 100644 index 00000000..52c2b5ed --- /dev/null +++ b/src-tauri/crates/psysonic-audio/src/playback_rate.rs @@ -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, + pub strategy: Arc, + pub speed: Arc, + pub pitch_semitones: Arc, +} + +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 + Send + 'static> { + inner: Option, + base_sample_rate: SampleRate, + base_channels: ChannelCount, + atomics: PlaybackRateAtomics, + offload: Option, + handback_rx: Option>, + handback_requested: bool, +} + +impl + Send + 'static> PlaybackRateSource { + 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 { + 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 + Send + 'static> Iterator for PlaybackRateSource { + type Item = f32; + + fn next(&mut self) -> Option { + 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 + Send + 'static> Source for PlaybackRateSource { + fn current_span_len(&self) -> Option { + 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 { + 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, + remaining: usize, + } + + impl Iterator for SeekSpy { + type Item = f32; + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + self.remaining -= 1; + Some(0.0) + } + } + + impl Source for SeekSpy { + fn current_span_len(&self) -> Option { + Some(self.remaining) + } + fn channels(&self) -> ChannelCount { + ChannelCount::new(1).unwrap() + } + fn sample_rate(&self) -> SampleRate { + self.rate + } + fn total_duration(&self) -> Option { + 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 { + if self.remaining == 0 { + return None; + } + self.remaining -= 1; + Some(0.0) + } + } + + impl Source for FixedRateSource { + fn current_span_len(&self) -> Option { + 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 { + 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::() / 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> = + 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 = 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); + } +} diff --git a/src-tauri/crates/psysonic-audio/src/preserve_worker.rs b/src-tauri/crates/psysonic-audio/src/preserve_worker.rs new file mode 100644 index 00000000..976abcb9 --- /dev/null +++ b/src-tauri/crates/psysonic-audio/src/preserve_worker.rs @@ -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, + done: Arc, + cmd_rx: mpsc::Receiver, +} + +pub(crate) struct PreserveOffload { + cons: HeapCons, + stop: Arc, + done: Arc, + cmd_tx: SyncSender, + thread: Option>, +} + +impl PreserveOffload { + pub(crate) fn spawn + Send + 'static>( + inner: S, + atomics: PlaybackRateAtomics, + sample_rate: u32, + channels: u16, + handback_tx: SyncSender, + ) -> Self { + let cap = ((sample_rate as f32 * channels as f32 * 2.5) as usize).max(RB_MIN_CAPACITY); + let rb = HeapRb::::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::(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 { + 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>, + frame: Vec, +} + +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, + pending: VecDeque, + 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, capacity: usize) -> f32 { + 1.0 - prod.vacant_len() as f32 / capacity as f32 +} + +fn push_pending(prod: &mut HeapProd, pending: &mut VecDeque, 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>( + inner: &mut S, + prod: &mut HeapProd, + 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 + Send>( + mut inner: S, + mut prod: HeapProd, + env: PreserveWorkerEnv, + handback_tx: SyncSender, +) { + 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 { + if self.remaining == 0 { + return None; + } + self.remaining -= 1; + Some(0.25) + } + } + + impl Source for SineSource { + fn current_span_len(&self) -> Option { + 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 { + 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}"); + } +} diff --git a/src-tauri/crates/psysonic-audio/src/progress_task.rs b/src-tauri/crates/psysonic-audio/src/progress_task.rs index dc2029eb..f390966e 100644 --- a/src-tauri/crates/psysonic-audio/src/progress_task.rs +++ b/src-tauri/crates/psysonic-audio/src/progress_task.rs @@ -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( gapless_switch_at: Arc, current_playback_url: Arc>>, stream_playback_armed: Arc, + 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( // 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( } 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, playback_url: Arc>>, stream_playback_armed: Arc, + 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(), ); } } diff --git a/src-tauri/crates/psysonic-audio/src/radio_commands.rs b/src-tauri/crates/psysonic-audio/src/radio_commands.rs index e50c1952..1ba1ed11 100644 --- a/src-tauri/crates/psysonic-audio/src/radio_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/radio_commands.rs @@ -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(()) diff --git a/src-tauri/crates/psysonic-audio/src/sources.rs b/src-tauri/crates/psysonic-audio/src/sources.rs index fcf59867..048f81a1 100644 --- a/src-tauri/crates/psysonic-audio/src/sources.rs +++ b/src-tauri/crates/psysonic-audio/src/sources.rs @@ -14,7 +14,6 @@ const EQ_CHECK_INTERVAL: usize = 1024; pub(crate) struct EqSource> { inner: S, - sample_rate: rodio::SampleRate, channels: rodio::ChannelCount, gains: Arc<[AtomicU32; 10]>, enabled: Arc, @@ -47,7 +46,7 @@ impl> EqSource { }) }); 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> EqSource { #[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::::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> Iterator for EqSource { impl> Source for EqSource { fn current_span_len(&self) -> Option { 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 { 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::::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> Source for EqSource { pub(crate) struct DynSource { inner: Box + Send>, channels: rodio::ChannelCount, - sample_rate: rodio::SampleRate, } impl DynSource { pub(crate) fn new(src: impl Source + 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 { 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 { self.inner.total_duration() } fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { self.inner.try_seek(pos) diff --git a/src-tauri/crates/psysonic-audio/src/transport_commands.rs b/src-tauri/crates/psysonic-audio/src/transport_commands.rs index 81a751c0..47266300 100644 --- a/src-tauri/crates/psysonic-audio/src/transport_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/transport_commands.rs @@ -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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c496a69..d9540cd7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -520,6 +520,7 @@ pub fn run() { audio::mix_commands::audio_set_volume, audio::mix_commands::audio_update_replay_gain, audio::mix_commands::audio_set_eq, + audio::mix_commands::audio_set_playback_rate, audio::autoeq_commands::autoeq_entries, audio::autoeq_commands::autoeq_fetch_profile, audio::preload_commands::audio_preload, diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index fd307267..62b9214a 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -53,9 +53,12 @@ import { useConnectionStatus } from '../hooks/useConnectionStatus'; import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; import { usePlayerStore } from '../store/playerStore'; +import '../store/previewPlayerVolumeSync'; import { useThemeStore } from '../store/themeStore'; import { useFontStore } from '../store/fontStore'; import { useEqStore } from '../store/eqStore'; +import { usePlaybackRateStore } from '../store/playbackRateStore'; +import { usePlaybackRateOrbitSync } from '../hooks/usePlaybackRateOrbitSync'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { persistSidebarCollapsed, @@ -78,6 +81,7 @@ export function AppShell() { useOrbitHost(); useOrbitGuest(); useOrbitBodyAttrs(); + usePlaybackRateOrbitSync(); useTrayMenuI18n(); useServerCapabilitiesProbe(); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); @@ -124,6 +128,7 @@ export function AppShell() { useEffect(() => { useEqStore.getState().syncToRust(); + usePlaybackRateStore.getState().syncToRust(); }, []); useEffect(() => { diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 2887bd46..e3276b66 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -34,6 +34,7 @@ import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock'; import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo'; import { PlayerTransportControls } from './playerBar/PlayerTransportControls'; import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection'; +import { PlayerPlaybackRate } from './playerBar/PlayerPlaybackRate'; import { PlayerVolume } from './playerBar/PlayerVolume'; import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu'; import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar'; @@ -279,6 +280,10 @@ export default function PlayerBar() { ) : ( <> + {isLayoutVisible('playbackRate') && ( + + )} + {isLayoutVisible('equalizer') && ( + + {open && createPortal( +
+ +
, + document.body, + )} + + ); +} diff --git a/src/components/settings/AudioTab.tsx b/src/components/settings/AudioTab.tsx index a0410914..c7fa72f4 100644 --- a/src/components/settings/AudioTab.tsx +++ b/src/components/settings/AudioTab.tsx @@ -8,7 +8,9 @@ import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/lou import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe'; import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection'; import { NormalizationBlock } from './audio/NormalizationBlock'; +import { Gauge } from 'lucide-react'; import { PlaybackBehaviorBlock } from './audio/PlaybackBehaviorBlock'; +import { PlaybackRateBlock } from './audio/PlaybackRateBlock'; import { TrackPreviewsSection } from './audio/TrackPreviewsSection'; export function AudioTab() { @@ -77,6 +79,16 @@ export function AudioTab() { + {/* Playback speed */} + } + > +
+ +
+
+ {/* Replay Gain + Crossfade + Gapless */} = { starRating: 'settings.playerBarStarRating', favorite: 'settings.playerBarFavorite', lastfmLove: 'settings.playerBarLastfmLove', + playbackRate: 'settings.playerBarPlaybackRate', equalizer: 'settings.playerBarEqualizer', miniPlayer: 'settings.playerBarMiniPlayer', }; @@ -23,6 +24,7 @@ const PLAYER_BAR_LAYOUT_ICONS: Record = ), + playbackRate: , equalizer: , miniPlayer: , }; diff --git a/src/components/settings/audio/PlaybackRateBlock.tsx b/src/components/settings/audio/PlaybackRateBlock.tsx new file mode 100644 index 00000000..590b025f --- /dev/null +++ b/src/components/settings/audio/PlaybackRateBlock.tsx @@ -0,0 +1,211 @@ +import React, { useCallback } from 'react'; +import type { TFunction } from 'i18next'; +import { + PLAYBACK_PITCH_MAX, + PLAYBACK_PITCH_MIN, + PLAYBACK_PITCH_STEP, + PLAYBACK_SPEED_MAX, + PLAYBACK_SPEED_MIN, + PLAYBACK_SPEED_PRESETS, + PLAYBACK_SPEED_STEP, + PLAYBACK_STRATEGIES, + clampPlaybackPitch, + clampPlaybackSpeed, + derivedVarispeedSemitones, + formatPitchLabel, + formatSpeedLabel, + isPlaybackRateApplied, + type PlaybackStrategy, +} from '../../../utils/audio/playbackRateHelpers'; +import { usePlaybackRateStore } from '../../../store/playbackRateStore'; +import { useOrbitStore } from '../../../store/orbitStore'; +import { isOrbitPlaybackSyncActive } from '../../../utils/orbit'; + +interface Props { + t: TFunction; + /** When false, hide master enable (player popup). */ + showEnable?: boolean; +} + +export function PlaybackRateControls({ t, showEnable = true }: Props) { + const compact = !showEnable; + const enabled = usePlaybackRateStore(s => s.enabled); + const strategy = usePlaybackRateStore(s => s.strategy); + const speed = usePlaybackRateStore(s => s.speed); + const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones); + const { + setEnabled, + setStrategy, + setSpeed, + setPitchSemitones, + applyPresetSpeed, + } = usePlaybackRateStore(); + const orbitRole = useOrbitStore(s => s.role); + const orbitPhase = useOrbitStore(s => s.phase); + + const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase); + const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive); + const derivedPitch = derivedVarispeedSemitones(speed); + + const strategyLabel = (s: PlaybackStrategy) => { + switch (s) { + case 'speed_corrected': + return t('settings.playbackRateStrategySpeed'); + case 'varispeed': + return t('settings.playbackRateStrategyVarispeed'); + case 'preserve_pitch': + return t('settings.playbackRateStrategyPreserve'); + } + }; + + const handleWheelSpeed = useCallback((e: React.WheelEvent) => { + if (!compact || !enabled) return; + e.preventDefault(); + e.stopPropagation(); + const delta = e.deltaY > 0 ? -PLAYBACK_SPEED_STEP : PLAYBACK_SPEED_STEP; + setSpeed(clampPlaybackSpeed(speed + delta)); + }, [compact, enabled, speed, setSpeed]); + + const handleWheelPitch = useCallback((e: React.WheelEvent) => { + if (!compact || !enabled || strategy !== 'preserve_pitch') return; + e.preventDefault(); + e.stopPropagation(); + const delta = e.deltaY > 0 ? -PLAYBACK_PITCH_STEP : PLAYBACK_PITCH_STEP; + setPitchSemitones(clampPlaybackPitch(pitchSemitones + delta)); + }, [compact, enabled, strategy, pitchSemitones, setPitchSemitones]); + + return ( +
+ {showEnable && ( +
+
+
{t('settings.playbackRateEnabled')}
+
+ {t('settings.playbackRateEnabledDesc')} +
+
+ +
+ )} + + {(!showEnable || enabled) && ( + <> + {showEnable &&
} + +
+ {!compact && ( + {t('settings.playbackRateStrategy')} + )} +
+ {PLAYBACK_STRATEGIES.map(s => ( + + ))} +
+
+ +
+ {!compact && ( + {t('settings.playbackRateSpeed')} + )} + setSpeed(parseFloat(e.target.value))} + className="playback-rate-slider" + aria-label={t('settings.playbackRateSpeed')} + /> + {formatSpeedLabel(speed)} +
+ +
+ {PLAYBACK_SPEED_PRESETS.map(preset => ( + + ))} +
+ + {strategy === 'varispeed' && !compact && ( +
+ {t('settings.playbackRateDerivedPitch', { + value: formatPitchLabel(derivedPitch), + })} +
+ )} + + {strategy === 'speed_corrected' && !compact && ( +
+ {t('settings.playbackRateAutoPitch')} +
+ )} + + {strategy === 'preserve_pitch' && ( +
+ {!compact && ( + {t('settings.playbackRatePitch')} + )} + setPitchSemitones(parseFloat(e.target.value))} + className="playback-rate-slider" + aria-label={t('settings.playbackRatePitch')} + /> + {formatPitchLabel(pitchSemitones)} +
+ )} + + {!compact && ( +

+ {t('settings.playbackRateHint')} +

+ )} + + {orbitActive && enabled && ( +

+ {t(compact ? 'settings.playbackRateOrbitPausedShort' : 'settings.playbackRateOrbitPaused')} +

+ )} + + {!compact && !effectActive && enabled && !orbitActive && ( +

+ {t('settings.playbackRateNeutral')} +

+ )} + + )} +
+ ); +} + +export function PlaybackRateBlock({ t }: { t: TFunction }) { + return ; +} diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts index b2d70b7e..d5fde32b 100644 --- a/src/components/settings/settingsTabs.ts +++ b/src/components/settings/settingsTabs.ts @@ -35,6 +35,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' }, { tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' }, { tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' }, + { tab: 'audio', titleKey: 'settings.playbackRateTitle', keywords: 'speed playback rate tempo pitch varispeed preserve corrected time stretch' }, { tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' }, { tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' }, { tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' }, diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index aa3d1855..0d862b6b 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -125,6 +125,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)', 'Library browse: local-vs-network text search race, All Albums/Artists catalog from index, DevTools browse-race logging (PR #847)', 'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)', + 'Playback speed: global 0.5–2.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)', ], }, { diff --git a/src/hooks/usePlaybackRateOrbitSync.ts b/src/hooks/usePlaybackRateOrbitSync.ts new file mode 100644 index 00000000..5beb2d6a --- /dev/null +++ b/src/hooks/usePlaybackRateOrbitSync.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { useOrbitStore } from '../store/orbitStore'; +import { usePlaybackRateStore } from '../store/playbackRateStore'; + +/** Re-sync playback rate when Orbit enters or leaves shared playback. */ +export function usePlaybackRateOrbitSync() { + const role = useOrbitStore(s => s.role); + const phase = useOrbitStore(s => s.phase); + + useEffect(() => { + usePlaybackRateStore.getState().syncToRust(); + }, [role, phase]); +} diff --git a/src/hooks/usePlayerBarAnchoredPopover.ts b/src/hooks/usePlayerBarAnchoredPopover.ts new file mode 100644 index 00000000..d52c8311 --- /dev/null +++ b/src/hooks/usePlayerBarAnchoredPopover.ts @@ -0,0 +1,63 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +/** Fixed popover anchored above a player-bar trigger (overflow menu / speed btn). */ +export function usePlayerBarAnchoredPopover(width: number, zIndex = 10050) { + const [open, setOpen] = useState(false); + const [popStyle, setPopStyle] = useState({}); + const btnRef = useRef(null); + const popRef = useRef(null); + + const updatePopStyle = () => { + const btn = btnRef.current; + if (!btn) return; + const MARGIN = 8; + const r = btn.getBoundingClientRect(); + const left = Math.min( + Math.max(r.right - width, MARGIN), + window.innerWidth - width - MARGIN, + ); + setPopStyle({ + position: 'fixed', + left, + width, + bottom: window.innerHeight - r.top + MARGIN, + zIndex, + }); + }; + + useLayoutEffect(() => { + if (!open) return; + updatePopStyle(); + }, [open, width]); + + useEffect(() => { + if (!open) return; + const onReposition = () => updatePopStyle(); + window.addEventListener('resize', onReposition); + window.addEventListener('scroll', onReposition, true); + return () => { + window.removeEventListener('resize', onReposition); + window.removeEventListener('scroll', onReposition, true); + }; + }, [open, width]); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (btnRef.current?.contains(target) || popRef.current?.contains(target)) return; + setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + return { open, setOpen, popStyle, btnRef, popRef }; +} diff --git a/src/hooks/useUtilityOverflowMenu.ts b/src/hooks/useUtilityOverflowMenu.ts index d6d3e2bf..77e39859 100644 --- a/src/hooks/useUtilityOverflowMenu.ts +++ b/src/hooks/useUtilityOverflowMenu.ts @@ -76,7 +76,7 @@ export function useUtilityOverflowMenu( useEffect(() => { if (!utilityMenuOpen) return; - const MENU_WIDTH = 238; + const MENU_WIDTH = utilityMenuMode === 'volume' ? 238 : 320; const MARGIN = 8; const updateMenuPos = () => { const btn = utilityBtnRef.current; @@ -101,7 +101,7 @@ export function useUtilityOverflowMenu( window.removeEventListener('resize', updateMenuPos); window.removeEventListener('scroll', updateMenuPos, true); }; - }, [utilityMenuOpen]); + }, [utilityMenuOpen, utilityMenuMode]); return { utilityOverflow, diff --git a/src/locales/de/player.ts b/src/locales/de/player.ts index d6316fed..368b19f7 100644 --- a/src/locales/de/player.ts +++ b/src/locales/de/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Warteschlange einklappen, Breite ändern', moreOptions: 'Weitere Optionen', equalizer: 'Equalizer', + playbackRate: 'Geschwindigkeit', miniPlayer: 'Mini-Player', lyrics: 'Lyrics', fsLyricsToggle: 'Lyrics im Vollbild', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index ecfa21d6..d5cb9a05 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -326,8 +326,24 @@ export const settings = { playerBarStarRating: 'Sterne-Bewertung', playerBarFavorite: 'Favorit (Herz)', playerBarLastfmLove: 'Last.fm Love', + playerBarPlaybackRate: 'Wiedergabegeschwindigkeit', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Mini-Player', + playbackRateTitle: 'Wiedergabegeschwindigkeit', + playbackRateEnabled: 'Wiedergabegeschwindigkeit aktivieren', + playbackRateEnabledDesc: 'Globale Geschwindigkeit für alle Titel. Gilt nicht für Radio und Vorschau.', + playbackRateStrategy: 'Strategie', + playbackRateStrategySpeed: 'Geschwindigkeit', + playbackRateStrategyVarispeed: 'Tonhöhe folgt dem Tempo', + playbackRateStrategyPreserve: 'Tonhöhe', + playbackRateSpeed: 'Geschwindigkeit', + playbackRatePitch: 'Tonhöhe', + playbackRateDerivedPitch: 'Tonhöhenverschiebung durch Geschwindigkeit: {{value}}', + playbackRateAutoPitch: 'Die Tonhöhe wird automatisch korrigiert.', + playbackRateHint: '„Geschwindigkeit“ behält die Tonhöhe bei. „Tonhöhe folgt dem Tempo“ verschiebt die Tonhöhe mit der Geschwindigkeit. „Tonhöhe“ fügt eine manuelle Verschiebung hinzu. Höhere CPU-Last als bei „Tonhöhe folgt dem Tempo“. Nicht für Radio, Vorschau oder Orbit.', + playbackRateNeutral: 'Bei 1,0× und ohne Tonverschiebung ist die Wiedergabe normal.', + playbackRateOrbitPaused: 'Während Orbit-Sessions nicht aktiv — Wiedergabe bleibt bei 1,0× für die Synchronisation.', + playbackRateOrbitPausedShort: 'Orbit: 1,0× für Sync.', advancedMode: 'Erweitert', advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.', advancedBadge: 'Erweitert', diff --git a/src/locales/en/player.ts b/src/locales/en/player.ts index 3e8ace6a..d5cd53c3 100644 --- a/src/locales/en/player.ts +++ b/src/locales/en/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Collapse queue, resize', moreOptions: 'More options', equalizer: 'Equalizer', + playbackRate: 'Playback speed', miniPlayer: 'Mini Player', lyrics: 'Lyrics', fsLyricsToggle: 'Lyrics in fullscreen', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 1bede627..d498720e 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -329,8 +329,24 @@ export const settings = { playerBarStarRating: 'Star rating', playerBarFavorite: 'Favorite (heart)', playerBarLastfmLove: 'Last.fm love', + playerBarPlaybackRate: 'Playback speed', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Mini player', + playbackRateTitle: 'Playback speed', + playbackRateEnabled: 'Enable playback speed', + playbackRateEnabledDesc: 'Change speed globally for all tracks. Not applied to radio or previews.', + playbackRateStrategy: 'Strategy', + playbackRateStrategySpeed: 'Speed', + playbackRateStrategyVarispeed: 'Varispeed', + playbackRateStrategyPreserve: 'Pitch shift', + playbackRateSpeed: 'Speed', + playbackRatePitch: 'Pitch', + playbackRateDerivedPitch: 'Pitch shift from speed: {{value}}', + playbackRateAutoPitch: 'Pitch is corrected automatically.', + playbackRateHint: 'Speed keeps pitch natural. Varispeed changes pitch with speed. Pitch shift adds a manual offset. Uses more CPU than varispeed. Not for radio, previews, or Orbit.', + playbackRateNeutral: 'At 1.0× with no pitch offset, playback is normal.', + playbackRateOrbitPaused: 'Not applied during Orbit sessions — playback stays at 1.0× for sync.', + playbackRateOrbitPausedShort: 'Orbit: 1.0× for sync.', advancedMode: 'Advanced', advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.', advancedBadge: 'Advanced', diff --git a/src/locales/es/player.ts b/src/locales/es/player.ts index c5067b18..b22f04da 100644 --- a/src/locales/es/player.ts +++ b/src/locales/es/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Contraer cola, cambiar ancho', moreOptions: 'Más opciones', equalizer: 'Ecualizador', + playbackRate: 'Velocidad', miniPlayer: 'Mini reproductor', lyrics: 'Letras', fsLyricsToggle: 'Letras en pantalla completa', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 7d64187c..39e00ec0 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -325,8 +325,24 @@ export const settings = { playerBarStarRating: 'Calificación con estrellas', playerBarFavorite: 'Favorito (corazón)', playerBarLastfmLove: 'Last.fm love', + playerBarPlaybackRate: 'Velocidad de reproducción', playerBarEqualizer: 'Ecualizador', playerBarMiniPlayer: 'Mini reproductor', + playbackRateTitle: 'Velocidad de reproducción', + playbackRateEnabled: 'Activar velocidad de reproducción', + playbackRateEnabledDesc: 'Velocidad global para todas las pistas. No se aplica a la radio ni a las vistas previas.', + playbackRateStrategy: 'Estrategia', + playbackRateStrategySpeed: 'Velocidad', + playbackRateStrategyVarispeed: 'Con tono', + playbackRateStrategyPreserve: 'Tono', + playbackRateSpeed: 'Velocidad', + playbackRatePitch: 'Tono', + playbackRateDerivedPitch: 'Cambio de tono por velocidad: {{value}}', + playbackRateAutoPitch: 'El tono se corrige automáticamente.', + playbackRateHint: '« Velocidad » mantiene el tono natural. « Con tono » cambia el tono con la velocidad. « Tono » añade un desplazamiento manual. Usa más CPU que « Con tono ». No para radio, vistas previas u Orbit.', + playbackRateNeutral: 'A 1,0× y sin desplazamiento de tono, la reproducción es normal.', + playbackRateOrbitPaused: 'No se aplica durante sesiones Orbit — la reproducción permanece a 1,0× para sincronizar.', + playbackRateOrbitPausedShort: 'Orbit: 1,0× para sincronizar.', advancedMode: 'Avanzado', advancedModeTooltip: 'Mostrar opciones avanzadas en todas las pestañas de ajustes. Aportes de la comunidad que no reflejan necesariamente la filosofía de diseño de los mantenedores de Psysonic.', advancedBadge: 'Avanzado', diff --git a/src/locales/fr/player.ts b/src/locales/fr/player.ts index b4fd4f99..693807fb 100644 --- a/src/locales/fr/player.ts +++ b/src/locales/fr/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Réduire la file, redimensionner', moreOptions: 'Plus d’options', equalizer: 'Égaliseur', + playbackRate: 'Vitesse', miniPlayer: 'Mini-lecteur', lyrics: 'Paroles', fsLyricsToggle: 'Paroles en plein écran', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 56488e97..491d1e91 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -323,8 +323,24 @@ export const settings = { playerBarStarRating: 'Note par étoiles', playerBarFavorite: 'Favori (cœur)', playerBarLastfmLove: 'Last.fm love', + playerBarPlaybackRate: 'Vitesse de lecture', playerBarEqualizer: 'Égaliseur', playerBarMiniPlayer: 'Mini-lecteur', + playbackRateTitle: 'Vitesse de lecture', + playbackRateEnabled: 'Activer la vitesse de lecture', + playbackRateEnabledDesc: 'Vitesse globale pour toutes les pistes. Non appliquée à la radio ni aux aperçus.', + playbackRateStrategy: 'Stratégie', + playbackRateStrategySpeed: 'Vitesse', + playbackRateStrategyVarispeed: 'Avec hauteur', + playbackRateStrategyPreserve: 'Hauteur', + playbackRateSpeed: 'Vitesse', + playbackRatePitch: 'Hauteur', + playbackRateDerivedPitch: 'Décalage de hauteur lié à la vitesse : {{value}}', + playbackRateAutoPitch: 'La hauteur est corrigée automatiquement.', + playbackRateHint: '« Vitesse » conserve la hauteur naturelle. « Avec hauteur » change la hauteur avec la vitesse. « Hauteur » ajoute un décalage manuel. Plus gourmand en CPU que « Avec hauteur ». Non pour la radio, les aperçus ou Orbit.', + playbackRateNeutral: 'À 1,0× sans décalage de hauteur, la lecture est normale.', + playbackRateOrbitPaused: 'Non appliqué pendant les sessions Orbit — la lecture reste à 1,0× pour la synchro.', + playbackRateOrbitPausedShort: 'Orbit : 1,0× pour la synchro.', advancedMode: 'Avancé', advancedModeTooltip: 'Afficher les options avancées dans tous les onglets des paramètres. Contributions de la communauté qui ne reflètent pas nécessairement la philosophie de conception des mainteneurs de Psysonic.', advancedBadge: 'Avancé', diff --git a/src/locales/nb/player.ts b/src/locales/nb/player.ts index 3b799552..00b4dc2e 100644 --- a/src/locales/nb/player.ts +++ b/src/locales/nb/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Skjul kø, endre bredde', moreOptions: 'Flere alternativer', equalizer: 'Equalizer', + playbackRate: 'Hastighet', miniPlayer: 'Minispiller', lyrics: 'Sangtekst', fsLyricsToggle: 'Sangtekst i fullskjerm', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index ac836c86..3fb590ed 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -322,8 +322,24 @@ export const settings = { playerBarStarRating: 'Stjernevurdering', playerBarFavorite: 'Favoritt (hjerte)', playerBarLastfmLove: 'Last.fm love', + playerBarPlaybackRate: 'Avspillingshastighet', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Miniavspiller', + playbackRateTitle: 'Avspillingshastighet', + playbackRateEnabled: 'Aktiver avspillingshastighet', + playbackRateEnabledDesc: 'Global hastighet for alle spor. Gjelder ikke radio og forhåndsvisninger.', + playbackRateStrategy: 'Strategi', + playbackRateStrategySpeed: 'Hastighet', + playbackRateStrategyVarispeed: 'Med tone', + playbackRateStrategyPreserve: 'Tonehøyde', + playbackRateSpeed: 'Hastighet', + playbackRatePitch: 'Tonehøyde', + playbackRateDerivedPitch: 'Tonehøydeforskyvning fra hastighet: {{value}}', + playbackRateAutoPitch: 'Tonehøyden korrigeres automatisk.', + playbackRateHint: '« Hastighet » beholder naturlig tonehøyde. « Med tone » endrer tonehøyden med hastigheten. « Tonehøyde » legger til manuell forskyvning. Mer CPU enn « Med tone ». Ikke for radio, forhåndsvisninger eller Orbit.', + playbackRateNeutral: 'Ved 1,0× og uten toneforskyvning er avspilling normal.', + playbackRateOrbitPaused: 'Ikke aktiv under Orbit-økter — avspilling forblir 1,0× for synk.', + playbackRateOrbitPausedShort: 'Orbit: 1,0× for synk.', advancedMode: 'Avansert', advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.', advancedBadge: 'Avansert', diff --git a/src/locales/nl/player.ts b/src/locales/nl/player.ts index 8a0bfe7b..11909c6a 100644 --- a/src/locales/nl/player.ts +++ b/src/locales/nl/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Wachtrij inklappen, breedte wijzigen', moreOptions: 'Meer opties', equalizer: 'Equalizer', + playbackRate: 'Snelheid', miniPlayer: 'Minispeler', lyrics: 'Songtekst', fsLyricsToggle: 'Songtekst in volledig scherm', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 82564ebc..ae33ae23 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -323,8 +323,24 @@ export const settings = { playerBarStarRating: 'Sterbeoordeling', playerBarFavorite: 'Favoriet (hart)', playerBarLastfmLove: 'Last.fm love', + playerBarPlaybackRate: 'Afspeelsnelheid', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Mini-speler', + playbackRateTitle: 'Afspeelsnelheid', + playbackRateEnabled: 'Afspeelsnelheid inschakelen', + playbackRateEnabledDesc: 'Globale snelheid voor alle nummers. Niet van toepassing op radio en previews.', + playbackRateStrategy: 'Strategie', + playbackRateStrategySpeed: 'Snelheid', + playbackRateStrategyVarispeed: 'Met toon', + playbackRateStrategyPreserve: 'Toonhoogte', + playbackRateSpeed: 'Snelheid', + playbackRatePitch: 'Toonhoogte', + playbackRateDerivedPitch: 'Toonhoogteverschuiving door snelheid: {{value}}', + playbackRateAutoPitch: 'De toonhoogte wordt automatisch gecorrigeerd.', + playbackRateHint: '« Snelheid » behoudt de natuurlijke toonhoogte. « Met toon » verandert de toonhoogte met de snelheid. « Toonhoogte » voegt een handmatige verschuiving toe. Meer CPU dan « Met toon ». Niet voor radio, previews of Orbit.', + playbackRateNeutral: 'Bij 1,0× en zonder toonverschuiving is afspelen normaal.', + playbackRateOrbitPaused: 'Niet actief tijdens Orbit-sessies — afspelen blijft 1,0× voor sync.', + playbackRateOrbitPausedShort: 'Orbit: 1,0× voor sync.', advancedMode: 'Geavanceerd', advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.', advancedBadge: 'Geavanceerd', diff --git a/src/locales/ro/player.ts b/src/locales/ro/player.ts index ab5874d9..b8aab101 100644 --- a/src/locales/ro/player.ts +++ b/src/locales/ro/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Strânge coada, redimensionează', moreOptions: 'Mai multe opțiuni', equalizer: 'Egalizator', + playbackRate: 'Viteză', miniPlayer: 'Player Mini', lyrics: 'Versuri', fsLyricsToggle: 'Versuri în ecran complet', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 2be0cf8a..4de04c8d 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -328,8 +328,24 @@ export const settings = { playerBarStarRating: 'Evaluare cu stele', playerBarFavorite: 'Favorit (inimă)', playerBarLastfmLove: 'Last.fm love', + playerBarPlaybackRate: 'Viteză redare', playerBarEqualizer: 'Egalizator', playerBarMiniPlayer: 'Mini player', + playbackRateTitle: 'Viteză redare', + playbackRateEnabled: 'Activează viteza de redare', + playbackRateEnabledDesc: 'Viteză globală pentru toate piesele. Nu se aplică la radio și previzualizări.', + playbackRateStrategy: 'Strategie', + playbackRateStrategySpeed: 'Viteză', + playbackRateStrategyVarispeed: 'Cu ton', + playbackRateStrategyPreserve: 'Ton', + playbackRateSpeed: 'Viteză', + playbackRatePitch: 'Ton', + playbackRateDerivedPitch: 'Deplasare ton din viteză: {{value}}', + playbackRateAutoPitch: 'Tonul este corectat automat.', + playbackRateHint: '« Viteză » păstrează tonul natural. « Cu ton » schimbă tonul odată cu viteza. « Ton » adaugă o deplasare manuală. Consumă mai mult CPU decât « Cu ton ». Nu pentru radio, previzualizări sau Orbit.', + playbackRateNeutral: 'La 1,0× și fără deplasare de ton, redarea este normală.', + playbackRateOrbitPaused: 'Nu se aplică în sesiunile Orbit — redarea rămâne la 1,0× pentru sincronizare.', + playbackRateOrbitPausedShort: 'Orbit: 1,0× pentru sync.', advancedMode: 'Avansat', advancedModeTooltip: 'Afișează opțiuni avansate în toate filele de setări. Contribuții ale comunității care nu reflectă neapărat filozofia de design a mentenanților Psysonic.', advancedBadge: 'Avansat', diff --git a/src/locales/ru/player.ts b/src/locales/ru/player.ts index bf0ed0d2..d1460e96 100644 --- a/src/locales/ru/player.ts +++ b/src/locales/ru/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: 'Свернуть очередь, изменить ширину', moreOptions: 'Дополнительно', equalizer: 'Эквалайзер', + playbackRate: 'Скорость', miniPlayer: 'Мини-плеер', lyrics: 'Текст', lyricsLoading: 'Загрузка текста…', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 3be265ca..ec44d42e 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -339,8 +339,24 @@ export const settings = { playerBarStarRating: 'Оценка звёздами', playerBarFavorite: 'Избранное (сердечко)', playerBarLastfmLove: 'Last.fm love', + playerBarPlaybackRate: 'Скорость', playerBarEqualizer: 'Эквалайзер', playerBarMiniPlayer: 'Мини-плеер', + playbackRateTitle: 'Скорость воспроизведения', + playbackRateEnabled: 'Включить изменение скорости', + playbackRateEnabledDesc: 'Глобальная скорость для всех треков. Не применяется к радио и превью.', + playbackRateStrategy: 'Стратегия', + playbackRateStrategySpeed: 'Скорость', + playbackRateStrategyVarispeed: 'С тоном', + playbackRateStrategyPreserve: 'Сдвиг тона', + playbackRateSpeed: 'Скорость', + playbackRatePitch: 'Тон', + playbackRateDerivedPitch: 'Сдвиг тона от скорости: {{value}}', + playbackRateAutoPitch: 'Тон корректируется автоматически.', + playbackRateHint: '«Скорость» сохраняет высоту тона при изменении темпа. «С тоном» меняет тон вместе со скоростью. «Сдвиг тона» — ручное смещение. Выше нагрузка на процессор, чем у режима «С тоном». Не для радио, превью и Orbit.', + playbackRateNeutral: 'При 1.0× и нулевом сдвиге тона воспроизведение обычное.', + playbackRateOrbitPaused: 'Во время сессии Orbit не применяется — воспроизведение остаётся 1.0× для синхронизации.', + playbackRateOrbitPausedShort: 'Orbit: 1.0× для синхронизации.', advancedMode: 'Дополнительно', advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.', advancedBadge: 'Дополнительно', diff --git a/src/locales/zh/player.ts b/src/locales/zh/player.ts index c8113fe7..04fc17c7 100644 --- a/src/locales/zh/player.ts +++ b/src/locales/zh/player.ts @@ -45,6 +45,7 @@ export const player = { collapseQueueResize: '收起队列,调整宽度', moreOptions: '更多选项', equalizer: '均衡器', + playbackRate: '速度', miniPlayer: '迷你播放器', lyrics: '歌词', fsLyricsToggle: '全屏歌词', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 49b1ca55..3ab7b771 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -322,8 +322,24 @@ export const settings = { playerBarStarRating: '星级评分', playerBarFavorite: '收藏(心形)', playerBarLastfmLove: 'Last.fm 喜欢', + playerBarPlaybackRate: '播放速度', playerBarEqualizer: '均衡器', playerBarMiniPlayer: '迷你播放器', + playbackRateTitle: '播放速度', + playbackRateEnabled: '启用播放速度', + playbackRateEnabledDesc: '全局调整所有曲目的播放速度。不适用于电台和预览。', + playbackRateStrategy: '策略', + playbackRateStrategySpeed: '速度', + playbackRateStrategyVarispeed: '随速变调', + playbackRateStrategyPreserve: '音高', + playbackRateSpeed: '速度', + playbackRatePitch: '音高', + playbackRateDerivedPitch: '速度引起的音高偏移:{{value}}', + playbackRateAutoPitch: '音高会自动校正。', + playbackRateHint: '「速度」保持音高不变。「随速变调」会随速度改变音高。「音高」可手动偏移。比「随速变调」占用更多 CPU。不适用于电台、预览或 Orbit。', + playbackRateNeutral: '在 1.0× 且无音高偏移时,播放为正常速度。', + playbackRateOrbitPaused: 'Orbit 会话期间不生效 — 播放保持 1.0× 以同步。', + playbackRateOrbitPausedShort: 'Orbit:1.0× 同步。', advancedMode: '高级', advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。', advancedBadge: '高级', diff --git a/src/store/playListenSession.test.ts b/src/store/playListenSession.test.ts index a3917a93..5d586d47 100644 --- a/src/store/playListenSession.test.ts +++ b/src/store/playListenSession.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; import { onInvoke } from '../test/mocks/tauri'; import { useLibraryIndexStore } from './libraryIndexStore'; +import { usePlayerStore } from './playerStore'; +import { usePreviewStore } from './previewStore'; import { _resetPlayListenSessionForTest, playListenSessionFinalize, @@ -31,14 +33,12 @@ const testTrack: Track = { }; describe('playListenSession', () => { - beforeEach(async () => { + beforeEach(() => { _resetPlayListenSessionForTest(); useLibraryIndexStore.setState({ masterEnabled: true, syncExcludedByServer: {}, }); - const { usePlayerStore } = await import('./playerStore'); - const { usePreviewStore } = await import('./previewStore'); usePlayerStore.setState({ currentRadio: null, isPlaying: true, @@ -76,7 +76,6 @@ describe('playListenSession', () => { }); it('picks up engine duration from progress ticks', async () => { - const { usePlayerStore } = await import('./playerStore'); usePlayerStore.setState({ currentTrack: { ...testTrack, duration: 0 }, }); @@ -118,7 +117,6 @@ describe('playListenSession', () => { }); it('skips radio playback', async () => { - const { usePlayerStore } = await import('./playerStore'); usePlayerStore.setState({ currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' }, }); diff --git a/src/store/playListenSession.ts b/src/store/playListenSession.ts index 6a86ef1a..724bed65 100644 --- a/src/store/playListenSession.ts +++ b/src/store/playListenSession.ts @@ -1,4 +1,6 @@ import type { Track } from './playerStoreTypes'; +import { usePlayerStore } from './playerStore'; +import { usePreviewStore } from './previewStore'; import { libraryRecordPlaySession, type PlaySessionEndReason, @@ -48,9 +50,7 @@ function noteDurationHint(durationSec?: number): void { } } -async function playerGateBlocks(): Promise { - const { usePlayerStore } = await import('./playerStore'); - const { usePreviewStore } = await import('./previewStore'); +function playerGateBlocks(): boolean { if (usePreviewStore.getState().previewingId) return true; if (usePlayerStore.getState().currentRadio) return true; return false; @@ -59,7 +59,7 @@ async function playerGateBlocks(): Promise { async function recordingEnabledForServer(serverId: string): Promise { if (!serverId) return false; if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false; - if (await playerGateBlocks()) return false; + if (playerGateBlocks()) return false; return libraryIsReady(serverId); } @@ -94,7 +94,6 @@ export async function playListenSessionOnProgress( ): Promise { if (!open?.recordingEnabled) return; noteDurationHint(durationSecHint); - const { usePlayerStore } = await import('./playerStore'); const store = usePlayerStore.getState(); const track = store.currentTrack; if (track?.id === open.trackId) { @@ -126,7 +125,6 @@ export async function playListenSessionFinalize(reason: PlaySessionEndReason): P return; } - const { usePlayerStore } = await import('./playerStore'); const track = usePlayerStore.getState().currentTrack; const durationSecHint = resolveDurationSecHint( track?.id === session.trackId ? track : null, diff --git a/src/store/playbackRateStore.ts b/src/store/playbackRateStore.ts new file mode 100644 index 00000000..909aa9ab --- /dev/null +++ b/src/store/playbackRateStore.ts @@ -0,0 +1,99 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { invoke } from '@tauri-apps/api/core'; +import { + clampPlaybackPitch, + clampPlaybackSpeed, + DEFAULT_PLAYBACK_STRATEGY, + effectivePlaybackPitch, + type PlaybackStrategy, +} from '../utils/audio/playbackRateHelpers'; +import { + restartPlaybackForRateChange, + shouldRestartPlaybackForRateChange, + type PlaybackRateSnapshot, +} from '../utils/audio/playbackRateRestart'; +import { isOrbitPlaybackSyncActive } from '../utils/orbit'; + +interface PlaybackRateState extends PlaybackRateSnapshot { + + setEnabled: (v: boolean) => void; + setStrategy: (s: PlaybackStrategy) => void; + setSpeed: (speed: number) => void; + setPitchSemitones: (semitones: number) => void; + applyPresetSpeed: (speed: number) => void; + syncToRust: () => void; +} + +function syncPlaybackRate(state: PlaybackRateSnapshot, prev?: PlaybackRateSnapshot) { + // Orbit sync assumes 1.0× wall-clock playback; suppress DSP without mutating prefs. + const effectiveEnabled = state.enabled && !isOrbitPlaybackSyncActive(); + invoke('audio_set_playback_rate', { + enabled: effectiveEnabled, + strategy: state.strategy, + speed: state.speed, + pitchSemitones: effectivePlaybackPitch(state.strategy, state.pitchSemitones), + }).catch(() => {}); + + if (prev && shouldRestartPlaybackForRateChange(prev, state)) { + restartPlaybackForRateChange(); + } +} + +export const usePlaybackRateStore = create()( + persist( + (set, get) => ({ + enabled: false, + strategy: DEFAULT_PLAYBACK_STRATEGY, + speed: 1.0, + pitchSemitones: 0, + + setEnabled: (v) => { + const prev = get(); + set({ enabled: v }); + syncPlaybackRate(get(), prev); + }, + + setStrategy: (strategy) => { + const prev = get(); + set({ strategy }); + syncPlaybackRate(get(), prev); + }, + + setSpeed: (speed) => { + const prev = get(); + const clamped = clampPlaybackSpeed(speed); + set({ speed: clamped }); + syncPlaybackRate(get(), prev); + }, + + setPitchSemitones: (semitones) => { + const prev = get(); + const clamped = clampPlaybackPitch(semitones); + set({ pitchSemitones: clamped }); + syncPlaybackRate(get(), prev); + }, + + applyPresetSpeed: (speed) => { + const prev = get(); + const clamped = clampPlaybackSpeed(speed); + set({ speed: clamped }); + syncPlaybackRate(get(), prev); + }, + + syncToRust: () => { + syncPlaybackRate(get()); + }, + }), + { + name: 'psysonic-playback-rate', + storage: createJSONStorage(() => localStorage), + partialize: (s) => ({ + enabled: s.enabled, + strategy: s.strategy, + speed: s.speed, + pitchSemitones: s.pitchSemitones, + }), + }, + ), +); diff --git a/src/store/playerBarLayoutStore.test.ts b/src/store/playerBarLayoutStore.test.ts index 6c1dc32f..f42eb493 100644 --- a/src/store/playerBarLayoutStore.test.ts +++ b/src/store/playerBarLayoutStore.test.ts @@ -9,10 +9,10 @@ describe('playerBarLayoutStore', () => { usePlayerBarLayoutStore.getState().reset(); }); - it('starts with all five items visible in declared order', () => { + it('starts with all six items visible in declared order', () => { const items = usePlayerBarLayoutStore.getState().items; expect(items.map(i => i.id)).toEqual([ - 'starRating', 'favorite', 'lastfmLove', 'equalizer', 'miniPlayer', + 'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer', ]); expect(items.every(i => i.visible)).toBe(true); }); diff --git a/src/store/playerBarLayoutStore.ts b/src/store/playerBarLayoutStore.ts index de7931b6..57582c94 100644 --- a/src/store/playerBarLayoutStore.ts +++ b/src/store/playerBarLayoutStore.ts @@ -5,6 +5,7 @@ export type PlayerBarLayoutItemId = | 'starRating' | 'favorite' | 'lastfmLove' + | 'playbackRate' | 'equalizer' | 'miniPlayer'; @@ -17,6 +18,7 @@ export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [ { id: 'starRating', visible: true }, { id: 'favorite', visible: true }, { id: 'lastfmLove', visible: true }, + { id: 'playbackRate', visible: true }, { id: 'equalizer', visible: true }, { id: 'miniPlayer', visible: true }, ]; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 4321441f..fa247c8d 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -127,4 +127,3 @@ usePlayerStore.subscribe((state, prev) => { buffered: state.buffered, }); }); - diff --git a/src/store/previewPlayerVolumeSync.ts b/src/store/previewPlayerVolumeSync.ts new file mode 100644 index 00000000..b5383710 --- /dev/null +++ b/src/store/previewPlayerVolumeSync.ts @@ -0,0 +1,13 @@ +/** + * Side-effect wiring: keep the Rust preview sink volume aligned with the main + * player slider while a track preview is active. + */ +import { invoke } from '@tauri-apps/api/core'; +import { computePreviewVolume, usePreviewStore } from './previewStore'; +import { usePlayerStore } from './playerStore'; + +usePlayerStore.subscribe((state, prev) => { + if (state.volume === prev.volume) return; + if (!usePreviewStore.getState().previewingId) return; + invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {}); +}); diff --git a/src/store/previewStore.test.ts b/src/store/previewStore.test.ts index 034b743e..ea5c4fc9 100644 --- a/src/store/previewStore.test.ts +++ b/src/store/previewStore.test.ts @@ -30,6 +30,7 @@ import { usePreviewStore } from './previewStore'; import { useAuthStore } from './authStore'; import { useOrbitStore } from './orbitStore'; import { usePlayerStore } from './playerStore'; +import './previewPlayerVolumeSync'; import { onInvoke, invokeMock } from '@/test/mocks/tauri'; import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset'; diff --git a/src/store/previewStore.ts b/src/store/previewStore.ts index 6c6e7669..08ae3b61 100644 --- a/src/store/previewStore.ts +++ b/src/store/previewStore.ts @@ -4,7 +4,7 @@ import { create } from 'zustand'; import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from './playerStore'; import { useAuthStore } from './authStore'; -import { useOrbitStore } from './orbitStore'; +import { isOrbitPlaybackSyncActive } from '../utils/orbit'; /** Minimal track info needed to surface the preview in the player bar UI. */ export interface PreviewingTrack { @@ -55,7 +55,7 @@ const PREVIEW_VOLUME_MATCH = true; * preview-specific gain, so we pre-multiply here). Master headroom is added on * the Rust side. */ -function computePreviewVolume(): number { +export function computePreviewVolume(): number { const auth = useAuthStore.getState(); let volume = usePlayerStore.getState().volume; if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') { @@ -82,10 +82,7 @@ export const usePreviewStore = create((set, get) => ({ // preview started by a guest would yank the host's track out from // under them. UI buttons are hidden via `[data-orbit-active]` CSS; // this guards keyboard shortcuts / programmatic callers. - const orbit = useOrbitStore.getState(); - const inOrbit = (orbit.role === 'host' || orbit.role === 'guest') - && (orbit.phase === 'active' || orbit.phase === 'joining' || orbit.phase === 'starting'); - if (inOrbit) return; + if (isOrbitPlaybackSyncActive()) return; const current = get().previewingId; if (current === song.id) { @@ -158,14 +155,3 @@ export const usePreviewStore = create((set, get) => ({ set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false }); }, })); - -// Keep the preview sink in sync with player volume slider movements while a -// preview is in flight. Without this the Rust preview Sink stays at whatever -// level was set at `audio_preview_play` — slider drags only ramp the main -// sink. Auth/normalization changes during preview are intentionally ignored -// (preview is short and the case is rare). -usePlayerStore.subscribe((state, prev) => { - if (state.volume === prev.volume) return; - if (!usePreviewStore.getState().previewingId) return; - invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {}); -}); diff --git a/src/styles/components/index.css b/src/styles/components/index.css index ea5a24f5..fc6f5ccd 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -40,6 +40,7 @@ @import './now-playing-page-2.css'; @import './radio-nowplaying-section.css'; @import './equalizer.css'; +@import './playback-rate.css'; @import './custom-select.css'; @import './changelog.css'; @import './genre-filter-bar.css'; diff --git a/src/styles/components/playback-rate.css b/src/styles/components/playback-rate.css new file mode 100644 index 00000000..dda1be7a --- /dev/null +++ b/src/styles/components/playback-rate.css @@ -0,0 +1,93 @@ +.playback-rate-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.playback-rate-strategy-row { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.playback-rate-strategy-btns { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.playback-rate-slider-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.playback-rate-label { + font-size: 13px; + font-weight: 500; + min-width: 4.5rem; + flex-shrink: 0; +} + +.playback-rate-slider { + flex: 1; + min-width: 80px; +} + +.playback-rate-value { + font-size: 13px; + color: var(--text-secondary); + min-width: 3rem; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.playback-rate-presets { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.player-playback-rate-popover, +.player-playback-rate-menu-section { + padding: 10px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--bg-card); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); +} + +.player-playback-rate-popover { + display: flex; + flex-direction: column; + gap: 8px; +} + +.player-playback-rate-menu-section { + box-shadow: none; +} + +.playback-rate-controls--compact .playback-rate-slider-row { + gap: 0.5rem; +} + +.playback-rate-controls--compact .playback-rate-slider { + flex: 1; + min-width: 0; +} + +.playback-rate-controls--compact .playback-rate-strategy-row { + gap: 0.35rem; +} + +.player-playback-rate-btn { + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + min-width: 2.5rem; + letter-spacing: -0.02em; +} + +.player-playback-rate-btn--live { + color: var(--accent); +} diff --git a/src/utils/audio/playbackRateHelpers.test.ts b/src/utils/audio/playbackRateHelpers.test.ts new file mode 100644 index 00000000..9555c2ac --- /dev/null +++ b/src/utils/audio/playbackRateHelpers.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + clampPlaybackSpeed, + isPlaybackEffectActive, + isPlaybackRateApplied, + derivedVarispeedSemitones, +} from './playbackRateHelpers'; + +describe('playbackRateHelpers', () => { + it('is inactive when disabled', () => { + expect(isPlaybackEffectActive(false, 'speed_corrected', 1.5, 0)).toBe(false); + }); + + it('is inactive at 1.0x and 0 pitch when enabled', () => { + expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 0)).toBe(false); + }); + + it('is active when speed differs from 1', () => { + expect(isPlaybackEffectActive(true, 'speed_corrected', 1.25, 0)).toBe(true); + }); + + it('preserve_pitch is active at 1.0x with pitch offset', () => { + expect(isPlaybackEffectActive(true, 'preserve_pitch', 1.0, 2)).toBe(true); + }); + + it('speed_corrected ignores stored pitch at 1.0x', () => { + expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 2)).toBe(false); + }); + + it('clamps speed', () => { + expect(clampPlaybackSpeed(3)).toBe(2); + expect(clampPlaybackSpeed(0.1)).toBe(0.5); + }); + + it('derives semitones for varispeed', () => { + expect(derivedVarispeedSemitones(2)).toBeCloseTo(12, 1); + }); + + it('is not applied during orbit', () => { + expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, true)).toBe(false); + expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, false)).toBe(true); + }); +}); diff --git a/src/utils/audio/playbackRateHelpers.ts b/src/utils/audio/playbackRateHelpers.ts new file mode 100644 index 00000000..9bc3e0ad --- /dev/null +++ b/src/utils/audio/playbackRateHelpers.ts @@ -0,0 +1,74 @@ +export type PlaybackStrategy = 'speed_corrected' | 'varispeed' | 'preserve_pitch'; + +/** Default strategy: speed only, pitch corrected automatically. */ +export const DEFAULT_PLAYBACK_STRATEGY: PlaybackStrategy = 'speed_corrected'; + +export const PLAYBACK_STRATEGIES: PlaybackStrategy[] = [ + 'speed_corrected', + 'varispeed', + 'preserve_pitch', +]; + +export const PLAYBACK_SPEED_MIN = 0.5; +export const PLAYBACK_SPEED_MAX = 2.0; +export const PLAYBACK_SPEED_STEP = 0.05; +export const PLAYBACK_PITCH_MIN = -12; +export const PLAYBACK_PITCH_MAX = 12; +export const PLAYBACK_PITCH_STEP = 0.1; +export const PLAYBACK_SPEED_PRESETS = [0.75, 1.0, 1.25, 1.5, 2.0] as const; + +export function clampPlaybackSpeed(speed: number): number { + return Math.max(PLAYBACK_SPEED_MIN, Math.min(PLAYBACK_SPEED_MAX, speed)); +} + +export function clampPlaybackPitch(semitones: number): number { + return Math.max(PLAYBACK_PITCH_MIN, Math.min(PLAYBACK_PITCH_MAX, semitones)); +} + +/** Pitch sent to Rust: manual offset only in preserve_pitch strategy. */ +export function effectivePlaybackPitch( + strategy: PlaybackStrategy, + pitchSemitones: number, +): number { + return strategy === 'preserve_pitch' ? pitchSemitones : 0; +} + +/** True when DSP should run (enabled + not neutral 1.0× / 0 st). */ +export function isPlaybackEffectActive( + enabled: boolean, + strategy: PlaybackStrategy, + speed: number, + pitchSemitones: number, +): boolean { + if (!enabled) return false; + if (strategy === 'preserve_pitch') { + return Math.abs(speed - 1) > 0.001 || Math.abs(pitchSemitones) > 0.001; + } + return Math.abs(speed - 1) > 0.001; +} + +/** True when the engine applies playback-rate DSP (Orbit sessions force passthrough). */ +export function isPlaybackRateApplied( + enabled: boolean, + strategy: PlaybackStrategy, + speed: number, + pitchSemitones: number, + orbitSessionActive: boolean, +): boolean { + if (orbitSessionActive) return false; + return isPlaybackEffectActive(enabled, strategy, speed, pitchSemitones); +} + +export function derivedVarispeedSemitones(speed: number): number { + if (speed <= 0) return 0; + return 12 * Math.log2(speed); +} + +export function formatSpeedLabel(speed: number): string { + return `${speed.toFixed(1)}×`; +} + +export function formatPitchLabel(semitones: number): string { + const rounded = Math.round(semitones * 10) / 10; + return rounded > 0 ? `+${rounded.toFixed(1)} st` : `${rounded.toFixed(1)} st`; +} diff --git a/src/utils/audio/playbackRateRestart.test.ts b/src/utils/audio/playbackRateRestart.test.ts new file mode 100644 index 00000000..a0081928 --- /dev/null +++ b/src/utils/audio/playbackRateRestart.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { isPlaybackEffectActive } from './playbackRateHelpers'; +import { + playbackPathChanged, + shouldRestartPlaybackForRateChange, + usesPreservePlaybackPath, +} from './playbackRateRestart'; + +describe('playbackRateRestart', () => { + const base = { + enabled: true, + strategy: 'speed_corrected' as const, + speed: 1.5, + pitchSemitones: 0, + }; + + it('detects preserve vs varispeed paths', () => { + expect(usesPreservePlaybackPath('speed_corrected')).toBe(true); + expect(usesPreservePlaybackPath('preserve_pitch')).toBe(true); + expect(usesPreservePlaybackPath('varispeed')).toBe(false); + expect(playbackPathChanged('speed_corrected', 'varispeed')).toBe(true); + expect(playbackPathChanged('speed_corrected', 'preserve_pitch')).toBe(false); + }); + + it('restarts on strategy change', () => { + expect(shouldRestartPlaybackForRateChange( + base, + { ...base, strategy: 'varispeed' }, + )).toBe(true); + }); + + it('restarts when effect becomes active', () => { + expect(shouldRestartPlaybackForRateChange( + { ...base, speed: 1.0 }, + { ...base, speed: 1.5 }, + )).toBe(true); + expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 0)).toBe(false); + expect(isPlaybackEffectActive(true, 'speed_corrected', 1.5, 0)).toBe(true); + }); + + it('does not restart on speed tweak within varispeed', () => { + expect(shouldRestartPlaybackForRateChange( + { ...base, strategy: 'varispeed', speed: 1.5 }, + { ...base, strategy: 'varispeed', speed: 1.75 }, + )).toBe(false); + }); + + it('does not restart on pitch tweak within preserve_pitch', () => { + expect(shouldRestartPlaybackForRateChange( + { ...base, strategy: 'preserve_pitch', pitchSemitones: 0 }, + { ...base, strategy: 'preserve_pitch', pitchSemitones: 2 }, + )).toBe(false); + }); +}); diff --git a/src/utils/audio/playbackRateRestart.ts b/src/utils/audio/playbackRateRestart.ts new file mode 100644 index 00000000..c91fe8d9 --- /dev/null +++ b/src/utils/audio/playbackRateRestart.ts @@ -0,0 +1,70 @@ +import { usePlayerStore } from '../../store/playerStore'; +import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState'; +import { isPlaybackEffectActive, type PlaybackStrategy } from './playbackRateHelpers'; + +/** Preserve-pitch DSP (worker) vs direct varispeed sample-rate scaling. */ +export function usesPreservePlaybackPath(strategy: PlaybackStrategy): boolean { + return strategy === 'speed_corrected' || strategy === 'preserve_pitch'; +} + +export function playbackPathChanged(a: PlaybackStrategy, b: PlaybackStrategy): boolean { + return usesPreservePlaybackPath(a) !== usesPreservePlaybackPath(b); +} + +export interface PlaybackRateSnapshot { + enabled: boolean; + strategy: PlaybackStrategy; + speed: number; + pitchSemitones: number; +} + +/** Whether live atomics are enough vs needing a source rebuild (spec §2.5). */ +export function shouldRestartPlaybackForRateChange( + prev: PlaybackRateSnapshot, + next: PlaybackRateSnapshot, +): boolean { + if (prev.strategy !== next.strategy) return true; + if (prev.enabled !== next.enabled && isPlaybackEffectActive( + next.enabled, + next.strategy, + next.speed, + next.pitchSemitones, + )) { + return true; + } + const prevActive = isPlaybackEffectActive( + prev.enabled, + prev.strategy, + prev.speed, + prev.pitchSemitones, + ); + const nextActive = isPlaybackEffectActive( + next.enabled, + next.strategy, + next.speed, + next.pitchSemitones, + ); + if (prevActive !== nextActive) return true; + return false; +} + +/** Re-decode current track at the current timeline position (seek-restart). */ +export function restartPlaybackForRateChange(): void { + const player = usePlayerStore.getState(); + const track = player.currentTrack; + if (!track || !player.isPlaying) return; + if (track.radioAdded) return; + + const dur = track.duration; + if (!dur || !Number.isFinite(dur) || dur <= 0) return; + + const time = Math.max(0, Math.min(player.currentTime, dur - 0.25)); + if (time > 0.05) { + setSeekFallbackVisualTarget({ + trackId: track.id, + seconds: time, + setAtMs: Date.now(), + }); + } + player.playTrack(track, player.queue, true); +} diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 7e4b28fd..5f1e7daa 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -31,6 +31,7 @@ export { serialiseOrbitState, suggestionKey, } from './orbit/helpers'; +export { isOrbitPlaybackSyncActive } from './orbit/sessionActive'; export { applyOutboxSnapshotsToState, computeOrbitDriftMs, diff --git a/src/utils/orbit/sessionActive.ts b/src/utils/orbit/sessionActive.ts new file mode 100644 index 00000000..f40fc7c6 --- /dev/null +++ b/src/utils/orbit/sessionActive.ts @@ -0,0 +1,14 @@ +import type { OrbitPhase, OrbitRole } from '../../store/orbitStore'; +import { useOrbitStore } from '../../store/orbitStore'; + +/** + * True while this client is in an Orbit session on the shared playback path + * (host/guest during start, join, or active play). Matches previewStore guard. + */ +export function isOrbitPlaybackSyncActive( + role: OrbitRole | null = useOrbitStore.getState().role, + phase: OrbitPhase = useOrbitStore.getState().phase, +): boolean { + if (role !== 'host' && role !== 'guest') return false; + return phase === 'active' || phase === 'joining' || phase === 'starting'; +}