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
+9
View File
@@ -140,6 +140,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* All styles now carry minimize/maximize/close icons for clear, colour-blind-friendly buttons, and an optional toggle hides the minimize button (maximize and close only). * All styles now carry minimize/maximize/close icons for clear, colour-blind-friendly buttons, and an optional toggle hides the minimize button (maximize and close only).
### Playback speed — Semitones strategy, finer labels, and advanced fine steps
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1084](https://github.com/Psychotoxical/psysonic/pull/1084)**
* New **Semitones** strategy sets varispeed directly in semitones (±12 st, 0.1 step) instead of coarse speed steps; the speed readout now shows two decimals so every slider notch is visible.
* Each strategy button has a short tooltip; **Advanced** mode adds an optional **Fine adjustment** toggle (0.01× / 0.01 st steps) in **Settings → Audio**.
## Changed ## Changed
### Dependencies — npm and Rust refresh ### Dependencies — npm and Rust refresh
@@ -5,10 +5,14 @@ import { PlaybackRateControls } from '../settings/audio/PlaybackRateBlock';
import { usePlaybackRateStore } from '../../store/playbackRateStore'; import { usePlaybackRateStore } from '../../store/playbackRateStore';
import { useOrbitStore } from '../../store/orbitStore'; import { useOrbitStore } from '../../store/orbitStore';
import { import {
PLAYBACK_SPEED_STEP, clampPlaybackPitch,
clampPlaybackSpeed, clampPlaybackSpeed,
derivedVarispeedSemitones,
formatSpeedLabel, formatSpeedLabel,
isPlaybackRateApplied, isPlaybackRateApplied,
playbackPitchStep,
playbackSpeedStep,
varispeedSpeedFromSemitones,
} from '../../utils/audio/playbackRateHelpers'; } from '../../utils/audio/playbackRateHelpers';
import { isOrbitPlaybackSyncActive } from '../../utils/orbit'; import { isOrbitPlaybackSyncActive } from '../../utils/orbit';
import { usePlayerBarAnchoredPopover } from '../../hooks/usePlayerBarAnchoredPopover'; import { usePlayerBarAnchoredPopover } from '../../hooks/usePlayerBarAnchoredPopover';
@@ -34,6 +38,7 @@ export function PlayerPlaybackRate({ t }: Props) {
const strategy = usePlaybackRateStore(s => s.strategy); const strategy = usePlaybackRateStore(s => s.strategy);
const speed = usePlaybackRateStore(s => s.speed); const speed = usePlaybackRateStore(s => s.speed);
const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones); const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones);
const fineStep = usePlaybackRateStore(s => s.fineStep);
const setSpeed = usePlaybackRateStore(s => s.setSpeed); const setSpeed = usePlaybackRateStore(s => s.setSpeed);
const orbitRole = useOrbitStore(s => s.role); const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase); const orbitPhase = useOrbitStore(s => s.phase);
@@ -45,9 +50,17 @@ export function PlayerPlaybackRate({ t }: Props) {
const handleWheel = useCallback((e: React.WheelEvent<HTMLElement>) => { const handleWheel = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!enabled) return; if (!enabled) return;
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -PLAYBACK_SPEED_STEP : PLAYBACK_SPEED_STEP; if (strategy === 'varispeed_semitones') {
const pitchStep = playbackPitchStep(fineStep);
const step = e.deltaY > 0 ? -pitchStep : pitchStep;
const st = clampPlaybackPitch(derivedVarispeedSemitones(speed) + step);
setSpeed(clampPlaybackSpeed(varispeedSpeedFromSemitones(st)));
return;
}
const speedStep = playbackSpeedStep(fineStep);
const delta = e.deltaY > 0 ? -speedStep : speedStep;
setSpeed(clampPlaybackSpeed(speed + delta)); setSpeed(clampPlaybackSpeed(speed + delta));
}, [enabled, speed, setSpeed]); }, [enabled, strategy, speed, fineStep, setSpeed]);
if (!enabled) return null; if (!enabled) return null;
@@ -3,11 +3,9 @@ import type { TFunction } from 'i18next';
import { import {
PLAYBACK_PITCH_MAX, PLAYBACK_PITCH_MAX,
PLAYBACK_PITCH_MIN, PLAYBACK_PITCH_MIN,
PLAYBACK_PITCH_STEP,
PLAYBACK_SPEED_MAX, PLAYBACK_SPEED_MAX,
PLAYBACK_SPEED_MIN, PLAYBACK_SPEED_MIN,
PLAYBACK_SPEED_PRESETS, PLAYBACK_SPEED_PRESETS,
PLAYBACK_SPEED_STEP,
PLAYBACK_STRATEGIES, PLAYBACK_STRATEGIES,
clampPlaybackPitch, clampPlaybackPitch,
clampPlaybackSpeed, clampPlaybackSpeed,
@@ -15,10 +13,14 @@ import {
formatPitchLabel, formatPitchLabel,
formatSpeedLabel, formatSpeedLabel,
isPlaybackRateApplied, isPlaybackRateApplied,
playbackPitchStep,
playbackSpeedStep,
varispeedSpeedFromSemitones,
type PlaybackStrategy, type PlaybackStrategy,
} from '../../../utils/audio/playbackRateHelpers'; } from '../../../utils/audio/playbackRateHelpers';
import { usePlaybackRateStore } from '../../../store/playbackRateStore'; import { usePlaybackRateStore } from '../../../store/playbackRateStore';
import { useOrbitStore } from '../../../store/orbitStore'; import { useOrbitStore } from '../../../store/orbitStore';
import { useAuthStore } from '../../../store/authStore';
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit'; import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
interface Props { interface Props {
@@ -33,19 +35,25 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
const strategy = usePlaybackRateStore(s => s.strategy); const strategy = usePlaybackRateStore(s => s.strategy);
const speed = usePlaybackRateStore(s => s.speed); const speed = usePlaybackRateStore(s => s.speed);
const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones); const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones);
const fineStep = usePlaybackRateStore(s => s.fineStep);
const { const {
setEnabled, setEnabled,
setStrategy, setStrategy,
setSpeed, setSpeed,
setPitchSemitones, setPitchSemitones,
applyPresetSpeed, applyPresetSpeed,
setFineStep,
} = usePlaybackRateStore(); } = usePlaybackRateStore();
const orbitRole = useOrbitStore(s => s.role); const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase); const orbitPhase = useOrbitStore(s => s.phase);
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase); const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase);
const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive); const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive);
const derivedPitch = derivedVarispeedSemitones(speed); const derivedPitch = derivedVarispeedSemitones(speed);
const speedStep = playbackSpeedStep(fineStep);
const pitchStep = playbackPitchStep(fineStep);
const pitchDecimals = fineStep ? 2 : 1;
const strategyLabel = (s: PlaybackStrategy) => { const strategyLabel = (s: PlaybackStrategy) => {
switch (s) { switch (s) {
@@ -53,26 +61,47 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
return t('settings.playbackRateStrategySpeed'); return t('settings.playbackRateStrategySpeed');
case 'varispeed': case 'varispeed':
return t('settings.playbackRateStrategyVarispeed'); return t('settings.playbackRateStrategyVarispeed');
case 'varispeed_semitones':
return t('settings.playbackRateStrategyVarispeedSemitones');
case 'preserve_pitch': case 'preserve_pitch':
return t('settings.playbackRateStrategyPreserve'); 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>) => { const handleWheelSpeed = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!compact || !enabled) return; if (!compact || !enabled) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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)); setSpeed(clampPlaybackSpeed(speed + delta));
}, [compact, enabled, speed, setSpeed]); }, [compact, enabled, strategy, speed, speedStep, pitchStep, setSpeed]);
const handleWheelPitch = useCallback((e: React.WheelEvent<HTMLElement>) => { const handleWheelPitch = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!compact || !enabled || strategy !== 'preserve_pitch') return; if (!compact || !enabled || strategy !== 'preserve_pitch') return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const delta = e.deltaY > 0 ? -PLAYBACK_PITCH_STEP : PLAYBACK_PITCH_STEP; const delta = e.deltaY > 0 ? -pitchStep : pitchStep;
setPitchSemitones(clampPlaybackPitch(pitchSemitones + delta)); setPitchSemitones(clampPlaybackPitch(pitchSemitones + delta));
}, [compact, enabled, strategy, pitchSemitones, setPitchSemitones]); }, [compact, enabled, strategy, pitchSemitones, pitchStep, setPitchSemitones]);
return ( return (
<div <div
@@ -113,6 +142,8 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
type="button" type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`} className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setStrategy(s)} onClick={() => setStrategy(s)}
data-tooltip={strategyTip(s)}
data-tooltip-wrap=""
> >
{strategyLabel(s)} {strategyLabel(s)}
</button> </button>
@@ -120,6 +151,7 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
</div> </div>
</div> </div>
{strategy !== 'varispeed_semitones' && (
<div className="playback-rate-slider-row"> <div className="playback-rate-slider-row">
{!compact && ( {!compact && (
<span className="playback-rate-label">{t('settings.playbackRateSpeed')}</span> <span className="playback-rate-label">{t('settings.playbackRateSpeed')}</span>
@@ -128,7 +160,7 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
type="range" type="range"
min={PLAYBACK_SPEED_MIN} min={PLAYBACK_SPEED_MIN}
max={PLAYBACK_SPEED_MAX} max={PLAYBACK_SPEED_MAX}
step={PLAYBACK_SPEED_STEP} step={speedStep}
value={speed} value={speed}
onChange={e => setSpeed(parseFloat(e.target.value))} onChange={e => setSpeed(parseFloat(e.target.value))}
className="playback-rate-slider" className="playback-rate-slider"
@@ -136,6 +168,28 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
/> />
<span className="playback-rate-value">{formatSpeedLabel(speed)}</span> <span className="playback-rate-value">{formatSpeedLabel(speed)}</span>
</div> </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"> <div className="playback-rate-presets">
{PLAYBACK_SPEED_PRESETS.map(preset => ( {PLAYBACK_SPEED_PRESETS.map(preset => (
@@ -153,7 +207,15 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
{strategy === 'varispeed' && !compact && ( {strategy === 'varispeed' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}> <div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateDerivedPitch', { {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> </div>
)} )}
@@ -173,13 +235,41 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
type="range" type="range"
min={PLAYBACK_PITCH_MIN} min={PLAYBACK_PITCH_MIN}
max={PLAYBACK_PITCH_MAX} max={PLAYBACK_PITCH_MAX}
step={PLAYBACK_PITCH_STEP} step={pitchStep}
value={pitchSemitones} value={pitchSemitones}
onChange={e => setPitchSemitones(parseFloat(e.target.value))} onChange={e => setPitchSemitones(parseFloat(e.target.value))}
className="playback-rate-slider" className="playback-rate-slider"
aria-label={t('settings.playbackRatePitch')} 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> </div>
)} )}
+1
View File
@@ -164,6 +164,7 @@ const CONTRIBUTOR_ENTRIES = [
'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)', 'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)',
'Audio: lazy-open output stream, 60s idle release (#1071), cold-start paused restore with silent engine prepare (PR #1073)', 'Audio: lazy-open output stream, 60s idle release (#1071), cold-start paused restore with silent engine prepare (PR #1073)',
'OpenSubsonic playbackReport — live now-playing state, gliding position bar, and immediate pause/resume on Navidrome ≥0.62 (PR #1080)', 'OpenSubsonic playbackReport — live now-playing state, gliding position bar, and immediate pause/resume on Navidrome ≥0.62 (PR #1080)',
'Playback speed follow-up — Semitones varispeed strategy, two-decimal speed label, per-strategy tooltips, and Advanced fine-step toggle (PR #1084)',
], ],
}, },
{ {
+9 -1
View File
@@ -448,12 +448,20 @@ export const settings = {
playbackRateStrategy: 'Strategie', playbackRateStrategy: 'Strategie',
playbackRateStrategySpeed: 'Geschwindigkeit', playbackRateStrategySpeed: 'Geschwindigkeit',
playbackRateStrategyVarispeed: 'Tonhöhe folgt dem Tempo', playbackRateStrategyVarispeed: 'Tonhöhe folgt dem Tempo',
playbackRateStrategyVarispeedSemitones: 'Halbtöne',
playbackRateStrategyPreserve: 'Tonhöhe', playbackRateStrategyPreserve: 'Tonhöhe',
playbackRateStrategySpeedTip: 'Ändert das Tempo; die Tonhöhe wird automatisch korrigiert.',
playbackRateStrategyVarispeedTip: 'Geschwindigkeit und Tonhöhe ändern sich gemeinsam, wie bei Tonband. Tonhöhe aus Geschwindigkeit: 12 × log2(Geschwindigkeit).',
playbackRateStrategyVarispeedSemitonesTip: 'Wie „Tonhöhe folgt dem Tempo“, aber direkt in Halbtönen eingestellt. Geschwindigkeit = 2^(Ht/12).',
playbackRateStrategyPreserveTip: 'Einstellbares Tempo plus manuelle Tonhöhenverschiebung (±12 Ht).',
playbackRateSpeed: 'Geschwindigkeit', playbackRateSpeed: 'Geschwindigkeit',
playbackRatePitch: 'Tonhöhe', playbackRatePitch: 'Tonhöhe',
playbackRateDerivedPitch: 'Tonhöhenverschiebung durch Geschwindigkeit: {{value}}', playbackRateDerivedPitch: 'Tonhöhenverschiebung durch Geschwindigkeit: {{value}}',
playbackRateDerivedSpeed: 'Geschwindigkeit aus Halbtönen: {{value}}',
playbackRateFineStep: 'Feinabstimmung',
playbackRateFineStepDesc: 'Kleinere Schieberegler-Schritte: 0,01× für Geschwindigkeit und 0,01 Ht für Tonhöhe.',
playbackRateAutoPitch: 'Die Tonhöhe wird automatisch korrigiert.', 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.', playbackRateHint: '„Geschwindigkeit“ behält die Tonhöhe bei. „Tonhöhe folgt dem Tempo“ verschiebt die Tonhöhe mit der Geschwindigkeit. „Tonhöhe folgt dem Tempo (Halbtöne)“ stellt diese Tonhöhenänderung direkt in Halbtönen ein. „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.', 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.', playbackRateOrbitPaused: 'Während Orbit-Sessions nicht aktiv — Wiedergabe bleibt bei 1,0× für die Synchronisation.',
playbackRateOrbitPausedShort: 'Orbit: 1,0× für Sync.', playbackRateOrbitPausedShort: 'Orbit: 1,0× für Sync.',
+9 -1
View File
@@ -492,12 +492,20 @@ export const settings = {
playbackRateStrategy: 'Strategy', playbackRateStrategy: 'Strategy',
playbackRateStrategySpeed: 'Speed', playbackRateStrategySpeed: 'Speed',
playbackRateStrategyVarispeed: 'Varispeed', playbackRateStrategyVarispeed: 'Varispeed',
playbackRateStrategyVarispeedSemitones: 'Semitones',
playbackRateStrategyPreserve: 'Pitch shift', playbackRateStrategyPreserve: 'Pitch shift',
playbackRateStrategySpeedTip: 'Changes tempo; pitch is corrected automatically.',
playbackRateStrategyVarispeedTip: 'Speed and pitch move together, like tape. Pitch follows speed: 12 × log2(speed).',
playbackRateStrategyVarispeedSemitonesTip: 'Varispeed dialled directly in semitones. Speed = 2^(st/12).',
playbackRateStrategyPreserveTip: 'Adjustable tempo plus a manual pitch offset (±12 st).',
playbackRateSpeed: 'Speed', playbackRateSpeed: 'Speed',
playbackRatePitch: 'Pitch', playbackRatePitch: 'Pitch',
playbackRateDerivedPitch: 'Pitch shift from speed: {{value}}', playbackRateDerivedPitch: 'Pitch shift from speed: {{value}}',
playbackRateDerivedSpeed: 'Speed from semitones: {{value}}',
playbackRateFineStep: 'Fine adjustment',
playbackRateFineStepDesc: 'Smaller slider steps: 0.01× for speed and 0.01 st for pitch.',
playbackRateAutoPitch: 'Pitch is corrected automatically.', 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.', playbackRateHint: 'Speed keeps pitch natural. Varispeed changes pitch with speed. Varispeed (semitones) sets that pitch change directly in semitones. 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.', playbackRateNeutral: 'At 1.0× with no pitch offset, playback is normal.',
playbackRateOrbitPaused: 'Not applied during Orbit sessions — playback stays at 1.0× for sync.', playbackRateOrbitPaused: 'Not applied during Orbit sessions — playback stays at 1.0× for sync.',
playbackRateOrbitPausedShort: 'Orbit: 1.0× for sync.', playbackRateOrbitPausedShort: 'Orbit: 1.0× for sync.',
+9 -1
View File
@@ -447,12 +447,20 @@ export const settings = {
playbackRateStrategy: 'Estrategia', playbackRateStrategy: 'Estrategia',
playbackRateStrategySpeed: 'Velocidad', playbackRateStrategySpeed: 'Velocidad',
playbackRateStrategyVarispeed: 'Con tono', playbackRateStrategyVarispeed: 'Con tono',
playbackRateStrategyVarispeedSemitones: 'Semitonos',
playbackRateStrategyPreserve: 'Tono', playbackRateStrategyPreserve: 'Tono',
playbackRateStrategySpeedTip: 'Cambia el tempo; el tono se corrige automáticamente.',
playbackRateStrategyVarispeedTip: 'La velocidad y el tono cambian juntos, como una cinta. Tono según la velocidad: 12 × log2(velocidad).',
playbackRateStrategyVarispeedSemitonesTip: 'Igual que « Con tono », pero ajustado directamente en semitonos. Velocidad = 2^(st/12).',
playbackRateStrategyPreserveTip: 'Tempo ajustable más un desplazamiento de tono manual (±12 st).',
playbackRateSpeed: 'Velocidad', playbackRateSpeed: 'Velocidad',
playbackRatePitch: 'Tono', playbackRatePitch: 'Tono',
playbackRateDerivedPitch: 'Cambio de tono por velocidad: {{value}}', playbackRateDerivedPitch: 'Cambio de tono por velocidad: {{value}}',
playbackRateDerivedSpeed: 'Velocidad según semitonos: {{value}}',
playbackRateFineStep: 'Ajuste fino',
playbackRateFineStepDesc: 'Pasos de control más pequeños: 0,01× para la velocidad y 0,01 st para el tono.',
playbackRateAutoPitch: 'El tono se corrige automáticamente.', 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.', playbackRateHint: '« Velocidad » mantiene el tono natural. « Con tono » cambia el tono con la velocidad. « Con tono (semitonos) » ajusta ese cambio directamente en semitonos. « 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.', 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.', playbackRateOrbitPaused: 'No se aplica durante sesiones Orbit — la reproducción permanece a 1,0× para sincronizar.',
playbackRateOrbitPausedShort: 'Orbit: 1,0× para sincronizar.', playbackRateOrbitPausedShort: 'Orbit: 1,0× para sincronizar.',
+9 -1
View File
@@ -435,12 +435,20 @@ export const settings = {
playbackRateStrategy: 'Stratégie', playbackRateStrategy: 'Stratégie',
playbackRateStrategySpeed: 'Vitesse', playbackRateStrategySpeed: 'Vitesse',
playbackRateStrategyVarispeed: 'Avec hauteur', playbackRateStrategyVarispeed: 'Avec hauteur',
playbackRateStrategyVarispeedSemitones: 'Demi-tons',
playbackRateStrategyPreserve: 'Hauteur', playbackRateStrategyPreserve: 'Hauteur',
playbackRateStrategySpeedTip: 'Modifie le tempo ; la hauteur est corrigée automatiquement.',
playbackRateStrategyVarispeedTip: 'La vitesse et la hauteur changent ensemble, comme une bande. Hauteur selon la vitesse : 12 × log2(vitesse).',
playbackRateStrategyVarispeedSemitonesTip: 'Comme « Avec hauteur », mais réglé directement en demi-tons. Vitesse = 2^(dt/12).',
playbackRateStrategyPreserveTip: 'Tempo réglable plus un décalage de hauteur manuel (±12 dt).',
playbackRateSpeed: 'Vitesse', playbackRateSpeed: 'Vitesse',
playbackRatePitch: 'Hauteur', playbackRatePitch: 'Hauteur',
playbackRateDerivedPitch: 'Décalage de hauteur lié à la vitesse : {{value}}', playbackRateDerivedPitch: 'Décalage de hauteur lié à la vitesse : {{value}}',
playbackRateDerivedSpeed: 'Vitesse selon les demi-tons : {{value}}',
playbackRateFineStep: 'Réglage fin',
playbackRateFineStepDesc: 'Pas de curseur plus petits : 0,01× pour la vitesse et 0,01 dt pour la hauteur.',
playbackRateAutoPitch: 'La hauteur est corrigée automatiquement.', 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.', playbackRateHint: '« Vitesse » conserve la hauteur naturelle. « Avec hauteur » change la hauteur avec la vitesse. « Avec hauteur (demi-tons) » règle ce changement directement en demi-tons. « 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.', 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.', playbackRateOrbitPaused: 'Non appliqué pendant les sessions Orbit — la lecture reste à 1,0× pour la synchro.',
playbackRateOrbitPausedShort: 'Orbit : 1,0× pour la synchro.', playbackRateOrbitPausedShort: 'Orbit : 1,0× pour la synchro.',
+9 -1
View File
@@ -434,12 +434,20 @@ export const settings = {
playbackRateStrategy: 'Strategi', playbackRateStrategy: 'Strategi',
playbackRateStrategySpeed: 'Hastighet', playbackRateStrategySpeed: 'Hastighet',
playbackRateStrategyVarispeed: 'Med tone', playbackRateStrategyVarispeed: 'Med tone',
playbackRateStrategyVarispeedSemitones: 'Halvtoner',
playbackRateStrategyPreserve: 'Tonehøyde', playbackRateStrategyPreserve: 'Tonehøyde',
playbackRateStrategySpeedTip: 'Endrer tempoet; tonehøyden korrigeres automatisk.',
playbackRateStrategyVarispeedTip: 'Hastighet og tonehøyde endres sammen, som på bånd. Tonehøyde fra hastighet: 12 × log2(hastighet).',
playbackRateStrategyVarispeedSemitonesTip: 'Som « Med tone », men stilt inn direkte i halvtoner. Hastighet = 2^(ht/12).',
playbackRateStrategyPreserveTip: 'Justerbart tempo pluss en manuell tonehøydeforskyvning (±12 ht).',
playbackRateSpeed: 'Hastighet', playbackRateSpeed: 'Hastighet',
playbackRatePitch: 'Tonehøyde', playbackRatePitch: 'Tonehøyde',
playbackRateDerivedPitch: 'Tonehøydeforskyvning fra hastighet: {{value}}', playbackRateDerivedPitch: 'Tonehøydeforskyvning fra hastighet: {{value}}',
playbackRateDerivedSpeed: 'Hastighet fra halvtoner: {{value}}',
playbackRateFineStep: 'Finjustering',
playbackRateFineStepDesc: 'Mindre glidebryter-steg: 0,01× for hastighet og 0,01 ht for tonehøyde.',
playbackRateAutoPitch: 'Tonehøyden korrigeres automatisk.', 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.', playbackRateHint: '« Hastighet » beholder naturlig tonehøyde. « Med tone » endrer tonehøyden med hastigheten. « Med tone (halvtoner) » stiller denne endringen direkte i halvtoner. « 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.', playbackRateNeutral: 'Ved 1,0× og uten toneforskyvning er avspilling normal.',
playbackRateOrbitPaused: 'Ikke aktiv under Orbit-økter — avspilling forblir 1,0× for synk.', playbackRateOrbitPaused: 'Ikke aktiv under Orbit-økter — avspilling forblir 1,0× for synk.',
playbackRateOrbitPausedShort: 'Orbit: 1,0× for synk.', playbackRateOrbitPausedShort: 'Orbit: 1,0× for synk.',
+9 -1
View File
@@ -435,12 +435,20 @@ export const settings = {
playbackRateStrategy: 'Strategie', playbackRateStrategy: 'Strategie',
playbackRateStrategySpeed: 'Snelheid', playbackRateStrategySpeed: 'Snelheid',
playbackRateStrategyVarispeed: 'Met toon', playbackRateStrategyVarispeed: 'Met toon',
playbackRateStrategyVarispeedSemitones: 'Halve tonen',
playbackRateStrategyPreserve: 'Toonhoogte', playbackRateStrategyPreserve: 'Toonhoogte',
playbackRateStrategySpeedTip: 'Verandert het tempo; de toonhoogte wordt automatisch gecorrigeerd.',
playbackRateStrategyVarispeedTip: 'Snelheid en toonhoogte veranderen samen, zoals bij tape. Toonhoogte uit snelheid: 12 × log2(snelheid).',
playbackRateStrategyVarispeedSemitonesTip: 'Zoals « Met toon », maar rechtstreeks in halve tonen ingesteld. Snelheid = 2^(ht/12).',
playbackRateStrategyPreserveTip: 'Instelbaar tempo plus een handmatige toonhoogteverschuiving (±12 ht).',
playbackRateSpeed: 'Snelheid', playbackRateSpeed: 'Snelheid',
playbackRatePitch: 'Toonhoogte', playbackRatePitch: 'Toonhoogte',
playbackRateDerivedPitch: 'Toonhoogteverschuiving door snelheid: {{value}}', playbackRateDerivedPitch: 'Toonhoogteverschuiving door snelheid: {{value}}',
playbackRateDerivedSpeed: 'Snelheid uit halve tonen: {{value}}',
playbackRateFineStep: 'Fijnafstelling',
playbackRateFineStepDesc: 'Kleinere schuifregelaarstappen: 0,01× voor snelheid en 0,01 ht voor toonhoogte.',
playbackRateAutoPitch: 'De toonhoogte wordt automatisch gecorrigeerd.', 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.', playbackRateHint: '« Snelheid » behoudt de natuurlijke toonhoogte. « Met toon » verandert de toonhoogte met de snelheid. « Met toon (halve tonen) » stelt die verandering rechtstreeks in halve tonen in. « 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.', playbackRateNeutral: 'Bij 1,0× en zonder toonverschuiving is afspelen normaal.',
playbackRateOrbitPaused: 'Niet actief tijdens Orbit-sessies — afspelen blijft 1,0× voor sync.', playbackRateOrbitPaused: 'Niet actief tijdens Orbit-sessies — afspelen blijft 1,0× voor sync.',
playbackRateOrbitPausedShort: 'Orbit: 1,0× voor sync.', playbackRateOrbitPausedShort: 'Orbit: 1,0× voor sync.',
+9 -1
View File
@@ -450,12 +450,20 @@ export const settings = {
playbackRateStrategy: 'Strategie', playbackRateStrategy: 'Strategie',
playbackRateStrategySpeed: 'Viteză', playbackRateStrategySpeed: 'Viteză',
playbackRateStrategyVarispeed: 'Cu ton', playbackRateStrategyVarispeed: 'Cu ton',
playbackRateStrategyVarispeedSemitones: 'Semitonuri',
playbackRateStrategyPreserve: 'Ton', playbackRateStrategyPreserve: 'Ton',
playbackRateStrategySpeedTip: 'Schimbă tempoul; tonul este corectat automat.',
playbackRateStrategyVarispeedTip: 'Viteza și tonul se schimbă împreună, ca la bandă. Ton în funcție de viteză: 12 × log2(viteză).',
playbackRateStrategyVarispeedSemitonesTip: 'La fel ca « Cu ton », dar reglat direct în semitonuri. Viteză = 2^(st/12).',
playbackRateStrategyPreserveTip: 'Tempo reglabil plus o deplasare manuală a tonului (±12 st).',
playbackRateSpeed: 'Viteză', playbackRateSpeed: 'Viteză',
playbackRatePitch: 'Ton', playbackRatePitch: 'Ton',
playbackRateDerivedPitch: 'Deplasare ton din viteză: {{value}}', playbackRateDerivedPitch: 'Deplasare ton din viteză: {{value}}',
playbackRateDerivedSpeed: 'Viteză din semitonuri: {{value}}',
playbackRateFineStep: 'Reglaj fin',
playbackRateFineStepDesc: 'Pași mai mici ai glisorului: 0,01× pentru viteză și 0,01 st pentru ton.',
playbackRateAutoPitch: 'Tonul este corectat automat.', 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.', playbackRateHint: '« Viteză » păstrează tonul natural. « Cu ton » schimbă tonul odată cu viteza. « Cu ton (semitonuri) » setează această schimbare direct în semitonuri. « 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ă.', 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.', playbackRateOrbitPaused: 'Nu se aplică în sesiunile Orbit — redarea rămâne la 1,0× pentru sincronizare.',
playbackRateOrbitPausedShort: 'Orbit: 1,0× pentru sync.', playbackRateOrbitPausedShort: 'Orbit: 1,0× pentru sync.',
+9 -1
View File
@@ -505,12 +505,20 @@ export const settings = {
playbackRateStrategy: 'Стратегия', playbackRateStrategy: 'Стратегия',
playbackRateStrategySpeed: 'Скорость', playbackRateStrategySpeed: 'Скорость',
playbackRateStrategyVarispeed: 'С тоном', playbackRateStrategyVarispeed: 'С тоном',
playbackRateStrategyVarispeedSemitones: 'Полутона',
playbackRateStrategyPreserve: 'Сдвиг тона', playbackRateStrategyPreserve: 'Сдвиг тона',
playbackRateStrategySpeedTip: 'Меняет темп; высота тона корректируется автоматически.',
playbackRateStrategyVarispeedTip: 'Скорость и тон меняются вместе, как у плёнки. Тон от скорости: 12 × log2(скорость).',
playbackRateStrategyVarispeedSemitonesTip: 'Тот же режим «С тоном», но задаётся напрямую в полутонах. Скорость = 2^(пт/12).',
playbackRateStrategyPreserveTip: 'Регулируемый темп плюс ручное смещение тона (±12 пт).',
playbackRateSpeed: 'Скорость', playbackRateSpeed: 'Скорость',
playbackRatePitch: 'Тон', playbackRatePitch: 'Тон',
playbackRateDerivedPitch: 'Сдвиг тона от скорости: {{value}}', playbackRateDerivedPitch: 'Сдвиг тона от скорости: {{value}}',
playbackRateDerivedSpeed: 'Скорость из полутонов: {{value}}',
playbackRateFineStep: 'Точная настройка',
playbackRateFineStepDesc: 'Меньший шаг ползунков: 0.01× для скорости и 0.01 пт для тона.',
playbackRateAutoPitch: 'Тон корректируется автоматически.', playbackRateAutoPitch: 'Тон корректируется автоматически.',
playbackRateHint: '«Скорость» сохраняет высоту тона при изменении темпа. «С тоном» меняет тон вместе со скоростью. «Сдвиг тона» — ручное смещение. Выше нагрузка на процессор, чем у режима «С тоном». Не для радио, превью и Orbit.', playbackRateHint: '«Скорость» сохраняет высоту тона при изменении темпа. «С тоном» меняет тон вместе со скоростью. «С тоном (полутона)» задаёт это изменение тона напрямую в полутонах. «Сдвиг тона» — ручное смещение. Выше нагрузка на процессор, чем у режима «С тоном». Не для радио, превью и Orbit.',
playbackRateNeutral: 'При 1.0× и нулевом сдвиге тона воспроизведение обычное.', playbackRateNeutral: 'При 1.0× и нулевом сдвиге тона воспроизведение обычное.',
playbackRateOrbitPaused: 'Во время сессии Orbit не применяется — воспроизведение остаётся 1.0× для синхронизации.', playbackRateOrbitPaused: 'Во время сессии Orbit не применяется — воспроизведение остаётся 1.0× для синхронизации.',
playbackRateOrbitPausedShort: 'Orbit: 1.0× для синхронизации.', playbackRateOrbitPausedShort: 'Orbit: 1.0× для синхронизации.',
+9 -1
View File
@@ -434,12 +434,20 @@ export const settings = {
playbackRateStrategy: '策略', playbackRateStrategy: '策略',
playbackRateStrategySpeed: '速度', playbackRateStrategySpeed: '速度',
playbackRateStrategyVarispeed: '随速变调', playbackRateStrategyVarispeed: '随速变调',
playbackRateStrategyVarispeedSemitones: '半音',
playbackRateStrategyPreserve: '音高', playbackRateStrategyPreserve: '音高',
playbackRateStrategySpeedTip: '改变速度;音高自动校正。',
playbackRateStrategyVarispeedTip: '速度和音高一起变化,像磁带一样。音高由速度决定:12 × log2(速度)。',
playbackRateStrategyVarispeedSemitonesTip: '与「随速变调」相同,但直接以半音设置。速度 = 2^(半音/12)。',
playbackRateStrategyPreserveTip: '可调速度,外加手动音高偏移(±12 半音)。',
playbackRateSpeed: '速度', playbackRateSpeed: '速度',
playbackRatePitch: '音高', playbackRatePitch: '音高',
playbackRateDerivedPitch: '速度引起的音高偏移:{{value}}', playbackRateDerivedPitch: '速度引起的音高偏移:{{value}}',
playbackRateDerivedSpeed: '由半音得出的速度:{{value}}',
playbackRateFineStep: '精细调节',
playbackRateFineStepDesc: '更小的滑块步长:速度 0.01×,音高 0.01 半音。',
playbackRateAutoPitch: '音高会自动校正。', playbackRateAutoPitch: '音高会自动校正。',
playbackRateHint: '「速度」保持音高不变。「随速变调」会随速度改变音高。「音高」可手动偏移。比「随速变调」占用更多 CPU。不适用于电台、预览或 Orbit。', playbackRateHint: '「速度」保持音高不变。「随速变调」会随速度改变音高。「随速变调(半音)」可直接以半音设置该音高变化。「音高」可手动偏移。比「随速变调」占用更多 CPU。不适用于电台、预览或 Orbit。',
playbackRateNeutral: '在 1.0× 且无音高偏移时,播放为正常速度。', playbackRateNeutral: '在 1.0× 且无音高偏移时,播放为正常速度。',
playbackRateOrbitPaused: 'Orbit 会话期间不生效 — 播放保持 1.0× 以同步。', playbackRateOrbitPaused: 'Orbit 会话期间不生效 — 播放保持 1.0× 以同步。',
playbackRateOrbitPausedShort: 'Orbit1.0× 同步。', playbackRateOrbitPausedShort: 'Orbit1.0× 同步。',
+9 -1
View File
@@ -6,6 +6,7 @@ import {
clampPlaybackSpeed, clampPlaybackSpeed,
DEFAULT_PLAYBACK_STRATEGY, DEFAULT_PLAYBACK_STRATEGY,
effectivePlaybackPitch, effectivePlaybackPitch,
engineStrategy,
type PlaybackStrategy, type PlaybackStrategy,
} from '../utils/audio/playbackRateHelpers'; } from '../utils/audio/playbackRateHelpers';
import { import {
@@ -16,12 +17,15 @@ import {
import { isOrbitPlaybackSyncActive } from '../utils/orbit'; import { isOrbitPlaybackSyncActive } from '../utils/orbit';
interface PlaybackRateState extends PlaybackRateSnapshot { interface PlaybackRateState extends PlaybackRateSnapshot {
/** UI-only: smaller slider steps (Advanced). Not sent to the engine. */
fineStep: boolean;
setEnabled: (v: boolean) => void; setEnabled: (v: boolean) => void;
setStrategy: (s: PlaybackStrategy) => void; setStrategy: (s: PlaybackStrategy) => void;
setSpeed: (speed: number) => void; setSpeed: (speed: number) => void;
setPitchSemitones: (semitones: number) => void; setPitchSemitones: (semitones: number) => void;
applyPresetSpeed: (speed: number) => void; applyPresetSpeed: (speed: number) => void;
setFineStep: (v: boolean) => void;
syncToRust: () => void; syncToRust: () => void;
} }
@@ -30,7 +34,7 @@ function syncPlaybackRate(state: PlaybackRateSnapshot, prev?: PlaybackRateSnapsh
const effectiveEnabled = state.enabled && !isOrbitPlaybackSyncActive(); const effectiveEnabled = state.enabled && !isOrbitPlaybackSyncActive();
invoke('audio_set_playback_rate', { invoke('audio_set_playback_rate', {
enabled: effectiveEnabled, enabled: effectiveEnabled,
strategy: state.strategy, strategy: engineStrategy(state.strategy),
speed: state.speed, speed: state.speed,
pitchSemitones: effectivePlaybackPitch(state.strategy, state.pitchSemitones), pitchSemitones: effectivePlaybackPitch(state.strategy, state.pitchSemitones),
}).catch(() => {}); }).catch(() => {});
@@ -47,6 +51,7 @@ export const usePlaybackRateStore = create<PlaybackRateState>()(
strategy: DEFAULT_PLAYBACK_STRATEGY, strategy: DEFAULT_PLAYBACK_STRATEGY,
speed: 1.0, speed: 1.0,
pitchSemitones: 0, pitchSemitones: 0,
fineStep: false,
setEnabled: (v) => { setEnabled: (v) => {
const prev = get(); const prev = get();
@@ -81,6 +86,8 @@ export const usePlaybackRateStore = create<PlaybackRateState>()(
syncPlaybackRate(get(), prev); syncPlaybackRate(get(), prev);
}, },
setFineStep: (v) => set({ fineStep: v }),
syncToRust: () => { syncToRust: () => {
syncPlaybackRate(get()); syncPlaybackRate(get());
}, },
@@ -93,6 +100,7 @@ export const usePlaybackRateStore = create<PlaybackRateState>()(
strategy: s.strategy, strategy: s.strategy,
speed: s.speed, speed: s.speed,
pitchSemitones: s.pitchSemitones, pitchSemitones: s.pitchSemitones,
fineStep: s.fineStep,
}), }),
}, },
), ),
+7
View File
@@ -16,6 +16,13 @@
gap: 0.5rem; gap: 0.5rem;
} }
/* Let all four strategies share the row width evenly in Settings; they still
* wrap gracefully in the narrow player popover. */
.playback-rate-strategy-btns > .btn {
flex: 1 1 auto;
white-space: nowrap;
}
.playback-rate-slider-row { .playback-rate-slider-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
clampPlaybackSpeed, clampPlaybackSpeed,
engineStrategy,
formatPitchLabel,
formatSpeedLabel,
isPlaybackEffectActive, isPlaybackEffectActive,
isPlaybackRateApplied, isPlaybackRateApplied,
derivedVarispeedSemitones, derivedVarispeedSemitones,
playbackPitchStep,
playbackSpeedStep,
varispeedSpeedFromSemitones,
} from './playbackRateHelpers'; } from './playbackRateHelpers';
describe('playbackRateHelpers', () => { describe('playbackRateHelpers', () => {
@@ -40,4 +46,46 @@ describe('playbackRateHelpers', () => {
expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, true)).toBe(false); expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, true)).toBe(false);
expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, false)).toBe(true); expect(isPlaybackRateApplied(true, 'speed_corrected', 1.5, 0, false)).toBe(true);
}); });
it('formats speed with two decimals', () => {
expect(formatSpeedLabel(1.0)).toBe('1.00×');
expect(formatSpeedLabel(1.05)).toBe('1.05×');
expect(formatSpeedLabel(1.1)).toBe('1.10×');
expect(formatSpeedLabel(1.15)).toBe('1.15×');
});
it('maps semitones to a speed multiplier and back', () => {
expect(varispeedSpeedFromSemitones(0)).toBeCloseTo(1, 6);
expect(varispeedSpeedFromSemitones(12)).toBeCloseTo(2, 6);
expect(varispeedSpeedFromSemitones(-12)).toBeCloseTo(0.5, 6);
const speed = varispeedSpeedFromSemitones(3);
expect(derivedVarispeedSemitones(speed)).toBeCloseTo(3, 6);
});
it('routes varispeed_semitones to the varispeed engine', () => {
expect(engineStrategy('varispeed_semitones')).toBe('varispeed');
expect(engineStrategy('varispeed')).toBe('varispeed');
expect(engineStrategy('preserve_pitch')).toBe('preserve_pitch');
expect(engineStrategy('speed_corrected')).toBe('speed_corrected');
});
it('varispeed_semitones is active when speed differs from 1', () => {
expect(isPlaybackEffectActive(true, 'varispeed_semitones', 1.0, 0)).toBe(false);
expect(isPlaybackEffectActive(true, 'varispeed_semitones', 1.25, 0)).toBe(true);
});
it('returns fine slider steps only when opted in', () => {
expect(playbackSpeedStep(false)).toBe(0.05);
expect(playbackSpeedStep(true)).toBe(0.01);
expect(playbackPitchStep(false)).toBe(0.1);
expect(playbackPitchStep(true)).toBe(0.01);
});
it('formats pitch with configurable precision', () => {
expect(formatPitchLabel(3)).toBe('+3.0 st');
expect(formatPitchLabel(-2.5)).toBe('-2.5 st');
expect(formatPitchLabel(0)).toBe('0.0 st');
expect(formatPitchLabel(1.23, 2)).toBe('+1.23 st');
expect(formatPitchLabel(-1.23, 2)).toBe('-1.23 st');
});
}); });
+38 -6
View File
@@ -1,4 +1,8 @@
export type PlaybackStrategy = 'speed_corrected' | 'varispeed' | 'preserve_pitch'; export type PlaybackStrategy =
| 'speed_corrected'
| 'varispeed'
| 'varispeed_semitones'
| 'preserve_pitch';
/** Default strategy: speed only, pitch corrected automatically. */ /** Default strategy: speed only, pitch corrected automatically. */
export const DEFAULT_PLAYBACK_STRATEGY: PlaybackStrategy = 'speed_corrected'; export const DEFAULT_PLAYBACK_STRATEGY: PlaybackStrategy = 'speed_corrected';
@@ -6,9 +10,18 @@ export const DEFAULT_PLAYBACK_STRATEGY: PlaybackStrategy = 'speed_corrected';
export const PLAYBACK_STRATEGIES: PlaybackStrategy[] = [ export const PLAYBACK_STRATEGIES: PlaybackStrategy[] = [
'speed_corrected', 'speed_corrected',
'varispeed', 'varispeed',
'varispeed_semitones',
'preserve_pitch', 'preserve_pitch',
]; ];
/**
* Frontend-only strategies map onto a Rust engine strategy. `varispeed_semitones`
* is a UI lens over varispeed: the user dials semitones, speed = 2^(st/12).
*/
export function engineStrategy(strategy: PlaybackStrategy): PlaybackStrategy {
return strategy === 'varispeed_semitones' ? 'varispeed' : strategy;
}
export const PLAYBACK_SPEED_MIN = 0.5; export const PLAYBACK_SPEED_MIN = 0.5;
export const PLAYBACK_SPEED_MAX = 2.0; export const PLAYBACK_SPEED_MAX = 2.0;
export const PLAYBACK_SPEED_STEP = 0.05; export const PLAYBACK_SPEED_STEP = 0.05;
@@ -17,6 +30,18 @@ export const PLAYBACK_PITCH_MAX = 12;
export const PLAYBACK_PITCH_STEP = 0.1; 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 const PLAYBACK_SPEED_PRESETS = [0.75, 1.0, 1.25, 1.5, 2.0] as const;
/** Fine-precision slider steps (opt-in via Advanced settings). */
export const PLAYBACK_SPEED_STEP_FINE = 0.01;
export const PLAYBACK_PITCH_STEP_FINE = 0.01;
export function playbackSpeedStep(fine: boolean): number {
return fine ? PLAYBACK_SPEED_STEP_FINE : PLAYBACK_SPEED_STEP;
}
export function playbackPitchStep(fine: boolean): number {
return fine ? PLAYBACK_PITCH_STEP_FINE : PLAYBACK_PITCH_STEP;
}
export function clampPlaybackSpeed(speed: number): number { export function clampPlaybackSpeed(speed: number): number {
return Math.max(PLAYBACK_SPEED_MIN, Math.min(PLAYBACK_SPEED_MAX, speed)); return Math.max(PLAYBACK_SPEED_MIN, Math.min(PLAYBACK_SPEED_MAX, speed));
} }
@@ -64,11 +89,18 @@ export function derivedVarispeedSemitones(speed: number): number {
return 12 * Math.log2(speed); return 12 * Math.log2(speed);
} }
export function formatSpeedLabel(speed: number): string { /** Inverse of {@link derivedVarispeedSemitones}: speed multiplier for a semitone offset. */
return `${speed.toFixed(1)}×`; export function varispeedSpeedFromSemitones(semitones: number): number {
return Math.pow(2, semitones / 12);
} }
export function formatPitchLabel(semitones: number): string { export function formatSpeedLabel(speed: number): string {
const rounded = Math.round(semitones * 10) / 10; return `${speed.toFixed(2)}×`;
return rounded > 0 ? `+${rounded.toFixed(1)} st` : `${rounded.toFixed(1)} st`; }
export function formatPitchLabel(semitones: number, decimals = 1): string {
const factor = Math.pow(10, decimals);
const rounded = Math.round(semitones * factor) / factor;
const sign = rounded > 0 ? '+' : '';
return `${sign}${rounded.toFixed(decimals)} st`;
} }
@@ -51,4 +51,13 @@ describe('playbackRateRestart', () => {
{ ...base, strategy: 'preserve_pitch', pitchSemitones: 2 }, { ...base, strategy: 'preserve_pitch', pitchSemitones: 2 },
)).toBe(false); )).toBe(false);
}); });
it('does not restart switching between the two varispeed lenses at the same speed', () => {
expect(usesPreservePlaybackPath('varispeed_semitones')).toBe(false);
expect(playbackPathChanged('varispeed', 'varispeed_semitones')).toBe(false);
expect(shouldRestartPlaybackForRateChange(
{ ...base, strategy: 'varispeed', speed: 1.5 },
{ ...base, strategy: 'varispeed_semitones', speed: 1.5 },
)).toBe(false);
});
}); });
+8 -2
View File
@@ -1,6 +1,10 @@
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState'; import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
import { isPlaybackEffectActive, type PlaybackStrategy } from './playbackRateHelpers'; import {
engineStrategy,
isPlaybackEffectActive,
type PlaybackStrategy,
} from './playbackRateHelpers';
/** Preserve-pitch DSP (worker) vs direct varispeed sample-rate scaling. */ /** Preserve-pitch DSP (worker) vs direct varispeed sample-rate scaling. */
export function usesPreservePlaybackPath(strategy: PlaybackStrategy): boolean { export function usesPreservePlaybackPath(strategy: PlaybackStrategy): boolean {
@@ -23,7 +27,9 @@ export function shouldRestartPlaybackForRateChange(
prev: PlaybackRateSnapshot, prev: PlaybackRateSnapshot,
next: PlaybackRateSnapshot, next: PlaybackRateSnapshot,
): boolean { ): boolean {
if (prev.strategy !== next.strategy) return true; // varispeed ↔ varispeed_semitones share one engine path at the same speed;
// only restart when the underlying engine strategy actually changes.
if (engineStrategy(prev.strategy) !== engineStrategy(next.strategy)) return true;
if (prev.enabled !== next.enabled && isPlaybackEffectActive( if (prev.enabled !== next.enabled && isPlaybackEffectActive(
next.enabled, next.enabled,
next.strategy, next.strategy,