feat(playback): Semitones strategy, 2-decimal speed label, advanced fine steps (#1084)

* feat(playback): varispeed-by-semitones strategy and 2-decimal speed label

Adds a fourth playback-rate strategy "Varispeed (semitones)" — a frontend
lens over varispeed where the user dials the pitch change directly in
semitones (±12 st, 0.1 step) and speed = 2^(st/12). Engine contract is
unchanged: the store maps it to the existing "varispeed" Rust strategy via
engineStrategy(), and switching between the two varispeed lenses at the same
speed no longer restarts the track.

Also widens the speed readout to two decimals (formatSpeedLabel → toFixed(2))
so every 0.05 slider step is visible (1.05×, 1.10×, 1.15×), addressing the
feedback on issue #531 that the label looked stuck between steps.

Includes new i18n keys and updated hint across all 9 locales, plus Vitest
coverage for the new helper, label formatting, and the no-restart lens switch.

* refactor(playback): shorten Semitones strategy label, add per-strategy tooltips

Renames the fourth strategy to a concise "Semitones" (was "Varispeed
(semitones)") across all 9 locales, and lets the four strategy buttons share
the row width so they fit on one line in Settings while still wrapping in the
narrow player popover. Each strategy button now has a short hover tooltip
explaining how it is built (incl. the 2^(st/12) and 12 × log2(speed) maths).

* feat(playback): advanced fine-step precision for speed/pitch sliders

Adds an opt-in "Fine adjustment" toggle in Settings → Audio (visible only in
Advanced mode) that shrinks the playback-rate slider steps to 0.01× for speed
and 0.01 st for pitch/semitones, with the pitch readout widening to two
decimals to match. Default behaviour (0.05× / 0.1 st) is unchanged. The
preference is UI-only (persisted, not sent to the engine) and also applies to
the player-bar popover and wheel. Addresses the finer-precision request on
issue #531 without cluttering the default UX.

* docs: changelog and credits for playback speed follow-up (PR #1084)
This commit is contained in:
cucadmuh
2026-06-14 01:32:20 +03:00
committed by GitHub
parent 41c8187186
commit 0b7d9eae2d
19 changed files with 341 additions and 46 deletions
@@ -3,11 +3,9 @@ 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,
@@ -15,10 +13,14 @@ import {
formatPitchLabel,
formatSpeedLabel,
isPlaybackRateApplied,
playbackPitchStep,
playbackSpeedStep,
varispeedSpeedFromSemitones,
type PlaybackStrategy,
} from '../../../utils/audio/playbackRateHelpers';
import { usePlaybackRateStore } from '../../../store/playbackRateStore';
import { useOrbitStore } from '../../../store/orbitStore';
import { useAuthStore } from '../../../store/authStore';
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
interface Props {
@@ -33,19 +35,25 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
const strategy = usePlaybackRateStore(s => s.strategy);
const speed = usePlaybackRateStore(s => s.speed);
const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones);
const fineStep = usePlaybackRateStore(s => s.fineStep);
const {
setEnabled,
setStrategy,
setSpeed,
setPitchSemitones,
applyPresetSpeed,
setFineStep,
} = usePlaybackRateStore();
const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase);
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase);
const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive);
const derivedPitch = derivedVarispeedSemitones(speed);
const speedStep = playbackSpeedStep(fineStep);
const pitchStep = playbackPitchStep(fineStep);
const pitchDecimals = fineStep ? 2 : 1;
const strategyLabel = (s: PlaybackStrategy) => {
switch (s) {
@@ -53,26 +61,47 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
return t('settings.playbackRateStrategySpeed');
case 'varispeed':
return t('settings.playbackRateStrategyVarispeed');
case 'varispeed_semitones':
return t('settings.playbackRateStrategyVarispeedSemitones');
case 'preserve_pitch':
return t('settings.playbackRateStrategyPreserve');
}
};
const strategyTip = (s: PlaybackStrategy) => {
switch (s) {
case 'speed_corrected':
return t('settings.playbackRateStrategySpeedTip');
case 'varispeed':
return t('settings.playbackRateStrategyVarispeedTip');
case 'varispeed_semitones':
return t('settings.playbackRateStrategyVarispeedSemitonesTip');
case 'preserve_pitch':
return t('settings.playbackRateStrategyPreserveTip');
}
};
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;
if (strategy === 'varispeed_semitones') {
const step = e.deltaY > 0 ? -pitchStep : pitchStep;
const st = clampPlaybackPitch(derivedVarispeedSemitones(speed) + step);
setSpeed(clampPlaybackSpeed(varispeedSpeedFromSemitones(st)));
return;
}
const delta = e.deltaY > 0 ? -speedStep : speedStep;
setSpeed(clampPlaybackSpeed(speed + delta));
}, [compact, enabled, speed, setSpeed]);
}, [compact, enabled, strategy, speed, speedStep, pitchStep, 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;
const delta = e.deltaY > 0 ? -pitchStep : pitchStep;
setPitchSemitones(clampPlaybackPitch(pitchSemitones + delta));
}, [compact, enabled, strategy, pitchSemitones, setPitchSemitones]);
}, [compact, enabled, strategy, pitchSemitones, pitchStep, setPitchSemitones]);
return (
<div
@@ -113,6 +142,8 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setStrategy(s)}
data-tooltip={strategyTip(s)}
data-tooltip-wrap=""
>
{strategyLabel(s)}
</button>
@@ -120,22 +151,45 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
</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>
{strategy !== 'varispeed_semitones' && (
<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={speedStep}
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>
)}
{strategy === 'varispeed_semitones' && (
<div className="playback-rate-slider-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRatePitch')}</span>
)}
<input
type="range"
min={PLAYBACK_PITCH_MIN}
max={PLAYBACK_PITCH_MAX}
step={pitchStep}
value={clampPlaybackPitch(derivedPitch)}
onChange={e =>
setSpeed(clampPlaybackSpeed(varispeedSpeedFromSemitones(parseFloat(e.target.value))))
}
className="playback-rate-slider"
aria-label={t('settings.playbackRatePitch')}
/>
<span className="playback-rate-value">{formatPitchLabel(derivedPitch, pitchDecimals)}</span>
</div>
)}
<div className="playback-rate-presets">
{PLAYBACK_SPEED_PRESETS.map(preset => (
@@ -153,7 +207,15 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
{strategy === 'varispeed' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateDerivedPitch', {
value: formatPitchLabel(derivedPitch),
value: formatPitchLabel(derivedPitch, pitchDecimals),
})}
</div>
)}
{strategy === 'varispeed_semitones' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateDerivedSpeed', {
value: formatSpeedLabel(speed),
})}
</div>
)}
@@ -173,13 +235,41 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
type="range"
min={PLAYBACK_PITCH_MIN}
max={PLAYBACK_PITCH_MAX}
step={PLAYBACK_PITCH_STEP}
step={pitchStep}
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>
<span className="playback-rate-value">{formatPitchLabel(pitchSemitones, pitchDecimals)}</span>
</div>
)}
{!compact && advancedSettingsEnabled && (
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}>
{t('settings.playbackRateFineStep')}
<span
className="settings-sub-section-advanced-badge"
style={{ marginRight: 0 }}
aria-hidden="true"
>
{t('settings.advancedBadge')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateFineStepDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.playbackRateFineStep')}>
<input
type="checkbox"
checked={fineStep}
onChange={e => setFineStep(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
)}