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

* feat(playback): global speed with three strategies

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

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

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

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

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

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

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

* fix(clippy): simplify raw_counter_samples branch for CI

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

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

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

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

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

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

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

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

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
cucadmuh
2026-05-22 19:59:28 +03:00
committed by GitHub
parent d54eceaf3b
commit e8e41752a7
64 changed files with 2405 additions and 70 deletions
+99
View File
@@ -0,0 +1,99 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import {
clampPlaybackPitch,
clampPlaybackSpeed,
DEFAULT_PLAYBACK_STRATEGY,
effectivePlaybackPitch,
type PlaybackStrategy,
} from '../utils/audio/playbackRateHelpers';
import {
restartPlaybackForRateChange,
shouldRestartPlaybackForRateChange,
type PlaybackRateSnapshot,
} from '../utils/audio/playbackRateRestart';
import { isOrbitPlaybackSyncActive } from '../utils/orbit';
interface PlaybackRateState extends PlaybackRateSnapshot {
setEnabled: (v: boolean) => void;
setStrategy: (s: PlaybackStrategy) => void;
setSpeed: (speed: number) => void;
setPitchSemitones: (semitones: number) => void;
applyPresetSpeed: (speed: number) => void;
syncToRust: () => void;
}
function syncPlaybackRate(state: PlaybackRateSnapshot, prev?: PlaybackRateSnapshot) {
// Orbit sync assumes 1.0× wall-clock playback; suppress DSP without mutating prefs.
const effectiveEnabled = state.enabled && !isOrbitPlaybackSyncActive();
invoke('audio_set_playback_rate', {
enabled: effectiveEnabled,
strategy: state.strategy,
speed: state.speed,
pitchSemitones: effectivePlaybackPitch(state.strategy, state.pitchSemitones),
}).catch(() => {});
if (prev && shouldRestartPlaybackForRateChange(prev, state)) {
restartPlaybackForRateChange();
}
}
export const usePlaybackRateStore = create<PlaybackRateState>()(
persist(
(set, get) => ({
enabled: false,
strategy: DEFAULT_PLAYBACK_STRATEGY,
speed: 1.0,
pitchSemitones: 0,
setEnabled: (v) => {
const prev = get();
set({ enabled: v });
syncPlaybackRate(get(), prev);
},
setStrategy: (strategy) => {
const prev = get();
set({ strategy });
syncPlaybackRate(get(), prev);
},
setSpeed: (speed) => {
const prev = get();
const clamped = clampPlaybackSpeed(speed);
set({ speed: clamped });
syncPlaybackRate(get(), prev);
},
setPitchSemitones: (semitones) => {
const prev = get();
const clamped = clampPlaybackPitch(semitones);
set({ pitchSemitones: clamped });
syncPlaybackRate(get(), prev);
},
applyPresetSpeed: (speed) => {
const prev = get();
const clamped = clampPlaybackSpeed(speed);
set({ speed: clamped });
syncPlaybackRate(get(), prev);
},
syncToRust: () => {
syncPlaybackRate(get());
},
}),
{
name: 'psysonic-playback-rate',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({
enabled: s.enabled,
strategy: s.strategy,
speed: s.speed,
pitchSemitones: s.pitchSemitones,
}),
},
),
);