mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(playback): global speed with three strategies (#852)
* feat(playback): global speed with three strategies Add Settings → Audio and player-bar controls for global playback speed (speed with auto pitch correction as default, varispeed, manual pitch shift). Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough. * fix(playback): align seekbar, seek, and progress on content timeline Unify UI timebase across varispeed and preserve strategies: full-track duration, speed-scaled progress for DSP paths, and content-timeline seeks without varispeed scaling. Reset the sample counter after seek so clicks land correctly; restart playback on strategy/enable changes instead of fragile hot-switching. * fix(ui): anchor playback speed popover like volume controls Replace the centered EQ-style modal with a player-bar popover (outside click, Escape, reposition on scroll). Show compact controls in the bar and overflow menu; keep strategy hints and labels in Settings only. * docs(release): CHANGELOG and credits for playback speed (PR #852) * docs(changelog): add playback speed entry for PR #852 * fix(clippy): simplify raw_counter_samples branch for CI Collapse duplicate if branches flagged by clippy::if-same-then-else. * fix(ui): wheel on pitch slider adjusts pitch in speed popover In compact player-bar controls, scroll over the pitch row changes pitch; elsewhere in the panel changes speed. Stop propagation so overflow menu wheel does not tweak volume. * fix(playback): address PR #852 review and drop ineffective dynamic imports Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample counter on live preserve-path speed changes; use neutral rate atomics for radio progress; static-import playerStore in playListenSession (move preview volume sync to previewPlayerVolumeSync side-effect module). * fix(i18n): translate playback-rate strategy labels in all locales Replace leftover English Varispeed/Pitch strings in ru and other non-en settings blocks so popover strategy buttons and hints read natively. * fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo" --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Playback speed — global tempo and pitch strategies
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#852](https://github.com/Psychotoxical/psysonic/pull/852)**
|
||||||
|
|
||||||
|
* **Settings → Audio** and player bar: global speed **0.5–2.0×** with three strategies — **Speed** (pitch-corrected, default), **Varispeed**, and **Pitch shift** (manual semitone offset).
|
||||||
|
* Rust path: preserve-pitch worker (`pitch_shift`) for Speed / Pitch shift; varispeed via sample-rate scaling; seek-restart on strategy or enable changes.
|
||||||
|
* Unified content timeline for seek bar, elapsed time, and seek; player bar popover anchored like volume (compact controls; full hints in Settings).
|
||||||
|
* **Orbit:** passthrough **1.0×** while a session is active. Not applied to radio or preview.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Servers — edit existing profiles
|
### 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)**
|
||||||
|
|||||||
Generated
+40
@@ -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(),
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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.5–2.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const player = {
|
|||||||
collapseQueueResize: 'Réduire la file, redimensionner',
|
collapseQueueResize: 'Réduire la file, redimensionner',
|
||||||
moreOptions: 'Plus d’options',
|
moreOptions: 'Plus d’options',
|
||||||
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',
|
||||||
|
|||||||
@@ -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é',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const player = {
|
|||||||
collapseQueueResize: 'Свернуть очередь, изменить ширину',
|
collapseQueueResize: 'Свернуть очередь, изменить ширину',
|
||||||
moreOptions: 'Дополнительно',
|
moreOptions: 'Дополнительно',
|
||||||
equalizer: 'Эквалайзер',
|
equalizer: 'Эквалайзер',
|
||||||
|
playbackRate: 'Скорость',
|
||||||
miniPlayer: 'Мини-плеер',
|
miniPlayer: 'Мини-плеер',
|
||||||
lyrics: 'Текст',
|
lyrics: 'Текст',
|
||||||
lyricsLoading: 'Загрузка текста…',
|
lyricsLoading: 'Загрузка текста…',
|
||||||
|
|||||||
@@ -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: 'Дополнительно',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const player = {
|
|||||||
collapseQueueResize: '收起队列,调整宽度',
|
collapseQueueResize: '收起队列,调整宽度',
|
||||||
moreOptions: '更多选项',
|
moreOptions: '更多选项',
|
||||||
equalizer: '均衡器',
|
equalizer: '均衡器',
|
||||||
|
playbackRate: '速度',
|
||||||
miniPlayer: '迷你播放器',
|
miniPlayer: '迷你播放器',
|
||||||
lyrics: '歌词',
|
lyrics: '歌词',
|
||||||
fsLyricsToggle: '全屏歌词',
|
fsLyricsToggle: '全屏歌词',
|
||||||
|
|||||||
@@ -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: 'Orbit:1.0× 同步。',
|
||||||
advancedMode: '高级',
|
advancedMode: '高级',
|
||||||
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
|
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
|
||||||
advancedBadge: '高级',
|
advancedBadge: '高级',
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -127,4 +127,3 @@ usePlayerStore.subscribe((state, prev) => {
|
|||||||
buffered: state.buffered,
|
buffered: state.buffered,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user