feat(playback): global speed with three strategies (#852)

* feat(playback): global speed with three strategies

Add Settings → Audio and player-bar controls for global playback speed
(speed with auto pitch correction as default, varispeed, manual pitch shift).
Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough.

* fix(playback): align seekbar, seek, and progress on content timeline

Unify UI timebase across varispeed and preserve strategies: full-track
duration, speed-scaled progress for DSP paths, and content-timeline seeks
without varispeed scaling. Reset the sample counter after seek so clicks
land correctly; restart playback on strategy/enable changes instead of
fragile hot-switching.

* fix(ui): anchor playback speed popover like volume controls

Replace the centered EQ-style modal with a player-bar popover (outside
click, Escape, reposition on scroll). Show compact controls in the bar
and overflow menu; keep strategy hints and labels in Settings only.

* docs(release): CHANGELOG and credits for playback speed (PR #852)

* docs(changelog): add playback speed entry for PR #852

* fix(clippy): simplify raw_counter_samples branch for CI

Collapse duplicate if branches flagged by clippy::if-same-then-else.

* fix(ui): wheel on pitch slider adjusts pitch in speed popover

In compact player-bar controls, scroll over the pitch row changes pitch;
elsewhere in the panel changes speed. Stop propagation so overflow menu
wheel does not tweak volume.

* fix(playback): address PR #852 review and drop ineffective dynamic imports

Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample
counter on live preserve-path speed changes; use neutral rate atomics for
radio progress; static-import playerStore in playListenSession (move preview
volume sync to previewPlayerVolumeSync side-effect module).

* fix(i18n): translate playback-rate strategy labels in all locales

Replace leftover English Varispeed/Pitch strings in ru and other non-en
settings blocks so popover strategy buttons and hints read natively.

* fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo"

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
cucadmuh
2026-05-22 19:59:28 +03:00
committed by GitHub
parent d54eceaf3b
commit e8e41752a7
64 changed files with 2405 additions and 70 deletions
+11
View File
@@ -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.52.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 ### Servers — edit existing profiles
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)**
+40
View File
@@ -2790,6 +2790,23 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -2922,6 +2939,16 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
@@ -3502,6 +3529,18 @@ dependencies = [
"futures-io", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@@ -3783,6 +3822,7 @@ dependencies = [
"libc", "libc",
"lofty", "lofty",
"md5", "md5",
"pitch_shift",
"psysonic-analysis", "psysonic-analysis",
"psysonic-core", "psysonic-core",
"reqwest", "reqwest",
@@ -26,6 +26,7 @@ url = "2"
thread-priority = "3" thread-priority = "3"
lofty = "0.24" lofty = "0.24"
id3 = "1.16.4" id3 = "1.16.4"
pitch_shift = "2.1"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
@@ -19,6 +19,7 @@ use super::play_input::{
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs, spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
PlayInputContext, SinkSwapInputs, PlayInputContext, SinkSwapInputs,
}; };
use super::playback_rate::preserve_pitch_will_run;
use super::preview::preview_clear_for_new_main_playback; use super::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task; use super::progress_task::spawn_progress_task;
use super::state::{ChainedInfo, PreloadedTrack}; 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 // we resume — the buffer is already full and the hardware gets its frames
// without an underrun on the very first period. // without an underrun on the very first period.
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small. // 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); let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
if needs_prefill || defer_playback_start { if needs_prefill || defer_playback_start {
sink.pause(); sink.pause();
@@ -387,12 +392,12 @@ pub async fn audio_play(
sink.append(source); sink.append(source);
if needs_prefill { if needs_prefill {
// 500 ms lets rodio decode several seconds of hi-res audio into its let prefill_ms = if needs_preserve_prefill {
// internal buffer while the sink is paused. The hardware sees no gap 800
// because the output is held — it only starts draining after sink.play(). } else {
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size, 500
// absorbing scheduler jitter and PipeWire graph wake-up latency. };
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(prefill_ms)).await;
if state.generation.load(Ordering::SeqCst) != gen { if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently return Ok(()); // skipped during pre-fill — abort silently
} }
@@ -447,6 +452,7 @@ pub async fn audio_play(
state.gapless_switch_at.clone(), state.gapless_switch_at.clone(),
state.current_playback_url.clone(), state.current_playback_url.clone(),
state.stream_playback_armed.clone(), state.stream_playback_armed.clone(),
state.playback_rate.clone(),
); );
Ok(()) Ok(())
@@ -568,6 +574,7 @@ pub async fn audio_chain_preload(
state.eq_gains.clone(), state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_enabled.clone(),
state.eq_pre_gain.clone(), state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_next.clone(), done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(), chain_counter.clone(),
+29 -10
View File
@@ -17,6 +17,7 @@ use symphonia::core::{
}; };
use super::codec::{psysonic_codec_registry, try_make_radio_decoder}; use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
use super::playback_rate::{PlaybackRateAtomics, PlaybackRateSource};
use super::sources::*; use super::sources::*;
// ─── SizedCursorSource — correct byte_len for seekable in-memory 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_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>, eq_enabled: Arc<AtomicBool>,
eq_pre_gain: Arc<AtomicU32>, eq_pre_gain: Arc<AtomicU32>,
playback_rate: PlaybackRateAtomics,
done_flag: Arc<AtomicBool>, done_flag: Arc<AtomicBool>,
fade_in_dur: Duration, fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>, sample_counter: Arc<AtomicU64>,
@@ -616,7 +618,9 @@ pub(crate) fn build_source(
let fadeout_trigger = Arc::new(AtomicBool::new(false)); let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0)); 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_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone()); let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag); let notifying = NotifyingSource::new(fade_out, done_flag);
@@ -625,7 +629,7 @@ pub(crate) fn build_source(
Ok(BuiltSource { Ok(BuiltSource {
source: boosted, source: boosted,
duration_secs: effective_dur, duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
output_rate, output_rate,
output_channels: channels.get(), output_channels: channels.get(),
fadeout_trigger, fadeout_trigger,
@@ -643,6 +647,7 @@ pub(crate) fn build_streaming_source(
eq_gains: Arc<[AtomicU32; 10]>, eq_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>, eq_enabled: Arc<AtomicBool>,
eq_pre_gain: Arc<AtomicU32>, eq_pre_gain: Arc<AtomicU32>,
playback_rate: PlaybackRateAtomics,
done_flag: Arc<AtomicBool>, done_flag: Arc<AtomicBool>,
fade_in_dur: Duration, fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>, sample_counter: Arc<AtomicU64>,
@@ -682,7 +687,9 @@ pub(crate) fn build_streaming_source(
let fadeout_trigger = Arc::new(AtomicBool::new(false)); let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0)); 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_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone()); let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag); let notifying = NotifyingSource::new(fade_out, done_flag);
@@ -694,7 +701,7 @@ pub(crate) fn build_streaming_source(
Ok(BuiltSource { Ok(BuiltSource {
source: boosted, source: boosted,
duration_secs: effective_dur, duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
output_rate, output_rate,
output_channels: channels.get(), output_channels: channels.get(),
fadeout_trigger, fadeout_trigger,
@@ -915,21 +922,29 @@ mod build_source_tests {
} }
type EqGains = Arc<[AtomicU32; 10]>; type EqGains = Arc<[AtomicU32; 10]>;
type SourceArgs = (EqGains, Arc<AtomicBool>, Arc<AtomicU32>, Arc<AtomicBool>, Arc<AtomicU64>); type SourceArgs = (
EqGains,
Arc<AtomicBool>,
Arc<AtomicU32>,
PlaybackRateAtomics,
Arc<AtomicBool>,
Arc<AtomicU64>,
);
fn default_source_args() -> SourceArgs { fn default_source_args() -> SourceArgs {
let eq_gains: Arc<[AtomicU32; 10]> = let eq_gains: Arc<[AtomicU32; 10]> =
Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))); Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits())));
let eq_enabled = Arc::new(AtomicBool::new(false)); let eq_enabled = Arc::new(AtomicBool::new(false));
let eq_pre_gain = Arc::new(AtomicU32::new(0f32.to_bits())); 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 done_flag = Arc::new(AtomicBool::new(false));
let sample_counter = Arc::new(AtomicU64::new(0)); 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] #[test]
fn build_source_succeeds_for_synthetic_wav() { 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 wav = synthetic_wav_bytes_local(0.4);
let built = build_source( let built = build_source(
wav, wav,
@@ -937,6 +952,7 @@ mod build_source_tests {
eq_gains, eq_gains,
eq_enabled, eq_enabled,
eq_pre_gain, eq_pre_gain,
playback_rate,
done_flag, done_flag,
Duration::ZERO, Duration::ZERO,
sample_counter, sample_counter,
@@ -952,13 +968,14 @@ mod build_source_tests {
#[test] #[test]
fn build_source_returns_err_for_garbage_bytes() { 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( let result = build_source(
vec![0u8; 32], vec![0u8; 32],
0.0, 0.0,
eq_gains, eq_gains,
eq_enabled, eq_enabled,
eq_pre_gain, eq_pre_gain,
playback_rate,
done_flag, done_flag,
Duration::ZERO, Duration::ZERO,
sample_counter, sample_counter,
@@ -971,7 +988,7 @@ mod build_source_tests {
#[test] #[test]
fn build_streaming_source_succeeds_for_synthetic_wav() { 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 wav = synthetic_wav_bytes_local(0.4);
let decoder = SizedDecoder::new(wav, Some("wav"), false).unwrap(); let decoder = SizedDecoder::new(wav, Some("wav"), false).unwrap();
let built = build_streaming_source( let built = build_streaming_source(
@@ -980,6 +997,7 @@ mod build_source_tests {
eq_gains, eq_gains,
eq_enabled, eq_enabled,
eq_pre_gain, eq_pre_gain,
playback_rate,
done_flag, done_flag,
Duration::ZERO, Duration::ZERO,
sample_counter, sample_counter,
@@ -993,7 +1011,7 @@ mod build_source_tests {
#[test] #[test]
fn build_source_with_target_rate_resamples() { 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 wav = synthetic_wav_bytes_local(0.3);
let built = build_source( let built = build_source(
wav, wav,
@@ -1001,6 +1019,7 @@ mod build_source_tests {
eq_gains, eq_gains,
eq_enabled, eq_enabled,
eq_pre_gain, eq_pre_gain,
playback_rate,
done_flag, done_flag,
Duration::from_millis(5), Duration::from_millis(5),
sample_counter, sample_counter,
@@ -222,6 +222,15 @@ pub(crate) async fn try_resume_after_device_change(
let mut cur = engine.current.lock().unwrap(); let mut cur = engine.current.lock().unwrap();
cur.seek_offset = snap.current_time_secs; cur.seek_offset = snap.current_time_secs;
cur.play_started = Some(Instant::now()); 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)) => { Ok(Err(e)) => {
crate::app_eprintln!("[device-resume] seek failed: {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.gapless_switch_at.clone(),
engine.current_playback_url.clone(), engine.current_playback_url.clone(),
engine.stream_playback_armed.clone(), engine.stream_playback_armed.clone(),
engine.playback_rate.clone(),
); );
crate::app_deprintln!( crate::app_deprintln!(
@@ -32,6 +32,7 @@ pub struct AudioEngine {
pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc<AtomicBool>, pub eq_enabled: Arc<AtomicBool>,
pub eq_pre_gain: Arc<AtomicU32>, pub eq_pre_gain: Arc<AtomicU32>,
pub playback_rate: crate::playback_rate::PlaybackRateAtomics,
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>, pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
/// Last fully downloaded manual-stream track bytes (same playback identity), /// Last fully downloaded manual-stream track bytes (same playback identity),
/// used to recover seek/replay without waiting for network again. /// 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_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
eq_enabled: Arc::new(AtomicBool::new(false)), eq_enabled: Arc::new(AtomicBool::new(false)),
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())), eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
playback_rate: crate::playback_rate::PlaybackRateAtomics::new(),
preloaded: Arc::new(Mutex::new(None)), preloaded: Arc::new(Mutex::new(None)),
stream_completed_cache: Arc::new(Mutex::new(None)), stream_completed_cache: Arc::new(Mutex::new(None)),
stream_completed_spill: Arc::new(Mutex::new(None)), stream_completed_spill: Arc::new(Mutex::new(None)),
@@ -14,6 +14,8 @@ mod dev_io;
pub mod device_commands; pub mod device_commands;
pub mod mix_commands; pub mod mix_commands;
mod play_input; mod play_input;
pub mod playback_rate;
mod preserve_worker;
pub mod preload_commands; pub mod preload_commands;
pub(crate) mod progress_task; pub(crate) mod progress_task;
pub mod radio_commands; pub mod radio_commands;
@@ -143,6 +143,89 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed); 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] #[tauri::command]
pub fn audio_set_normalization( pub fn audio_set_normalization(
engine: String, engine: String,
@@ -846,6 +846,7 @@ pub(super) async fn build_source_from_play_input(
state.eq_gains.clone(), state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_enabled.clone(),
state.eq_pre_gain.clone(), state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag, done_flag,
fade_in_dur, fade_in_dur,
state.samples_played.clone(), state.samples_played.clone(),
@@ -876,6 +877,7 @@ pub(super) async fn build_source_from_play_input(
state.eq_gains.clone(), state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_enabled.clone(),
state.eq_pre_gain.clone(), state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag, done_flag,
fade_in_dur, fade_in_dur,
state.samples_played.clone(), state.samples_played.clone(),
@@ -896,6 +898,7 @@ pub(super) async fn build_source_from_play_input(
state.eq_gains.clone(), state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_enabled.clone(),
state.eq_pre_gain.clone(), state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag, done_flag,
fade_in_dur, fade_in_dur,
state.samples_played.clone(), state.samples_played.clone(),
@@ -0,0 +1,662 @@
//! Global playback speed / pitch strategies (varispeed, speed-corrected, preserve pitch).
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
use rodio::source::SeekError;
use rodio::{ChannelCount, SampleRate, Source};
use crate::preserve_worker::PreserveOffload;
pub const STRATEGY_VARISPEED: u32 = 0;
pub const STRATEGY_PRESERVE_PITCH: u32 = 1;
pub const STRATEGY_SPEED_CORRECTED: u32 = 2;
pub(crate) const PRESERVE_MAKEUP_GAIN: f32 = 1.35;
#[derive(Clone)]
pub struct PlaybackRateAtomics {
pub enabled: Arc<AtomicBool>,
pub strategy: Arc<AtomicU32>,
pub speed: Arc<AtomicU32>,
pub pitch_semitones: Arc<AtomicU32>,
}
impl Default for PlaybackRateAtomics {
fn default() -> Self {
Self {
enabled: Arc::new(AtomicBool::new(false)),
strategy: Arc::new(AtomicU32::new(STRATEGY_SPEED_CORRECTED)),
speed: Arc::new(AtomicU32::new(1.0f32.to_bits())),
pitch_semitones: Arc::new(AtomicU32::new(0.0f32.to_bits())),
}
}
}
impl PlaybackRateAtomics {
pub fn new() -> Self {
Self::default()
}
pub fn load_speed(&self) -> f32 {
f32::from_bits(self.speed.load(Ordering::Relaxed)).clamp(0.5, 2.0)
}
pub fn load_pitch(&self) -> f32 {
f32::from_bits(self.pitch_semitones.load(Ordering::Relaxed)).clamp(-12.0, 12.0)
}
pub fn load_strategy(&self) -> u32 {
match self.strategy.load(Ordering::Relaxed) {
STRATEGY_PRESERVE_PITCH => STRATEGY_PRESERVE_PITCH,
STRATEGY_SPEED_CORRECTED => STRATEGY_SPEED_CORRECTED,
_ => STRATEGY_VARISPEED,
}
}
}
pub fn uses_preserve_dsp(strategy: u32) -> bool {
strategy == STRATEGY_PRESERVE_PITCH || strategy == STRATEGY_SPEED_CORRECTED
}
pub fn effective_pitch(atomics: &PlaybackRateAtomics) -> f32 {
if atomics.load_strategy() == STRATEGY_PRESERVE_PITCH {
atomics.load_pitch()
} else {
0.0
}
}
pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
if !atomics.enabled.load(Ordering::Relaxed) {
return false;
}
let speed = atomics.load_speed();
match atomics.load_strategy() {
STRATEGY_PRESERVE_PITCH => {
(speed - 1.0).abs() > 0.001 || atomics.load_pitch().abs() > 0.001
}
_ => (speed - 1.0).abs() > 0.001,
}
}
/// True when preserve-pitch DSP (background worker) should run for this track.
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
atomics.enabled.load(Ordering::Relaxed)
&& uses_preserve_dsp(atomics.load_strategy())
&& is_effect_active(atomics)
}
/// Content timeline length for seek bar / duration labels (always the full track).
pub fn effective_duration_secs(base_secs: f64, _atomics: &PlaybackRateAtomics) -> f64 {
base_secs
}
/// Map counter-derived seconds to timeline position for UI / near-end checks.
pub fn effective_position_secs(raw_secs: f64, atomics: &PlaybackRateAtomics) -> f64 {
if !is_effect_active(atomics) {
return raw_secs;
}
if atomics.load_strategy() == STRATEGY_VARISPEED {
return raw_secs;
}
// Preserve DSP outputs at the base sample rate; scale to content timeline.
raw_secs * atomics.load_speed() as f64
}
/// Sample-counter position mapped to the content timeline (seek bar / labels).
pub(crate) fn content_position_from_samples(
samples: u64,
sample_rate_hz: u32,
channels: u32,
atomics: &PlaybackRateAtomics,
) -> f64 {
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
effective_position_secs(samples as f64 / divisor, atomics)
}
/// Counter value that matches `content_position_from_samples` after a content-timeline seek.
pub(crate) fn raw_counter_samples_for_content_position(
content_secs: f64,
sample_rate_hz: u32,
channels: u32,
atomics: &PlaybackRateAtomics,
) -> u64 {
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
let raw_secs = if is_effect_active(atomics)
&& atomics.load_strategy() != STRATEGY_VARISPEED
{
content_secs / atomics.load_speed().max(0.001) as f64
} else {
content_secs
};
(raw_secs * divisor).round() as u64
}
pub(crate) fn preserve_out_samples(speed: f32) -> usize {
(128.0f32 / speed.clamp(0.5, 2.0)).round() as usize
}
pub struct PlaybackRateSource<S: Source<Item = f32> + Send + 'static> {
inner: Option<S>,
base_sample_rate: SampleRate,
base_channels: ChannelCount,
atomics: PlaybackRateAtomics,
offload: Option<PreserveOffload>,
handback_rx: Option<mpsc::Receiver<S>>,
handback_requested: bool,
}
impl<S: Source<Item = f32> + Send + 'static> PlaybackRateSource<S> {
pub fn new(inner: S, atomics: PlaybackRateAtomics) -> Self {
let base_sample_rate = inner.sample_rate();
let base_channels = inner.channels();
Self {
inner: Some(inner),
base_sample_rate,
base_channels,
atomics,
offload: None,
handback_rx: None,
handback_requested: false,
}
}
fn poll_handback(&mut self) {
let Some(rx) = &self.handback_rx else {
return;
};
if let Ok(inner) = rx.try_recv() {
self.inner = Some(inner);
self.handback_rx = None;
self.handback_requested = false;
if let Some(offload) = self.offload.take() {
offload.join();
}
}
}
fn request_handback_if_needed(&mut self) {
if self.inner.is_some() || self.handback_requested {
return;
}
if let Some(offload) = &self.offload {
offload.request_handback();
self.handback_requested = true;
}
}
fn ensure_offload(&mut self) {
if self.offload.is_some() {
return;
}
if let Some(inner) = self.inner.take() {
let (handback_tx, handback_rx) = mpsc::sync_channel(1);
self.handback_rx = Some(handback_rx);
self.offload = Some(PreserveOffload::spawn(
inner,
self.atomics.clone(),
self.base_sample_rate.get(),
self.base_channels.get(),
handback_tx,
));
}
}
fn base_sample_rate(&self) -> SampleRate {
self.inner
.as_ref()
.map(Source::sample_rate)
.unwrap_or(self.base_sample_rate)
}
fn try_recover_inner_from_offload(&mut self) {
if self.inner.is_some() || self.offload.is_none() {
return;
}
self.request_handback_if_needed();
self.poll_handback();
}
fn next_from_inner_or_pad(&mut self) -> Option<f32> {
self.try_recover_inner_from_offload();
if let Some(inner) = self.inner.as_mut() {
return inner.next();
}
if self
.offload
.as_ref()
.is_some_and(|offload| !offload.is_done())
{
return Some(0.0);
}
None
}
}
impl<S: Source<Item = f32> + Send + 'static> Iterator for PlaybackRateSource<S> {
type Item = f32;
fn next(&mut self) -> Option<Self::Item> {
if !is_effect_active(&self.atomics) {
if let Some(offload) = self.offload.as_mut() {
if let Some(s) = offload.pop() {
return Some(s);
}
}
return self.next_from_inner_or_pad();
}
if uses_preserve_dsp(self.atomics.load_strategy()) {
self.ensure_offload();
if let Some(s) = self.offload.as_mut().and_then(|o| o.pop()) {
return Some(s);
}
if self
.offload
.as_ref()
.is_some_and(|offload| !offload.is_done())
{
return Some(0.0);
}
return None;
}
// Varispeed: decoder must stay in `inner` (never in the preserve worker).
if self.offload.is_some() {
self.try_recover_inner_from_offload();
}
self.next_from_inner_or_pad()
}
}
impl<S: Source<Item = f32> + Send + 'static> Source for PlaybackRateSource<S> {
fn current_span_len(&self) -> Option<usize> {
self.inner.as_ref()?.current_span_len()
}
fn channels(&self) -> ChannelCount {
self.base_channels
}
fn sample_rate(&self) -> SampleRate {
if is_effect_active(&self.atomics) && self.atomics.load_strategy() == STRATEGY_VARISPEED {
let factor = self.atomics.load_speed().max(0.001);
SampleRate::new((self.base_sample_rate().get() as f32 * factor).max(1.0) as u32)
.unwrap_or(self.base_sample_rate)
} else {
self.base_sample_rate()
}
}
fn total_duration(&self) -> Option<Duration> {
self.inner.as_ref()?.total_duration()
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
// UI / transport always pass content-timeline seconds (0..full track).
if let Some(inner) = self.inner.as_mut() {
inner.try_seek(pos)?;
}
if let Some(offload) = self.offload.as_mut() {
offload.request_seek(pos);
offload.drain();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pitch_shift::{Shifter, TOTAL_F32};
#[test]
fn passthrough_when_disabled() {
let a = PlaybackRateAtomics::new();
assert!(!is_effect_active(&a));
}
#[test]
fn passthrough_at_unity() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
assert!(!is_effect_active(&a));
}
#[test]
fn active_when_speed_not_one() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
assert!(is_effect_active(&a));
}
#[test]
fn effective_duration_is_content_timeline() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
for strat in [
STRATEGY_VARISPEED,
STRATEGY_SPEED_CORRECTED,
STRATEGY_PRESERVE_PITCH,
] {
a.strategy.store(strat, Ordering::Relaxed);
assert!(
(effective_duration_secs(200.0, &a) - 200.0).abs() < 0.001,
"strategy {strat}"
);
}
}
#[test]
fn effective_position_varispeed_uses_counter() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
assert!((effective_position_secs(20.0, &a) - 20.0).abs() < 0.001);
}
#[test]
fn effective_position_preserve_scales_with_speed() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
assert!((effective_position_secs(10.0, &a) - 20.0).abs() < 0.001);
}
#[test]
fn effective_position_inactive_is_raw() {
let a = PlaybackRateAtomics::new();
assert!((effective_position_secs(15.0, &a) - 15.0).abs() < 0.001);
}
#[test]
fn raw_counter_samples_roundtrip_content_timeline() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(120.0, 44_100, 2, &a);
let back = content_position_from_samples(samples, 44_100, 2, &a);
assert!((back - 120.0).abs() < 0.05, "roundtrip at 2x preserve");
}
#[test]
fn raw_counter_samples_roundtrip_varispeed() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(90.0, 44_100, 2, &a);
let back = content_position_from_samples(samples, 44_100, 2, &a);
assert!((back - 90.0).abs() < 0.05, "roundtrip at 2x varispeed");
}
#[test]
fn varispeed_seek_uses_content_timeline() {
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::sync::Arc;
struct SeekSpy {
rate: SampleRate,
last_seek_secs: Arc<AtomicU64>,
remaining: usize,
}
impl Iterator for SeekSpy {
type Item = f32;
fn next(&mut self) -> Option<f32> {
if self.remaining == 0 {
return None;
}
self.remaining -= 1;
Some(0.0)
}
}
impl Source for SeekSpy {
fn current_span_len(&self) -> Option<usize> {
Some(self.remaining)
}
fn channels(&self) -> ChannelCount {
ChannelCount::new(1).unwrap()
}
fn sample_rate(&self) -> SampleRate {
self.rate
}
fn total_duration(&self) -> Option<Duration> {
Some(Duration::from_secs(200))
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
self.last_seek_secs
.store(pos.as_secs_f64().to_bits(), AtomicOrdering::Relaxed);
Ok(())
}
}
let last = Arc::new(AtomicU64::new(f64::NAN.to_bits()));
let spy = SeekSpy {
rate: SampleRate::new(44_100).unwrap(),
last_seek_secs: last.clone(),
remaining: 44_100,
};
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let mut src = PlaybackRateSource::new(spy, a);
src.try_seek(Duration::from_secs(120)).unwrap();
let got = f64::from_bits(last.load(AtomicOrdering::Relaxed));
assert!(
(got - 120.0).abs() < 0.001,
"varispeed seek must not scale content position, got {got}"
);
}
#[test]
fn preserve_out_samples_clamped() {
assert_eq!(preserve_out_samples(2.0), 64);
assert_eq!(preserve_out_samples(0.5), 256);
}
struct FixedRateSource {
rate: u32,
remaining: usize,
}
impl Iterator for FixedRateSource {
type Item = f32;
fn next(&mut self) -> Option<f32> {
if self.remaining == 0 {
return None;
}
self.remaining -= 1;
Some(0.0)
}
}
impl Source for FixedRateSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.remaining)
}
fn channels(&self) -> ChannelCount {
std::num::NonZero::new(1).unwrap()
}
fn sample_rate(&self) -> SampleRate {
SampleRate::new(self.rate).unwrap()
}
fn total_duration(&self) -> Option<Duration> {
Some(Duration::from_secs(1))
}
}
#[test]
fn speed_corrected_uses_preserve_dsp_path() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
assert!(uses_preserve_dsp(atomics.load_strategy()));
assert!(is_effect_active(&atomics));
assert_eq!(effective_pitch(&atomics), 0.0);
}
#[test]
fn preserve_pitch_respects_manual_pitch() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
atomics.pitch_semitones.store(3.0f32.to_bits(), Ordering::Relaxed);
assert!(is_effect_active(&atomics));
assert_eq!(effective_pitch(&atomics), 3.0);
}
#[test]
fn strategy_switch_preserve_to_varispeed_does_not_end_early() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
let mut src = PlaybackRateSource::new(
FixedRateSource {
rate: 44_100,
remaining: 50_000,
},
atomics.clone(),
);
for _ in 0..5_000 {
assert!(src.next().is_some());
}
atomics
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
let mut got = 0usize;
for _ in 0..2_000 {
if src.next().is_some() {
got += 1;
} else {
break;
}
}
assert!(
got > 100,
"varispeed should continue after preserve strategy switch, got {got} samples"
);
}
#[test]
fn varispeed_scales_reported_sample_rate() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
let src = PlaybackRateSource::new(
FixedRateSource {
rate: 44_100,
remaining: 1,
},
atomics,
);
assert_eq!(src.sample_rate().get(), 66_150);
}
#[test]
fn varispeed_propagates_through_dyn_source() {
use crate::sources::DynSource;
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
atomics.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let rate_src = PlaybackRateSource::new(
FixedRateSource {
rate: 48_000,
remaining: 1,
},
atomics,
);
let dyn_src = DynSource::new(rate_src);
assert_eq!(dyn_src.sample_rate().get(), 96_000);
}
fn rms_f32(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
}
#[test]
fn preserve_pitch_makeup_keeps_level_reasonable() {
let sr = 44_100f32;
let mut input = [0.0f32; 128];
for (i, s) in input.iter_mut().enumerate() {
*s = (i as f32 * 0.12).sin() * 0.75;
}
let in_rms = rms_f32(&input);
let mut shifter: Shifter<Box<[f32; TOTAL_F32]>> =
Shifter::new(Box::new([0.0; TOTAL_F32]));
for _ in 0..24 {
shifter.shift(&input, 4.0, 128, sr);
}
let dry = shifter.shift(&input, 4.0, 128, sr);
let boosted: Vec<f32> = dry
.iter()
.map(|&s| (s * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0))
.collect();
let out_rms = rms_f32(&boosted);
assert!(out_rms > in_rms * 0.8, "out_rms={out_rms} in_rms={in_rms}");
assert!(out_rms < in_rms * 1.25, "out_rms={out_rms} in_rms={in_rms}");
}
#[test]
fn live_speed_change_represerves_content_position() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(30.0, 44_100, 2, &atomics);
let content = content_position_from_samples(samples, 44_100, 2, &atomics);
assert!((content - 30.0).abs() < 0.05);
atomics.speed.store(1.8f32.to_bits(), Ordering::Relaxed);
let restamped =
raw_counter_samples_for_content_position(content, 44_100, 2, &atomics);
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
assert!((after - 30.0).abs() < 0.05);
}
}
@@ -0,0 +1,467 @@
//! Background worker for preserve-pitch DSP (phase vocoder is too heavy for cpal callback).
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use pitch_shift::{Shifter, TOTAL_F32};
use ringbuf::traits::{Consumer, Observer, Producer, Split};
use ringbuf::{HeapCons, HeapProd, HeapRb};
use rodio::Source;
use crate::playback_rate::{
effective_pitch, is_effect_active, preserve_out_samples, PlaybackRateAtomics, PRESERVE_MAKEUP_GAIN,
uses_preserve_dsp,
};
const FRAME_BLOCK: usize = 128;
const PRESERVE_OUT_MAX: usize = 1023;
const PRESERVE_PARAM_EPS_PITCH: f32 = 0.05;
const PRESERVE_PARAM_EPS_SPEED: f32 = 0.001;
const RB_MIN_CAPACITY: usize = 44_100 * 2 * 2; // ~2 s stereo @ 44.1 kHz
const RB_TARGET_FILL: f32 = 0.6;
const RB_FILL_HIGH: f32 = 0.88;
const FORWARD_BATCH: usize = 4096;
const WORKER_IDLE_SLEEP: Duration = Duration::from_millis(1);
enum WorkerCmd {
Seek(Duration),
Handback,
Shutdown,
}
struct PreserveWorkerEnv {
atomics: PlaybackRateAtomics,
sample_rate: u32,
channels: u16,
capacity: usize,
stop: Arc<AtomicBool>,
done: Arc<AtomicBool>,
cmd_rx: mpsc::Receiver<WorkerCmd>,
}
pub(crate) struct PreserveOffload {
cons: HeapCons<f32>,
stop: Arc<AtomicBool>,
done: Arc<AtomicBool>,
cmd_tx: SyncSender<WorkerCmd>,
thread: Option<JoinHandle<()>>,
}
impl PreserveOffload {
pub(crate) fn spawn<S: Source<Item = f32> + Send + 'static>(
inner: S,
atomics: PlaybackRateAtomics,
sample_rate: u32,
channels: u16,
handback_tx: SyncSender<S>,
) -> Self {
let cap = ((sample_rate as f32 * channels as f32 * 2.5) as usize).max(RB_MIN_CAPACITY);
let rb = HeapRb::<f32>::new(cap);
let (prod, cons) = rb.split();
let stop = Arc::new(AtomicBool::new(false));
let done = Arc::new(AtomicBool::new(false));
let (cmd_tx, cmd_rx) = mpsc::sync_channel::<WorkerCmd>(8);
let stop_worker = stop.clone();
let done_worker = done.clone();
let thread = thread::Builder::new()
.name("psysonic-preserve-pitch".into())
.spawn(move || {
worker_main(
inner,
prod,
PreserveWorkerEnv {
atomics,
sample_rate,
channels,
capacity: cap,
stop: stop_worker,
done: done_worker,
cmd_rx,
},
handback_tx,
);
})
.expect("spawn preserve-pitch worker");
Self {
cons,
stop,
done,
cmd_tx,
thread: Some(thread),
}
}
pub(crate) fn pop(&mut self) -> Option<f32> {
self.cons.try_pop()
}
pub(crate) fn is_done(&self) -> bool {
self.done.load(Ordering::Acquire)
}
pub(crate) fn request_seek(&self, pos: Duration) {
let _ = self.cmd_tx.send(WorkerCmd::Seek(pos));
}
pub(crate) fn request_handback(&self) {
let _ = self.cmd_tx.send(WorkerCmd::Handback);
}
pub(crate) fn drain(&mut self) {
while self.cons.try_pop().is_some() {}
}
pub(crate) fn join(mut self) {
self.stop.store(true, Ordering::Release);
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for PreserveOffload {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
struct PreserveChannelState {
shifter: Shifter<Box<[f32; TOTAL_F32]>>,
frame: Vec<f32>,
}
impl PreserveChannelState {
fn new() -> Self {
Self {
shifter: Shifter::new(Box::new([0.0; TOTAL_F32])),
frame: Vec::with_capacity(FRAME_BLOCK),
}
}
fn reset(&mut self) {
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
self.frame.clear();
}
fn reset_shifter(&mut self) {
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
}
}
struct PreserveState {
channels: Vec<PreserveChannelState>,
pending: VecDeque<f32>,
channel_idx: usize,
last_pitch: f32,
last_speed: f32,
}
impl PreserveState {
fn for_channels(count: u16) -> Self {
let n = count.max(1) as usize;
Self {
channels: (0..n).map(|_| PreserveChannelState::new()).collect(),
pending: VecDeque::new(),
channel_idx: 0,
last_pitch: f32::NAN,
last_speed: f32::NAN,
}
}
fn reset(&mut self, channels: u16) {
let n = channels.max(1) as usize;
if self.channels.len() != n {
self.channels = (0..n).map(|_| PreserveChannelState::new()).collect();
} else {
for ch in &mut self.channels {
ch.reset();
}
}
self.pending.clear();
self.channel_idx = 0;
self.last_pitch = f32::NAN;
self.last_speed = f32::NAN;
}
fn reset_if_params_changed(&mut self, pitch: f32, speed: f32) {
if self.last_pitch.is_nan() {
self.last_pitch = pitch;
self.last_speed = speed;
return;
}
if (pitch - self.last_pitch).abs() > PRESERVE_PARAM_EPS_PITCH
|| (speed - self.last_speed).abs() > PRESERVE_PARAM_EPS_SPEED
{
for ch in &mut self.channels {
ch.reset_shifter();
}
self.pending.clear();
self.last_pitch = pitch;
self.last_speed = speed;
}
}
fn process_block(&mut self, speed: f32, pitch: f32, sample_rate: f32) {
self.reset_if_params_changed(pitch, speed);
let out_n = preserve_out_samples(speed).clamp(1, PRESERVE_OUT_MAX);
let ch_count = self.channels.len();
let mut outs: Vec<&[f32]> = Vec::with_capacity(ch_count);
for ch in &mut self.channels {
if ch.frame.len() == FRAME_BLOCK {
let out = ch.shifter.shift(&ch.frame, pitch, out_n, sample_rate);
outs.push(out);
ch.frame.clear();
}
}
if outs.len() != ch_count {
return;
}
for i in 0..out_n {
for out_slice in &outs {
if let Some(&sample) = out_slice.get(i) {
self.pending
.push_back((sample * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0));
}
}
}
}
}
fn ring_fill(prod: &HeapProd<f32>, capacity: usize) -> f32 {
1.0 - prod.vacant_len() as f32 / capacity as f32
}
fn push_pending(prod: &mut HeapProd<f32>, pending: &mut VecDeque<f32>, stop: &AtomicBool) {
while let Some(&s) = pending.front() {
if stop.load(Ordering::Acquire) {
return;
}
if prod.try_push(s).is_ok() {
pending.pop_front();
} else {
return;
}
}
}
fn forward_passthrough<S: Source<Item = f32>>(
inner: &mut S,
prod: &mut HeapProd<f32>,
capacity: usize,
stop: &AtomicBool,
) -> bool {
let target = (capacity as f32 * RB_TARGET_FILL) as usize;
let mut pushed = 0usize;
while prod.occupied_len() < target && pushed < FORWARD_BATCH {
if stop.load(Ordering::Acquire) {
return false;
}
let Some(s) = inner.next() else {
return false;
};
if prod.try_push(s).is_err() {
break;
}
pushed += 1;
}
true
}
fn worker_main<S: Source<Item = f32> + Send>(
mut inner: S,
mut prod: HeapProd<f32>,
env: PreserveWorkerEnv,
handback_tx: SyncSender<S>,
) {
let PreserveWorkerEnv {
atomics,
sample_rate,
channels,
capacity,
stop,
done,
cmd_rx,
} = env;
let ch_count = channels.max(1) as usize;
let mut preserve = PreserveState::for_channels(channels);
let sr = sample_rate as f32;
'run: while !stop.load(Ordering::Acquire) {
if let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
WorkerCmd::Shutdown => break,
WorkerCmd::Handback => {
push_pending(&mut prod, &mut preserve.pending, &stop);
let _ = handback_tx.send(inner);
done.store(true, Ordering::Release);
return;
}
WorkerCmd::Seek(pos) => {
let _ = inner.try_seek(pos);
preserve.reset(channels);
}
}
}
let use_preserve = atomics.enabled.load(Ordering::Relaxed)
&& uses_preserve_dsp(atomics.load_strategy())
&& is_effect_active(&atomics);
if !use_preserve {
preserve.reset(channels);
push_pending(&mut prod, &mut preserve.pending, &stop);
let fill = ring_fill(&prod, capacity);
if fill >= RB_FILL_HIGH {
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
Ok(WorkerCmd::Shutdown) => break 'run,
Ok(WorkerCmd::Handback) => {
push_pending(&mut prod, &mut preserve.pending, &stop);
let _ = handback_tx.send(inner);
done.store(true, Ordering::Release);
return;
}
Ok(WorkerCmd::Seek(pos)) => {
let _ = inner.try_seek(pos);
preserve.reset(channels);
}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break 'run,
}
}
if !forward_passthrough(&mut inner, &mut prod, capacity, &stop) {
break;
}
continue;
}
let fill = ring_fill(&prod, capacity);
if fill >= RB_FILL_HIGH {
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
Ok(WorkerCmd::Shutdown) => break 'run,
Ok(WorkerCmd::Handback) => {
push_pending(&mut prod, &mut preserve.pending, &stop);
let _ = handback_tx.send(inner);
done.store(true, Ordering::Release);
return;
}
Ok(WorkerCmd::Seek(pos)) => {
let _ = inner.try_seek(pos);
preserve.reset(channels);
}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break 'run,
}
}
push_pending(&mut prod, &mut preserve.pending, &stop);
if !preserve.pending.is_empty() {
continue;
}
match inner.next() {
Some(s) => {
let ch = preserve.channel_idx;
preserve.channels[ch].frame.push(s);
preserve.channel_idx = (ch + 1) % ch_count;
if preserve
.channels
.iter()
.all(|c| c.frame.len() >= FRAME_BLOCK)
{
preserve.process_block(
atomics.load_speed(),
effective_pitch(&atomics),
sr,
);
}
}
None => break,
}
}
push_pending(&mut prod, &mut preserve.pending, &stop);
done.store(true, Ordering::Release);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::playback_rate::STRATEGY_PRESERVE_PITCH;
use rodio::{ChannelCount, SampleRate};
use std::time::Duration as StdDuration;
struct SineSource {
remaining: usize,
rate: u32,
}
impl Iterator for SineSource {
type Item = f32;
fn next(&mut self) -> Option<f32> {
if self.remaining == 0 {
return None;
}
self.remaining -= 1;
Some(0.25)
}
}
impl Source for SineSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.remaining)
}
fn channels(&self) -> ChannelCount {
std::num::NonZero::new(2).unwrap()
}
fn sample_rate(&self) -> SampleRate {
SampleRate::new(self.rate).unwrap()
}
fn total_duration(&self) -> Option<StdDuration> {
Some(StdDuration::from_secs(1))
}
}
#[test]
fn worker_prefills_ring_before_done() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
atomics.speed.store(1.25f32.to_bits(), Ordering::Relaxed);
let src = SineSource {
remaining: 44_100 * 2,
rate: 44_100,
};
let (tx, _rx) = mpsc::sync_channel(1);
let mut offload = PreserveOffload::spawn(src, atomics, 44_100, 2, tx);
std::thread::sleep(Duration::from_millis(150));
let mut got = 0usize;
for _ in 0..10_000 {
if let Some(s) = offload.pop() {
got += 1;
if got > 500 {
break;
}
let _ = s;
} else if offload.is_done() {
break;
} else {
std::thread::sleep(Duration::from_millis(1));
}
}
assert!(got > 500, "expected prefetched samples, got {got}");
}
}
@@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter, Runtime};
use super::engine::AudioCurrent; use super::engine::AudioCurrent;
use super::helpers::{ramp_sink_volume, ProgressPayload, MASTER_HEADROOM}; use super::helpers::{ramp_sink_volume, ProgressPayload, MASTER_HEADROOM};
use super::playback_rate::{effective_duration_secs, effective_position_secs, PlaybackRateAtomics};
use super::state::ChainedInfo; use super::state::ChainedInfo;
/// Sink for the three progress events the task emits. Production wraps an /// Sink for the three progress events the task emits. Production wraps an
@@ -68,6 +69,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
gapless_switch_at: Arc<AtomicU64>, gapless_switch_at: Arc<AtomicU64>,
current_playback_url: Arc<Mutex<Option<String>>>, current_playback_url: Arc<Mutex<Option<String>>>,
stream_playback_armed: Arc<AtomicBool>, stream_playback_armed: Arc<AtomicBool>,
playback_rate: PlaybackRateAtomics,
) { ) {
// Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on // Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on
// Linux; mirrors the quantum policy used for stream open/reopen plus a small // Linux; mirrors the quantum policy used for stream open/reopen plus a small
@@ -196,10 +198,11 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
// Read playback snapshot under a single lock to minimize contention // Read playback snapshot under a single lock to minimize contention
// with seek/play/pause commands that also touch `current`. // with seek/play/pause commands that also touch `current`.
let (dur, paused_at) = { let (base_dur, paused_at) = {
let cur = current_arc.lock().unwrap(); let cur = current_arc.lock().unwrap();
(cur.duration_secs, cur.paused_at) (cur.duration_secs, cur.paused_at)
}; };
let dur = effective_duration_secs(base_dur, &playback_rate);
let is_paused = paused_at.is_some(); let is_paused = paused_at.is_some();
let pos_raw = if !stream_playback_armed.load(Ordering::Relaxed) { let pos_raw = if !stream_playback_armed.load(Ordering::Relaxed) {
@@ -207,7 +210,8 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
} else if let Some(p) = paused_at { } else if let Some(p) = paused_at {
p p
} else { } 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 { let progress_latency = if is_paused {
0.0 0.0
@@ -333,6 +337,7 @@ mod tests {
gapless_switch_at: Arc<AtomicU64>, gapless_switch_at: Arc<AtomicU64>,
playback_url: Arc<Mutex<Option<String>>>, playback_url: Arc<Mutex<Option<String>>>,
stream_playback_armed: Arc<AtomicBool>, stream_playback_armed: Arc<AtomicBool>,
playback_rate: PlaybackRateAtomics,
} }
impl TaskHarness { impl TaskHarness {
@@ -362,6 +367,7 @@ mod tests {
gapless_switch_at: Arc::new(AtomicU64::new(0)), gapless_switch_at: Arc::new(AtomicU64::new(0)),
playback_url: Arc::new(Mutex::new(None)), playback_url: Arc::new(Mutex::new(None)),
stream_playback_armed: Arc::new(AtomicBool::new(true)), stream_playback_armed: Arc::new(AtomicBool::new(true)),
playback_rate: PlaybackRateAtomics::new(),
} }
} }
@@ -381,6 +387,7 @@ mod tests {
self.gapless_switch_at.clone(), self.gapless_switch_at.clone(),
self.playback_url.clone(), self.playback_url.clone(),
self.stream_playback_armed.clone(), self.stream_playback_armed.clone(),
self.playback_rate.clone(),
); );
} }
} }
@@ -11,6 +11,7 @@ use rodio::{Player, Source};
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder; use super::decode::SizedDecoder;
use super::playback_rate::PlaybackRateAtomics;
use super::engine::{audio_http_client, AudioEngine}; use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{content_type_to_hint, MASTER_HEADROOM}; use super::helpers::{content_type_to_hint, MASTER_HEADROOM};
use super::progress_task::spawn_progress_task; use super::progress_task::spawn_progress_task;
@@ -190,6 +191,7 @@ pub async fn audio_play_radio(
state.gapless_switch_at.clone(), state.gapless_switch_at.clone(),
state.current_playback_url.clone(), state.current_playback_url.clone(),
state.stream_playback_armed.clone(), state.stream_playback_armed.clone(),
PlaybackRateAtomics::default(),
); );
Ok(()) Ok(())
+10 -11
View File
@@ -14,7 +14,6 @@ const EQ_CHECK_INTERVAL: usize = 1024;
pub(crate) struct EqSource<S: Source<Item = f32>> { pub(crate) struct EqSource<S: Source<Item = f32>> {
inner: S, inner: S,
sample_rate: rodio::SampleRate,
channels: rodio::ChannelCount, channels: rodio::ChannelCount,
gains: Arc<[AtomicU32; 10]>, gains: Arc<[AtomicU32; 10]>,
enabled: Arc<AtomicBool>, enabled: Arc<AtomicBool>,
@@ -47,7 +46,7 @@ impl<S: Source<Item = f32>> EqSource<S> {
}) })
}); });
Self { Self {
inner, sample_rate, channels, gains, enabled, pre_gain, inner, channels, gains, enabled, pre_gain,
filters, filters,
current_gains: [0.0; 10], current_gains: [0.0; 10],
sample_counter: 0, sample_counter: 0,
@@ -57,14 +56,15 @@ impl<S: Source<Item = f32>> EqSource<S> {
#[allow(clippy::needless_range_loop)] #[allow(clippy::needless_range_loop)]
fn refresh_if_needed(&mut self) { fn refresh_if_needed(&mut self) {
let sample_rate = self.inner.sample_rate();
for band in 0..10 { for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed)); let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
if (gain_db - self.current_gains[band]).abs() > 0.01 { if (gain_db - self.current_gains[band]).abs() > 0.01 {
self.current_gains[band] = gain_db; self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0); let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params( if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db), FilterType::PeakingEQ(gain_db),
(self.sample_rate.get() as f32).hz(), (sample_rate.get() as f32).hz(),
freq.hz(), freq.hz(),
EQ_Q, EQ_Q,
) { ) {
@@ -109,19 +109,20 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
impl<S: Source<Item = f32>> Source for EqSource<S> { impl<S: Source<Item = f32>> Source for EqSource<S> {
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() } fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels } fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate } fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() } fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
#[allow(clippy::needless_range_loop)] #[allow(clippy::needless_range_loop)]
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { 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. // Reset biquad filter state to avoid glitches after seek.
for band in 0..10 { for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed)); let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
self.current_gains[band] = gain_db; self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0); let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params( if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db), FilterType::PeakingEQ(gain_db),
(self.sample_rate.get() as f32).hz(), (sample_rate.get() as f32).hz(),
freq.hz(), freq.hz(),
EQ_Q, EQ_Q,
) { ) {
@@ -144,14 +145,12 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
pub(crate) struct DynSource { pub(crate) struct DynSource {
inner: Box<dyn Source<Item = f32> + Send>, inner: Box<dyn Source<Item = f32> + Send>,
channels: rodio::ChannelCount, channels: rodio::ChannelCount,
sample_rate: rodio::SampleRate,
} }
impl DynSource { impl DynSource {
pub(crate) fn new(src: impl Source<Item = f32> + Send + 'static) -> Self { pub(crate) fn new(src: impl Source<Item = f32> + Send + 'static) -> Self {
let channels = src.channels(); let channels = src.channels();
let sample_rate = src.sample_rate(); Self { inner: Box::new(src), channels }
Self { inner: Box::new(src), channels, sample_rate }
} }
} }
@@ -163,7 +162,7 @@ impl Iterator for DynSource {
impl Source for DynSource { impl Source for DynSource {
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() } fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels } fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate } fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() } fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
self.inner.try_seek(pos) self.inner.try_seek(pos)
@@ -11,6 +11,9 @@ use ringbuf::HeapRb;
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use super::engine::{audio_http_client, AudioEngine}; 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::preview::preview_clear_for_new_main_playback;
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY}; 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(); let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink { if let Some(sink) = &cur.sink {
if !sink.is_paused() { 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(); sink.pause();
cur.paused_at = Some(pos); cur.paused_at = Some(pos);
cur.play_started = None; cur.play_started = None;
} }
} }
@@ -125,6 +134,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
#[tauri::command] #[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> { 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_TIMEOUT_MS: u64 = 700;
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40; const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance. // 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. // Seeking back invalidates any pending gapless chain.
let cur_pos = { let cur_pos = content_position_from_samples(
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?; state.samples_played.load(Ordering::Relaxed),
cur.position() state.current_sample_rate.load(Ordering::Relaxed),
}; state.current_channels.load(Ordering::Relaxed),
&state.playback_rate,
);
if seconds < cur_pos - 1.0 { if seconds < cur_pos - 1.0 {
*state.chained_info.lock().unwrap() = None; *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.seek_offset = seek_seconds;
cur.play_started = Some(Instant::now()); 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(()) Ok(())
} }
+1
View File
@@ -520,6 +520,7 @@ pub fn run() {
audio::mix_commands::audio_set_volume, audio::mix_commands::audio_set_volume,
audio::mix_commands::audio_update_replay_gain, audio::mix_commands::audio_update_replay_gain,
audio::mix_commands::audio_set_eq, audio::mix_commands::audio_set_eq,
audio::mix_commands::audio_set_playback_rate,
audio::autoeq_commands::autoeq_entries, audio::autoeq_commands::autoeq_entries,
audio::autoeq_commands::autoeq_fetch_profile, audio::autoeq_commands::autoeq_fetch_profile,
audio::preload_commands::audio_preload, audio::preload_commands::audio_preload,
+5
View File
@@ -53,9 +53,12 @@ import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import '../store/previewPlayerVolumeSync';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { useFontStore } from '../store/fontStore'; import { useFontStore } from '../store/fontStore';
import { useEqStore } from '../store/eqStore'; import { useEqStore } from '../store/eqStore';
import { usePlaybackRateStore } from '../store/playbackRateStore';
import { usePlaybackRateOrbitSync } from '../hooks/usePlaybackRateOrbitSync';
import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { import {
persistSidebarCollapsed, persistSidebarCollapsed,
@@ -78,6 +81,7 @@ export function AppShell() {
useOrbitHost(); useOrbitHost();
useOrbitGuest(); useOrbitGuest();
useOrbitBodyAttrs(); useOrbitBodyAttrs();
usePlaybackRateOrbitSync();
useTrayMenuI18n(); useTrayMenuI18n();
useServerCapabilitiesProbe(); useServerCapabilitiesProbe();
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
@@ -124,6 +128,7 @@ export function AppShell() {
useEffect(() => { useEffect(() => {
useEqStore.getState().syncToRust(); useEqStore.getState().syncToRust();
usePlaybackRateStore.getState().syncToRust();
}, []); }, []);
useEffect(() => { useEffect(() => {
+5
View File
@@ -34,6 +34,7 @@ import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock';
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo'; import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
import { PlayerTransportControls } from './playerBar/PlayerTransportControls'; import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection'; import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection';
import { PlayerPlaybackRate } from './playerBar/PlayerPlaybackRate';
import { PlayerVolume } from './playerBar/PlayerVolume'; import { PlayerVolume } from './playerBar/PlayerVolume';
import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu'; import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu';
import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar'; import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar';
@@ -279,6 +280,10 @@ export default function PlayerBar() {
</div> </div>
) : ( ) : (
<> <>
{isLayoutVisible('playbackRate') && (
<PlayerPlaybackRate t={t} />
)}
{isLayoutVisible('equalizer') && ( {isLayoutVisible('equalizer') && (
<button <button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`} className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
@@ -4,6 +4,7 @@ import { PictureInPicture2, SlidersVertical } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { PlayerVolume } from './PlayerVolume'; import { PlayerVolume } from './PlayerVolume';
import { PlayerPlaybackRateMenuSection } from './PlayerPlaybackRate';
import { import {
usePlayerBarLayoutStore, usePlayerBarLayoutStore,
type PlayerBarLayoutItemId, type PlayerBarLayoutItemId,
@@ -38,6 +39,7 @@ export function PlayerOverflowMenu({
layoutItems.find(i => i.id === id)?.visible !== false; layoutItems.find(i => i.id === id)?.visible !== false;
const showEqualizer = isLayoutVisible('equalizer'); const showEqualizer = isLayoutVisible('equalizer');
const showMiniPlayer = isLayoutVisible('miniPlayer'); const showMiniPlayer = isLayoutVisible('miniPlayer');
const showPlaybackRate = isLayoutVisible('playbackRate');
return createPortal( return createPortal(
<div <div
className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`} className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`}
@@ -73,6 +75,9 @@ export function PlayerOverflowMenu({
)} )}
</div> </div>
)} )}
{utilityMenuMode === 'full' && showPlaybackRate && (
<PlayerPlaybackRateMenuSection t={t} />
)}
<PlayerVolume <PlayerVolume
volume={volume} volume={volume}
setVolume={setVolume} setVolume={setVolume}
@@ -0,0 +1,81 @@
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { TFunction } from 'i18next';
import { PlaybackRateControls } from '../settings/audio/PlaybackRateBlock';
import { usePlaybackRateStore } from '../../store/playbackRateStore';
import { useOrbitStore } from '../../store/orbitStore';
import {
PLAYBACK_SPEED_STEP,
clampPlaybackSpeed,
formatSpeedLabel,
isPlaybackRateApplied,
} from '../../utils/audio/playbackRateHelpers';
import { isOrbitPlaybackSyncActive } from '../../utils/orbit';
import { usePlayerBarAnchoredPopover } from '../../hooks/usePlayerBarAnchoredPopover';
const POPOVER_WIDTH = 320;
interface Props {
t: TFunction;
}
export function PlayerPlaybackRateMenuSection({ t }: Props) {
const enabled = usePlaybackRateStore(s => s.enabled);
if (!enabled) return null;
return (
<div className="player-playback-rate-menu-section">
<PlaybackRateControls t={t} showEnable={false} />
</div>
);
}
export function PlayerPlaybackRate({ t }: Props) {
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 setSpeed = usePlaybackRateStore(s => s.setSpeed);
const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase);
const { open, setOpen, popStyle, btnRef, popRef } = usePlayerBarAnchoredPopover(POPOVER_WIDTH);
const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase);
const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive);
const handleWheel = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!enabled) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -PLAYBACK_SPEED_STEP : PLAYBACK_SPEED_STEP;
setSpeed(clampPlaybackSpeed(speed + delta));
}, [enabled, speed, setSpeed]);
if (!enabled) return null;
return (
<>
<button
ref={btnRef}
type="button"
className={`player-btn player-btn-sm player-playback-rate-btn${open ? ' active' : ''}${effectActive ? ' player-playback-rate-btn--live' : ''}`}
onClick={() => setOpen(v => !v)}
onWheel={handleWheel}
aria-label={t('player.playbackRate')}
aria-expanded={open}
data-tooltip={t('player.playbackRate')}
>
{formatSpeedLabel(speed)}
</button>
{open && createPortal(
<div
ref={popRef}
className="player-playback-rate-popover"
style={popStyle}
>
<PlaybackRateControls t={t} showEnable={false} />
</div>,
document.body,
)}
</>
);
}
+12
View File
@@ -8,7 +8,9 @@ import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/lou
import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe'; import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe';
import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection'; import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection';
import { NormalizationBlock } from './audio/NormalizationBlock'; import { NormalizationBlock } from './audio/NormalizationBlock';
import { Gauge } from 'lucide-react';
import { PlaybackBehaviorBlock } from './audio/PlaybackBehaviorBlock'; import { PlaybackBehaviorBlock } from './audio/PlaybackBehaviorBlock';
import { PlaybackRateBlock } from './audio/PlaybackRateBlock';
import { TrackPreviewsSection } from './audio/TrackPreviewsSection'; import { TrackPreviewsSection } from './audio/TrackPreviewsSection';
export function AudioTab() { export function AudioTab() {
@@ -77,6 +79,16 @@ export function AudioTab() {
</div> </div>
</SettingsSubSection> </SettingsSubSection>
{/* Playback speed */}
<SettingsSubSection
title={t('settings.playbackRateTitle')}
icon={<Gauge size={16} />}
>
<div className="settings-card">
<PlaybackRateBlock t={t} />
</div>
</SettingsSubSection>
{/* Replay Gain + Crossfade + Gapless */} {/* Replay Gain + Crossfade + Gapless */}
<SettingsSubSection <SettingsSubSection
title={t('settings.playbackTitle')} title={t('settings.playbackTitle')}
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react'; import { Gauge, Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react';
import LastfmIcon from '../LastfmIcon'; import LastfmIcon from '../LastfmIcon';
import { import {
usePlayerBarLayoutStore, usePlayerBarLayoutStore,
@@ -11,6 +11,7 @@ const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
starRating: 'settings.playerBarStarRating', starRating: 'settings.playerBarStarRating',
favorite: 'settings.playerBarFavorite', favorite: 'settings.playerBarFavorite',
lastfmLove: 'settings.playerBarLastfmLove', lastfmLove: 'settings.playerBarLastfmLove',
playbackRate: 'settings.playerBarPlaybackRate',
equalizer: 'settings.playerBarEqualizer', equalizer: 'settings.playerBarEqualizer',
miniPlayer: 'settings.playerBarMiniPlayer', miniPlayer: 'settings.playerBarMiniPlayer',
}; };
@@ -23,6 +24,7 @@ const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> =
<LastfmIcon size={16} /> <LastfmIcon size={16} />
</span> </span>
), ),
playbackRate: <Gauge size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
equalizer: <SlidersVertical size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />, equalizer: <SlidersVertical size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
miniPlayer: <PictureInPicture2 size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />, miniPlayer: <PictureInPicture2 size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
}; };
@@ -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<HTMLElement>) => {
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<HTMLElement>) => {
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 (
<div
className={`playback-rate-controls${compact ? ' playback-rate-controls--compact' : ''}`}
onWheel={compact ? handleWheelSpeed : undefined}
>
{showEnable && (
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.playbackRateEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateEnabledDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.playbackRateEnabled')}>
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
)}
{(!showEnable || enabled) && (
<>
{showEnable && <div className="divider" />}
<div className="playback-rate-strategy-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRateStrategy')}</span>
)}
<div className="playback-rate-strategy-btns">
{PLAYBACK_STRATEGIES.map(s => (
<button
key={s}
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setStrategy(s)}
>
{strategyLabel(s)}
</button>
))}
</div>
</div>
<div className="playback-rate-slider-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRateSpeed')}</span>
)}
<input
type="range"
min={PLAYBACK_SPEED_MIN}
max={PLAYBACK_SPEED_MAX}
step={PLAYBACK_SPEED_STEP}
value={speed}
onChange={e => setSpeed(parseFloat(e.target.value))}
className="playback-rate-slider"
aria-label={t('settings.playbackRateSpeed')}
/>
<span className="playback-rate-value">{formatSpeedLabel(speed)}</span>
</div>
<div className="playback-rate-presets">
{PLAYBACK_SPEED_PRESETS.map(preset => (
<button
key={preset}
type="button"
className={`btn btn-sm ${Math.abs(speed - preset) < 0.001 ? 'btn-primary' : 'btn-surface'}`}
onClick={() => applyPresetSpeed(preset)}
>
{formatSpeedLabel(preset)}
</button>
))}
</div>
{strategy === 'varispeed' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateDerivedPitch', {
value: formatPitchLabel(derivedPitch),
})}
</div>
)}
{strategy === 'speed_corrected' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateAutoPitch')}
</div>
)}
{strategy === 'preserve_pitch' && (
<div className="playback-rate-slider-row" onWheel={compact ? handleWheelPitch : undefined}>
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRatePitch')}</span>
)}
<input
type="range"
min={PLAYBACK_PITCH_MIN}
max={PLAYBACK_PITCH_MAX}
step={PLAYBACK_PITCH_STEP}
value={pitchSemitones}
onChange={e => setPitchSemitones(parseFloat(e.target.value))}
className="playback-rate-slider"
aria-label={t('settings.playbackRatePitch')}
/>
<span className="playback-rate-value">{formatPitchLabel(pitchSemitones)}</span>
</div>
)}
{!compact && (
<p className="playback-rate-hint" style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
{t('settings.playbackRateHint')}
</p>
)}
{orbitActive && enabled && (
<p className="playback-rate-orbit" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t(compact ? 'settings.playbackRateOrbitPausedShort' : 'settings.playbackRateOrbitPaused')}
</p>
)}
{!compact && !effectActive && enabled && !orbitActive && (
<p className="playback-rate-neutral" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('settings.playbackRateNeutral')}
</p>
)}
</>
)}
</div>
);
}
export function PlaybackRateBlock({ t }: { t: TFunction }) {
return <PlaybackRateControls t={t} showEnable />;
}
+1
View File
@@ -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.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.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.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: '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.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
{ tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' }, { tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
+1
View File
@@ -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)', '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)', '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)', 'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)',
'Playback speed: global 0.52.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)',
], ],
}, },
{ {
+13
View File
@@ -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]);
}
+63
View File
@@ -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<React.CSSProperties>({});
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(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 };
}
+2 -2
View File
@@ -76,7 +76,7 @@ export function useUtilityOverflowMenu(
useEffect(() => { useEffect(() => {
if (!utilityMenuOpen) return; if (!utilityMenuOpen) return;
const MENU_WIDTH = 238; const MENU_WIDTH = utilityMenuMode === 'volume' ? 238 : 320;
const MARGIN = 8; const MARGIN = 8;
const updateMenuPos = () => { const updateMenuPos = () => {
const btn = utilityBtnRef.current; const btn = utilityBtnRef.current;
@@ -101,7 +101,7 @@ export function useUtilityOverflowMenu(
window.removeEventListener('resize', updateMenuPos); window.removeEventListener('resize', updateMenuPos);
window.removeEventListener('scroll', updateMenuPos, true); window.removeEventListener('scroll', updateMenuPos, true);
}; };
}, [utilityMenuOpen]); }, [utilityMenuOpen, utilityMenuMode]);
return { return {
utilityOverflow, utilityOverflow,
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Warteschlange einklappen, Breite ändern', collapseQueueResize: 'Warteschlange einklappen, Breite ändern',
moreOptions: 'Weitere Optionen', moreOptions: 'Weitere Optionen',
equalizer: 'Equalizer', equalizer: 'Equalizer',
playbackRate: 'Geschwindigkeit',
miniPlayer: 'Mini-Player', miniPlayer: 'Mini-Player',
lyrics: 'Lyrics', lyrics: 'Lyrics',
fsLyricsToggle: 'Lyrics im Vollbild', fsLyricsToggle: 'Lyrics im Vollbild',
+16
View File
@@ -326,8 +326,24 @@ export const settings = {
playerBarStarRating: 'Sterne-Bewertung', playerBarStarRating: 'Sterne-Bewertung',
playerBarFavorite: 'Favorit (Herz)', playerBarFavorite: 'Favorit (Herz)',
playerBarLastfmLove: 'Last.fm Love', playerBarLastfmLove: 'Last.fm Love',
playerBarPlaybackRate: 'Wiedergabegeschwindigkeit',
playerBarEqualizer: 'Equalizer', playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Mini-Player', 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', advancedMode: 'Erweitert',
advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.', advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.',
advancedBadge: 'Erweitert', advancedBadge: 'Erweitert',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Collapse queue, resize', collapseQueueResize: 'Collapse queue, resize',
moreOptions: 'More options', moreOptions: 'More options',
equalizer: 'Equalizer', equalizer: 'Equalizer',
playbackRate: 'Playback speed',
miniPlayer: 'Mini Player', miniPlayer: 'Mini Player',
lyrics: 'Lyrics', lyrics: 'Lyrics',
fsLyricsToggle: 'Lyrics in fullscreen', fsLyricsToggle: 'Lyrics in fullscreen',
+16
View File
@@ -329,8 +329,24 @@ export const settings = {
playerBarStarRating: 'Star rating', playerBarStarRating: 'Star rating',
playerBarFavorite: 'Favorite (heart)', playerBarFavorite: 'Favorite (heart)',
playerBarLastfmLove: 'Last.fm love', playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Playback speed',
playerBarEqualizer: 'Equalizer', playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Mini player', 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', advancedMode: 'Advanced',
advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.', advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.',
advancedBadge: 'Advanced', advancedBadge: 'Advanced',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Contraer cola, cambiar ancho', collapseQueueResize: 'Contraer cola, cambiar ancho',
moreOptions: 'Más opciones', moreOptions: 'Más opciones',
equalizer: 'Ecualizador', equalizer: 'Ecualizador',
playbackRate: 'Velocidad',
miniPlayer: 'Mini reproductor', miniPlayer: 'Mini reproductor',
lyrics: 'Letras', lyrics: 'Letras',
fsLyricsToggle: 'Letras en pantalla completa', fsLyricsToggle: 'Letras en pantalla completa',
+16
View File
@@ -325,8 +325,24 @@ export const settings = {
playerBarStarRating: 'Calificación con estrellas', playerBarStarRating: 'Calificación con estrellas',
playerBarFavorite: 'Favorito (corazón)', playerBarFavorite: 'Favorito (corazón)',
playerBarLastfmLove: 'Last.fm love', playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Velocidad de reproducción',
playerBarEqualizer: 'Ecualizador', playerBarEqualizer: 'Ecualizador',
playerBarMiniPlayer: 'Mini reproductor', 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', 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.', 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', advancedBadge: 'Avanzado',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Réduire la file, redimensionner', collapseQueueResize: 'Réduire la file, redimensionner',
moreOptions: 'Plus doptions', moreOptions: 'Plus doptions',
equalizer: 'Égaliseur', equalizer: 'Égaliseur',
playbackRate: 'Vitesse',
miniPlayer: 'Mini-lecteur', miniPlayer: 'Mini-lecteur',
lyrics: 'Paroles', lyrics: 'Paroles',
fsLyricsToggle: 'Paroles en plein écran', fsLyricsToggle: 'Paroles en plein écran',
+16
View File
@@ -323,8 +323,24 @@ export const settings = {
playerBarStarRating: 'Note par étoiles', playerBarStarRating: 'Note par étoiles',
playerBarFavorite: 'Favori (cœur)', playerBarFavorite: 'Favori (cœur)',
playerBarLastfmLove: 'Last.fm love', playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Vitesse de lecture',
playerBarEqualizer: 'Égaliseur', playerBarEqualizer: 'Égaliseur',
playerBarMiniPlayer: 'Mini-lecteur', 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é', 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.', 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é', advancedBadge: 'Avancé',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Skjul kø, endre bredde', collapseQueueResize: 'Skjul kø, endre bredde',
moreOptions: 'Flere alternativer', moreOptions: 'Flere alternativer',
equalizer: 'Equalizer', equalizer: 'Equalizer',
playbackRate: 'Hastighet',
miniPlayer: 'Minispiller', miniPlayer: 'Minispiller',
lyrics: 'Sangtekst', lyrics: 'Sangtekst',
fsLyricsToggle: 'Sangtekst i fullskjerm', fsLyricsToggle: 'Sangtekst i fullskjerm',
+16
View File
@@ -322,8 +322,24 @@ export const settings = {
playerBarStarRating: 'Stjernevurdering', playerBarStarRating: 'Stjernevurdering',
playerBarFavorite: 'Favoritt (hjerte)', playerBarFavorite: 'Favoritt (hjerte)',
playerBarLastfmLove: 'Last.fm love', playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Avspillingshastighet',
playerBarEqualizer: 'Equalizer', playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Miniavspiller', 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', advancedMode: 'Avansert',
advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.', advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.',
advancedBadge: 'Avansert', advancedBadge: 'Avansert',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Wachtrij inklappen, breedte wijzigen', collapseQueueResize: 'Wachtrij inklappen, breedte wijzigen',
moreOptions: 'Meer opties', moreOptions: 'Meer opties',
equalizer: 'Equalizer', equalizer: 'Equalizer',
playbackRate: 'Snelheid',
miniPlayer: 'Minispeler', miniPlayer: 'Minispeler',
lyrics: 'Songtekst', lyrics: 'Songtekst',
fsLyricsToggle: 'Songtekst in volledig scherm', fsLyricsToggle: 'Songtekst in volledig scherm',
+16
View File
@@ -323,8 +323,24 @@ export const settings = {
playerBarStarRating: 'Sterbeoordeling', playerBarStarRating: 'Sterbeoordeling',
playerBarFavorite: 'Favoriet (hart)', playerBarFavorite: 'Favoriet (hart)',
playerBarLastfmLove: 'Last.fm love', playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Afspeelsnelheid',
playerBarEqualizer: 'Equalizer', playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Mini-speler', 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', advancedMode: 'Geavanceerd',
advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.', advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.',
advancedBadge: 'Geavanceerd', advancedBadge: 'Geavanceerd',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Strânge coada, redimensionează', collapseQueueResize: 'Strânge coada, redimensionează',
moreOptions: 'Mai multe opțiuni', moreOptions: 'Mai multe opțiuni',
equalizer: 'Egalizator', equalizer: 'Egalizator',
playbackRate: 'Viteză',
miniPlayer: 'Player Mini', miniPlayer: 'Player Mini',
lyrics: 'Versuri', lyrics: 'Versuri',
fsLyricsToggle: 'Versuri în ecran complet', fsLyricsToggle: 'Versuri în ecran complet',
+16
View File
@@ -328,8 +328,24 @@ export const settings = {
playerBarStarRating: 'Evaluare cu stele', playerBarStarRating: 'Evaluare cu stele',
playerBarFavorite: 'Favorit (inimă)', playerBarFavorite: 'Favorit (inimă)',
playerBarLastfmLove: 'Last.fm love', playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Viteză redare',
playerBarEqualizer: 'Egalizator', playerBarEqualizer: 'Egalizator',
playerBarMiniPlayer: 'Mini player', 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', 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.', 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', advancedBadge: 'Avansat',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: 'Свернуть очередь, изменить ширину', collapseQueueResize: 'Свернуть очередь, изменить ширину',
moreOptions: 'Дополнительно', moreOptions: 'Дополнительно',
equalizer: 'Эквалайзер', equalizer: 'Эквалайзер',
playbackRate: 'Скорость',
miniPlayer: 'Мини-плеер', miniPlayer: 'Мини-плеер',
lyrics: 'Текст', lyrics: 'Текст',
lyricsLoading: 'Загрузка текста…', lyricsLoading: 'Загрузка текста…',
+16
View File
@@ -339,8 +339,24 @@ export const settings = {
playerBarStarRating: 'Оценка звёздами', playerBarStarRating: 'Оценка звёздами',
playerBarFavorite: 'Избранное (сердечко)', playerBarFavorite: 'Избранное (сердечко)',
playerBarLastfmLove: 'Last.fm love', playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Скорость',
playerBarEqualizer: 'Эквалайзер', playerBarEqualizer: 'Эквалайзер',
playerBarMiniPlayer: 'Мини-плеер', 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: 'Дополнительно', advancedMode: 'Дополнительно',
advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.', advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.',
advancedBadge: 'Дополнительно', advancedBadge: 'Дополнительно',
+1
View File
@@ -45,6 +45,7 @@ export const player = {
collapseQueueResize: '收起队列,调整宽度', collapseQueueResize: '收起队列,调整宽度',
moreOptions: '更多选项', moreOptions: '更多选项',
equalizer: '均衡器', equalizer: '均衡器',
playbackRate: '速度',
miniPlayer: '迷你播放器', miniPlayer: '迷你播放器',
lyrics: '歌词', lyrics: '歌词',
fsLyricsToggle: '全屏歌词', fsLyricsToggle: '全屏歌词',
+16
View File
@@ -322,8 +322,24 @@ export const settings = {
playerBarStarRating: '星级评分', playerBarStarRating: '星级评分',
playerBarFavorite: '收藏(心形)', playerBarFavorite: '收藏(心形)',
playerBarLastfmLove: 'Last.fm 喜欢', playerBarLastfmLove: 'Last.fm 喜欢',
playerBarPlaybackRate: '播放速度',
playerBarEqualizer: '均衡器', playerBarEqualizer: '均衡器',
playerBarMiniPlayer: '迷你播放器', playerBarMiniPlayer: '迷你播放器',
playbackRateTitle: '播放速度',
playbackRateEnabled: '启用播放速度',
playbackRateEnabledDesc: '全局调整所有曲目的播放速度。不适用于电台和预览。',
playbackRateStrategy: '策略',
playbackRateStrategySpeed: '速度',
playbackRateStrategyVarispeed: '随速变调',
playbackRateStrategyPreserve: '音高',
playbackRateSpeed: '速度',
playbackRatePitch: '音高',
playbackRateDerivedPitch: '速度引起的音高偏移:{{value}}',
playbackRateAutoPitch: '音高会自动校正。',
playbackRateHint: '「速度」保持音高不变。「随速变调」会随速度改变音高。「音高」可手动偏移。比「随速变调」占用更多 CPU。不适用于电台、预览或 Orbit。',
playbackRateNeutral: '在 1.0× 且无音高偏移时,播放为正常速度。',
playbackRateOrbitPaused: 'Orbit 会话期间不生效 — 播放保持 1.0× 以同步。',
playbackRateOrbitPausedShort: 'Orbit1.0× 同步。',
advancedMode: '高级', advancedMode: '高级',
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。', advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
advancedBadge: '高级', advancedBadge: '高级',
+3 -5
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { onInvoke } from '../test/mocks/tauri'; import { onInvoke } from '../test/mocks/tauri';
import { useLibraryIndexStore } from './libraryIndexStore'; import { useLibraryIndexStore } from './libraryIndexStore';
import { usePlayerStore } from './playerStore';
import { usePreviewStore } from './previewStore';
import { import {
_resetPlayListenSessionForTest, _resetPlayListenSessionForTest,
playListenSessionFinalize, playListenSessionFinalize,
@@ -31,14 +33,12 @@ const testTrack: Track = {
}; };
describe('playListenSession', () => { describe('playListenSession', () => {
beforeEach(async () => { beforeEach(() => {
_resetPlayListenSessionForTest(); _resetPlayListenSessionForTest();
useLibraryIndexStore.setState({ useLibraryIndexStore.setState({
masterEnabled: true, masterEnabled: true,
syncExcludedByServer: {}, syncExcludedByServer: {},
}); });
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
usePlayerStore.setState({ usePlayerStore.setState({
currentRadio: null, currentRadio: null,
isPlaying: true, isPlaying: true,
@@ -76,7 +76,6 @@ describe('playListenSession', () => {
}); });
it('picks up engine duration from progress ticks', async () => { it('picks up engine duration from progress ticks', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({ usePlayerStore.setState({
currentTrack: { ...testTrack, duration: 0 }, currentTrack: { ...testTrack, duration: 0 },
}); });
@@ -118,7 +117,6 @@ describe('playListenSession', () => {
}); });
it('skips radio playback', async () => { it('skips radio playback', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({ usePlayerStore.setState({
currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' }, currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' },
}); });
+4 -6
View File
@@ -1,4 +1,6 @@
import type { Track } from './playerStoreTypes'; import type { Track } from './playerStoreTypes';
import { usePlayerStore } from './playerStore';
import { usePreviewStore } from './previewStore';
import { import {
libraryRecordPlaySession, libraryRecordPlaySession,
type PlaySessionEndReason, type PlaySessionEndReason,
@@ -48,9 +50,7 @@ function noteDurationHint(durationSec?: number): void {
} }
} }
async function playerGateBlocks(): Promise<boolean> { function playerGateBlocks(): boolean {
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
if (usePreviewStore.getState().previewingId) return true; if (usePreviewStore.getState().previewingId) return true;
if (usePlayerStore.getState().currentRadio) return true; if (usePlayerStore.getState().currentRadio) return true;
return false; return false;
@@ -59,7 +59,7 @@ async function playerGateBlocks(): Promise<boolean> {
async function recordingEnabledForServer(serverId: string): Promise<boolean> { async function recordingEnabledForServer(serverId: string): Promise<boolean> {
if (!serverId) return false; if (!serverId) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false; if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
if (await playerGateBlocks()) return false; if (playerGateBlocks()) return false;
return libraryIsReady(serverId); return libraryIsReady(serverId);
} }
@@ -94,7 +94,6 @@ export async function playListenSessionOnProgress(
): Promise<void> { ): Promise<void> {
if (!open?.recordingEnabled) return; if (!open?.recordingEnabled) return;
noteDurationHint(durationSecHint); noteDurationHint(durationSecHint);
const { usePlayerStore } = await import('./playerStore');
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const track = store.currentTrack; const track = store.currentTrack;
if (track?.id === open.trackId) { if (track?.id === open.trackId) {
@@ -126,7 +125,6 @@ export async function playListenSessionFinalize(reason: PlaySessionEndReason): P
return; return;
} }
const { usePlayerStore } = await import('./playerStore');
const track = usePlayerStore.getState().currentTrack; const track = usePlayerStore.getState().currentTrack;
const durationSecHint = resolveDurationSecHint( const durationSecHint = resolveDurationSecHint(
track?.id === session.trackId ? track : null, track?.id === session.trackId ? track : null,
+99
View File
@@ -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<PlaybackRateState>()(
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,
}),
},
),
);
+2 -2
View File
@@ -9,10 +9,10 @@ describe('playerBarLayoutStore', () => {
usePlayerBarLayoutStore.getState().reset(); 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; const items = usePlayerBarLayoutStore.getState().items;
expect(items.map(i => i.id)).toEqual([ 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); expect(items.every(i => i.visible)).toBe(true);
}); });
+2
View File
@@ -5,6 +5,7 @@ export type PlayerBarLayoutItemId =
| 'starRating' | 'starRating'
| 'favorite' | 'favorite'
| 'lastfmLove' | 'lastfmLove'
| 'playbackRate'
| 'equalizer' | 'equalizer'
| 'miniPlayer'; | 'miniPlayer';
@@ -17,6 +18,7 @@ export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
{ id: 'starRating', visible: true }, { id: 'starRating', visible: true },
{ id: 'favorite', visible: true }, { id: 'favorite', visible: true },
{ id: 'lastfmLove', visible: true }, { id: 'lastfmLove', visible: true },
{ id: 'playbackRate', visible: true },
{ id: 'equalizer', visible: true }, { id: 'equalizer', visible: true },
{ id: 'miniPlayer', visible: true }, { id: 'miniPlayer', visible: true },
]; ];
-1
View File
@@ -127,4 +127,3 @@ usePlayerStore.subscribe((state, prev) => {
buffered: state.buffered, buffered: state.buffered,
}); });
}); });
+13
View File
@@ -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(() => {});
});
+1
View File
@@ -30,6 +30,7 @@ import { usePreviewStore } from './previewStore';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useOrbitStore } from './orbitStore'; import { useOrbitStore } from './orbitStore';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import './previewPlayerVolumeSync';
import { onInvoke, invokeMock } from '@/test/mocks/tauri'; import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset'; import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset';
+3 -17
View File
@@ -4,7 +4,7 @@ import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore'; 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. */ /** Minimal track info needed to surface the preview in the player bar UI. */
export interface PreviewingTrack { 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 * preview-specific gain, so we pre-multiply here). Master headroom is added on
* the Rust side. * the Rust side.
*/ */
function computePreviewVolume(): number { export function computePreviewVolume(): number {
const auth = useAuthStore.getState(); const auth = useAuthStore.getState();
let volume = usePlayerStore.getState().volume; let volume = usePlayerStore.getState().volume;
if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') { if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') {
@@ -82,10 +82,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
// preview started by a guest would yank the host's track out from // preview started by a guest would yank the host's track out from
// under them. UI buttons are hidden via `[data-orbit-active]` CSS; // under them. UI buttons are hidden via `[data-orbit-active]` CSS;
// this guards keyboard shortcuts / programmatic callers. // this guards keyboard shortcuts / programmatic callers.
const orbit = useOrbitStore.getState(); if (isOrbitPlaybackSyncActive()) return;
const inOrbit = (orbit.role === 'host' || orbit.role === 'guest')
&& (orbit.phase === 'active' || orbit.phase === 'joining' || orbit.phase === 'starting');
if (inOrbit) return;
const current = get().previewingId; const current = get().previewingId;
if (current === song.id) { if (current === song.id) {
@@ -158,14 +155,3 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false }); 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(() => {});
});
+1
View File
@@ -40,6 +40,7 @@
@import './now-playing-page-2.css'; @import './now-playing-page-2.css';
@import './radio-nowplaying-section.css'; @import './radio-nowplaying-section.css';
@import './equalizer.css'; @import './equalizer.css';
@import './playback-rate.css';
@import './custom-select.css'; @import './custom-select.css';
@import './changelog.css'; @import './changelog.css';
@import './genre-filter-bar.css'; @import './genre-filter-bar.css';
+93
View File
@@ -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);
}
@@ -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);
});
});
+74
View File
@@ -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`;
}
@@ -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);
});
});
+70
View File
@@ -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);
}
+1
View File
@@ -31,6 +31,7 @@ export {
serialiseOrbitState, serialiseOrbitState,
suggestionKey, suggestionKey,
} from './orbit/helpers'; } from './orbit/helpers';
export { isOrbitPlaybackSyncActive } from './orbit/sessionActive';
export { export {
applyOutboxSnapshotsToState, applyOutboxSnapshotsToState,
computeOrbitDriftMs, computeOrbitDriftMs,
+14
View File
@@ -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';
}