mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user