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
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import {
clampPlaybackSpeed,
isPlaybackEffectActive,
isPlaybackRateApplied,
derivedVarispeedSemitones,
} from './playbackRateHelpers';
describe('playbackRateHelpers', () => {
it('is inactive when disabled', () => {
expect(isPlaybackEffectActive(false, 'speed_corrected', 1.5, 0)).toBe(false);
});
it('is inactive at 1.0x and 0 pitch when enabled', () => {
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 0)).toBe(false);
});
it('is active when speed differs from 1', () => {
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.25, 0)).toBe(true);
});
it('preserve_pitch is active at 1.0x with pitch offset', () => {
expect(isPlaybackEffectActive(true, 'preserve_pitch', 1.0, 2)).toBe(true);
});
it('speed_corrected ignores stored pitch at 1.0x', () => {
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 2)).toBe(false);
});
it('clamps speed', () => {
expect(clampPlaybackSpeed(3)).toBe(2);
expect(clampPlaybackSpeed(0.1)).toBe(0.5);
});
it('derives semitones for varispeed', () => {
expect(derivedVarispeedSemitones(2)).toBeCloseTo(12, 1);
});
it('is not applied during orbit', () => {
expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, true)).toBe(false);
expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, false)).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
export type PlaybackStrategy = 'speed_corrected' | 'varispeed' | 'preserve_pitch';
/** Default strategy: speed only, pitch corrected automatically. */
export const DEFAULT_PLAYBACK_STRATEGY: PlaybackStrategy = 'speed_corrected';
export const PLAYBACK_STRATEGIES: PlaybackStrategy[] = [
'speed_corrected',
'varispeed',
'preserve_pitch',
];
export const PLAYBACK_SPEED_MIN = 0.5;
export const PLAYBACK_SPEED_MAX = 2.0;
export const PLAYBACK_SPEED_STEP = 0.05;
export const PLAYBACK_PITCH_MIN = -12;
export const PLAYBACK_PITCH_MAX = 12;
export const PLAYBACK_PITCH_STEP = 0.1;
export const PLAYBACK_SPEED_PRESETS = [0.75, 1.0, 1.25, 1.5, 2.0] as const;
export function clampPlaybackSpeed(speed: number): number {
return Math.max(PLAYBACK_SPEED_MIN, Math.min(PLAYBACK_SPEED_MAX, speed));
}
export function clampPlaybackPitch(semitones: number): number {
return Math.max(PLAYBACK_PITCH_MIN, Math.min(PLAYBACK_PITCH_MAX, semitones));
}
/** Pitch sent to Rust: manual offset only in preserve_pitch strategy. */
export function effectivePlaybackPitch(
strategy: PlaybackStrategy,
pitchSemitones: number,
): number {
return strategy === 'preserve_pitch' ? pitchSemitones : 0;
}
/** True when DSP should run (enabled + not neutral 1.0× / 0 st). */
export function isPlaybackEffectActive(
enabled: boolean,
strategy: PlaybackStrategy,
speed: number,
pitchSemitones: number,
): boolean {
if (!enabled) return false;
if (strategy === 'preserve_pitch') {
return Math.abs(speed - 1) > 0.001 || Math.abs(pitchSemitones) > 0.001;
}
return Math.abs(speed - 1) > 0.001;
}
/** True when the engine applies playback-rate DSP (Orbit sessions force passthrough). */
export function isPlaybackRateApplied(
enabled: boolean,
strategy: PlaybackStrategy,
speed: number,
pitchSemitones: number,
orbitSessionActive: boolean,
): boolean {
if (orbitSessionActive) return false;
return isPlaybackEffectActive(enabled, strategy, speed, pitchSemitones);
}
export function derivedVarispeedSemitones(speed: number): number {
if (speed <= 0) return 0;
return 12 * Math.log2(speed);
}
export function formatSpeedLabel(speed: number): string {
return `${speed.toFixed(1)}×`;
}
export function formatPitchLabel(semitones: number): string {
const rounded = Math.round(semitones * 10) / 10;
return rounded > 0 ? `+${rounded.toFixed(1)} st` : `${rounded.toFixed(1)} st`;
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { isPlaybackEffectActive } from './playbackRateHelpers';
import {
playbackPathChanged,
shouldRestartPlaybackForRateChange,
usesPreservePlaybackPath,
} from './playbackRateRestart';
describe('playbackRateRestart', () => {
const base = {
enabled: true,
strategy: 'speed_corrected' as const,
speed: 1.5,
pitchSemitones: 0,
};
it('detects preserve vs varispeed paths', () => {
expect(usesPreservePlaybackPath('speed_corrected')).toBe(true);
expect(usesPreservePlaybackPath('preserve_pitch')).toBe(true);
expect(usesPreservePlaybackPath('varispeed')).toBe(false);
expect(playbackPathChanged('speed_corrected', 'varispeed')).toBe(true);
expect(playbackPathChanged('speed_corrected', 'preserve_pitch')).toBe(false);
});
it('restarts on strategy change', () => {
expect(shouldRestartPlaybackForRateChange(
base,
{ ...base, strategy: 'varispeed' },
)).toBe(true);
});
it('restarts when effect becomes active', () => {
expect(shouldRestartPlaybackForRateChange(
{ ...base, speed: 1.0 },
{ ...base, speed: 1.5 },
)).toBe(true);
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.0, 0)).toBe(false);
expect(isPlaybackEffectActive(true, 'speed_corrected', 1.5, 0)).toBe(true);
});
it('does not restart on speed tweak within varispeed', () => {
expect(shouldRestartPlaybackForRateChange(
{ ...base, strategy: 'varispeed', speed: 1.5 },
{ ...base, strategy: 'varispeed', speed: 1.75 },
)).toBe(false);
});
it('does not restart on pitch tweak within preserve_pitch', () => {
expect(shouldRestartPlaybackForRateChange(
{ ...base, strategy: 'preserve_pitch', pitchSemitones: 0 },
{ ...base, strategy: 'preserve_pitch', pitchSemitones: 2 },
)).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { usePlayerStore } from '../../store/playerStore';
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
import { isPlaybackEffectActive, type PlaybackStrategy } from './playbackRateHelpers';
/** Preserve-pitch DSP (worker) vs direct varispeed sample-rate scaling. */
export function usesPreservePlaybackPath(strategy: PlaybackStrategy): boolean {
return strategy === 'speed_corrected' || strategy === 'preserve_pitch';
}
export function playbackPathChanged(a: PlaybackStrategy, b: PlaybackStrategy): boolean {
return usesPreservePlaybackPath(a) !== usesPreservePlaybackPath(b);
}
export interface PlaybackRateSnapshot {
enabled: boolean;
strategy: PlaybackStrategy;
speed: number;
pitchSemitones: number;
}
/** Whether live atomics are enough vs needing a source rebuild (spec §2.5). */
export function shouldRestartPlaybackForRateChange(
prev: PlaybackRateSnapshot,
next: PlaybackRateSnapshot,
): boolean {
if (prev.strategy !== next.strategy) return true;
if (prev.enabled !== next.enabled && isPlaybackEffectActive(
next.enabled,
next.strategy,
next.speed,
next.pitchSemitones,
)) {
return true;
}
const prevActive = isPlaybackEffectActive(
prev.enabled,
prev.strategy,
prev.speed,
prev.pitchSemitones,
);
const nextActive = isPlaybackEffectActive(
next.enabled,
next.strategy,
next.speed,
next.pitchSemitones,
);
if (prevActive !== nextActive) return true;
return false;
}
/** Re-decode current track at the current timeline position (seek-restart). */
export function restartPlaybackForRateChange(): void {
const player = usePlayerStore.getState();
const track = player.currentTrack;
if (!track || !player.isPlaying) return;
if (track.radioAdded) return;
const dur = track.duration;
if (!dur || !Number.isFinite(dur) || dur <= 0) return;
const time = Math.max(0, Math.min(player.currentTime, dur - 0.25));
if (time > 0.05) {
setSeekFallbackVisualTarget({
trackId: track.id,
seconds: time,
setAtMs: Date.now(),
});
}
player.playTrack(track, player.queue, true);
}
+1
View File
@@ -31,6 +31,7 @@ export {
serialiseOrbitState,
suggestionKey,
} from './orbit/helpers';
export { isOrbitPlaybackSyncActive } from './orbit/sessionActive';
export {
applyOutboxSnapshotsToState,
computeOrbitDriftMs,
+14
View File
@@ -0,0 +1,14 @@
import type { OrbitPhase, OrbitRole } from '../../store/orbitStore';
import { useOrbitStore } from '../../store/orbitStore';
/**
* True while this client is in an Orbit session on the shared playback path
* (host/guest during start, join, or active play). Matches previewStore guard.
*/
export function isOrbitPlaybackSyncActive(
role: OrbitRole | null = useOrbitStore.getState().role,
phase: OrbitPhase = useOrbitStore.getState().phase,
): boolean {
if (role !== 'host' && role !== 'guest') return false;
return phase === 'active' || phase === 'joining' || phase === 'starting';
}