feat: musical speed controls (#2191)

* feat: musical speed controls
This commit is contained in:
vimae
2026-07-10 05:06:46 +01:00
committed by GitHub
parent e3c2605e1d
commit c64807cf94
4 changed files with 107 additions and 5 deletions
+3 -1
View File
@@ -1184,7 +1184,9 @@
"queryBuilderCustomFields_inputLabel": "Label", "queryBuilderCustomFields_inputLabel": "Label",
"queryBuilderCustomFields_inputTag": "Tag", "queryBuilderCustomFields_inputTag": "Tag",
"queryBuilderCustomFields": "Custom fields", "queryBuilderCustomFields": "Custom fields",
"queryBuilderCustomFields_description": "Add custom fields to use in query builders" "queryBuilderCustomFields_description": "Add custom fields to use in query builders",
"microtonalPitchControls": "Microtonal pitch controls",
"microtonalPitchControls_description": "Add more pitch controls to go between semi-tones"
}, },
"table": { "table": {
"column": { "column": {
@@ -14,6 +14,7 @@ import {
} from '/@/renderer/store'; } from '/@/renderer/store';
import { import {
useCombinedLyricsAndVisualizer, useCombinedLyricsAndVisualizer,
useMicrotonalPitchControls,
usePlaybackSettings, usePlaybackSettings,
useSettingsStore, useSettingsStore,
useSettingsStoreActions, useSettingsStoreActions,
@@ -21,11 +22,14 @@ import {
useShowVisualizerInSidebar, useShowVisualizerInSidebar,
} from '/@/renderer/store/settings.store'; } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Popover } from '/@/shared/components/popover/popover'; import { Popover } from '/@/shared/components/popover/popover';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider'; import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { CrossfadeStyle, PlayerStatus, PlayerStyle, PlayerType } from '/@/shared/types/types'; import { CrossfadeStyle, PlayerStatus, PlayerStyle, PlayerType } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
@@ -93,6 +97,11 @@ export const PlayerConfig = () => {
id: 'playbackSpeed', id: 'playbackSpeed',
label: t('player.playbackSpeed'), label: t('player.playbackSpeed'),
}, },
{
component: !preservePitch ? <PitchControls /> : <></>,
id: 'pitchControls',
label: '',
},
{ {
component: ( component: (
<Switch <Switch
@@ -364,16 +373,15 @@ export const PlaybackSpeedSlider = () => {
() => (value: number) => { () => (value: number) => {
const bpmValue = Number(bpm); const bpmValue = Number(bpm);
if (bpmValue > 0) { if (bpmValue > 0) {
return `${value} x / ${(bpmValue * value).toFixed(1)} BPM`; return `${value.toFixed(2)} x / ${(bpmValue * value).toFixed(1)} BPM`;
} }
return `${value} x`; return `${value.toFixed(2)} x`;
}, },
[bpm], [bpm],
); );
return ( return (
<Slider <Slider
defaultValue={speed}
label={formatPlaybackSpeedSliderLabel} label={formatPlaybackSpeedSliderLabel}
marks={[ marks={[
{ label: '0.5', value: 0.5 }, { label: '0.5', value: 0.5 },
@@ -386,14 +394,81 @@ export const PlaybackSpeedSlider = () => {
]} ]}
max={2} max={2}
min={0.5} min={0.5}
onChangeEnd={setSpeed} onChange={setSpeed}
onDoubleClick={() => setSpeed(1)} onDoubleClick={() => setSpeed(1)}
step={0.01} step={0.01}
styles={{ styles={{
markLabel: {}, markLabel: {},
root: {}, root: {},
}} }}
value={speed}
w="100%" w="100%"
/> />
); );
}; };
export const PitchControls = () => {
const microtonal = useMicrotonalPitchControls();
const speed = usePlayerSpeed();
const { setSpeed } = usePlayerActions();
const speedToPitch = (speed: number) => {
return 12 * Math.log2(speed);
};
const pitchToSpeed = (pitch: number) => {
return 2 ** (pitch / 12);
};
const adjustMusicalSpeed = (adjustment: number) => {
const curPitch = speedToPitch(speed);
const newSpeed = pitchToSpeed(curPitch + adjustment);
setSpeed(newSpeed);
};
return (
<Group gap={microtonal ? 'xs' : 'md'} my="sm" w="100%" wrap="nowrap">
<Button
aria-label="-1 semitone"
fullWidth
onClick={() => adjustMusicalSpeed(-1)}
size="compact-xs"
>
-1st
</Button>
{microtonal && (
<Button
aria-label="-10 cents"
fullWidth
onClick={() => adjustMusicalSpeed(-0.1)}
size="compact-xs"
>
-10ct
</Button>
)}
<Text size="xs" style={{ fontFamily: 'monospace' }} ta="center" w="60px">
{speed.toFixed(2)}% {speedToPitch(speed) > 0 && '+'}
{speedToPitch(speed) == 0 && '±'}
{speedToPitch(speed).toFixed(2)}st
</Text>
{microtonal && (
<Button
aria-label="+10 cents"
fullWidth
onClick={() => adjustMusicalSpeed(0.1)}
size="compact-xs"
>
+10ct
</Button>
)}
<Button
aria-label="+1 semitone"
fullWidth
onClick={() => adjustMusicalSpeed(1)}
size="compact-xs"
>
+1st
</Button>
</Group>
);
};
@@ -501,6 +501,26 @@ export const ControlSettings = memo(() => {
}, },
] ]
: []), : []),
{
control: (
<Switch
defaultChecked={settings.microtonalPitchControls}
onChange={(e) =>
setSettings({
general: {
...settings,
microtonalPitchControls: e.currentTarget.checked,
},
})
}
/>
),
description: t('setting.microtonalPitchControls', {
context: 'description',
}),
isHidden: false,
title: t('setting.microtonalPitchControls'),
},
]; ];
return <SettingsSection options={controlOptions} title={t('page.setting.controls')} />; return <SettingsSection options={controlOptions} title={t('page.setting.controls')} />;
+5
View File
@@ -505,6 +505,7 @@ export const GeneralSettingsSchema = z.object({
lastFM: z.boolean(), lastFM: z.boolean(),
lastfmApiKey: z.string(), lastfmApiKey: z.string(),
listenBrainz: z.boolean(), listenBrainz: z.boolean(),
microtonalPitchControls: z.boolean(),
musicBrainz: z.boolean(), musicBrainz: z.boolean(),
nativeAspectRatio: z.boolean(), nativeAspectRatio: z.boolean(),
nativeSpotify: z.boolean(), nativeSpotify: z.boolean(),
@@ -1201,6 +1202,7 @@ const initialState: SettingsState = {
lastFM: true, lastFM: true,
lastfmApiKey: '', lastfmApiKey: '',
listenBrainz: true, listenBrainz: true,
microtonalPitchControls: false,
musicBrainz: true, musicBrainz: true,
nativeAspectRatio: false, nativeAspectRatio: false,
nativeSpotify: false, nativeSpotify: false,
@@ -2830,3 +2832,6 @@ export const useButterchurnSettings = () => {
}; };
}, shallow); }, shallow);
}; };
export const useMicrotonalPitchControls = () =>
useSettingsStore((state) => state.general.microtonalPitchControls, shallow);