diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 6d72976ab..215291cea 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -25,6 +25,7 @@ import { getMpvProperties } from '/@/renderer/features/settings/components/playb import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store'; import { PlaybackType, PlayerStatus } from '/@/renderer/types'; import '@ag-grid-community/styles/ag-grid.css'; +import { bandsToAudioFilter } from '/@/renderer/utils'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); @@ -38,6 +39,7 @@ const remote = isElectron() ? window.electron.remote : null; export const App = () => { const theme = useTheme(); const contentFont = useSettingsStore((state) => state.general.fontContent); + const audioBands = useSettingsStore((state) => state.audio.bands); const { type: playbackType } = usePlaybackSettings(); const { bindings } = useHotkeySettings(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); @@ -66,12 +68,15 @@ export const App = () => { ...getMpvProperties(useSettingsStore.getState().playback.mpvProperties), }; + const volume = properties.volume; + properties.af = bandsToAudioFilter(audioBands); + mpvPlayer?.initialize({ extraParameters, properties, }); - mpvPlayer?.volume(properties.volume); + mpvPlayer?.volume(volume); } mpvPlayer?.restoreQueue(); }; @@ -85,6 +90,8 @@ export const App = () => { mpvPlayer?.stop(); mpvPlayer?.cleanup(); }; + // audioBands should NOT cause a cleanup of this function + // eslint-disable-next-line react-hooks/exhaustive-deps }, [clearQueue, playbackType]); useEffect(() => { diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index 541620779..6a277c9c1 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -7,7 +7,11 @@ import { crossfadeHandler, gaplessHandler, } from '/@/renderer/components/audio-player/utils/list-handlers'; -import { useSettingsStore } from '/@/renderer/store/settings.store'; +import { + AudioFrequencies, + useAudioSettings, + useSettingsStore, +} from '/@/renderer/store/settings.store'; import type { CrossfadeStyle } from '/@/renderer/types'; import { PlaybackStyle, PlayerStatus } from '/@/renderer/types'; @@ -35,6 +39,7 @@ const getDuration = (ref: any) => { type WebAudio = { context: AudioContext; + filters: BiquadFilterNode[]; gain: GainNode; }; @@ -54,6 +59,8 @@ export const AudioPlayer = forwardRef( }: AudioPlayerProps, ref: any, ) => { + const { bands } = useAudioSettings(); + const player1Ref = useRef(null); const player2Ref = useRef(null); const [isTransitioning, setIsTransitioning] = useState(false); @@ -122,9 +129,22 @@ export const AudioPlayer = forwardRef( sampleRate: playback.audioSampleRateHz || undefined, }); const gain = context.createGain(); - gain.connect(context.destination); + const filters: BiquadFilterNode[] = []; + + let priorNode = gain; + + for (let i = 0; i < AudioFrequencies.length; i += 1) { + const filter = context.createBiquadFilter(); + filter.type = 'peaking'; + filter.Q.value = AudioFrequencies[i].quality; + priorNode.connect(filter); + priorNode = filter; + filters.push(filter); + } - setWebAudio({ context, gain }); + priorNode.connect(context.destination); + + setWebAudio({ context, filters, gain }); return () => { return context.close(); @@ -135,6 +155,14 @@ export const AudioPlayer = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (webAudio) { + bands.forEach((band, idx) => { + webAudio.filters[idx].gain.value = band.gain; + }); + } + }, [bands, webAudio]); + useImperativeHandle(ref, () => ({ get player1() { return player1Ref?.current; diff --git a/src/renderer/features/settings/components/advanced-audio/advanced-audio-tab.tsx b/src/renderer/features/settings/components/advanced-audio/advanced-audio-tab.tsx new file mode 100644 index 000000000..3082b98ea --- /dev/null +++ b/src/renderer/features/settings/components/advanced-audio/advanced-audio-tab.tsx @@ -0,0 +1,11 @@ +import { Divider, Stack } from '@mantine/core'; +import { Equalizer } from '/@/renderer/features/settings/components/advanced-audio/equalizer'; + +export const AdvancedAudioTab = () => { + return ( + + + + + ); +}; diff --git a/src/renderer/features/settings/components/advanced-audio/eqalizer-slider.tsx b/src/renderer/features/settings/components/advanced-audio/eqalizer-slider.tsx new file mode 100644 index 000000000..a95c715c1 --- /dev/null +++ b/src/renderer/features/settings/components/advanced-audio/eqalizer-slider.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { Group, NumberInputProps, Stack, rem } from '@mantine/core'; +import { useMove, usePrevious } from '@mantine/hooks'; +import { Text } from '/@/renderer/components/text'; +import { NumberInput } from '/@/renderer/components'; +import styled from 'styled-components'; + +interface VerticalSliderProps { + onChange: (value: number) => void; + + title?: string; + value: number; +} + +export const DB_RADIUS = 6; +const DB_DIAMATER = 2 * DB_RADIUS; +const DB_SCALE = 100 / DB_DIAMATER; + +const EqualizerNumberInput = styled(NumberInput)` + & .mantine-NumberInput-input { + text-align: center; + } +`; + +export const EqualizerSlider = ({ value, title, onChange }: VerticalSliderProps) => { + const [seekingValue, setValue] = useState(value); + + const { ref, active } = useMove(({ y }) => { + const value = Math.round((0.5 - y) * DB_DIAMATER * 10) / 10; + setValue(value); + }); + + const wasActive = usePrevious(active); + + useEffect(() => { + if (wasActive && !active) { + onChange(seekingValue); + } + }, [active, onChange, seekingValue, wasActive]); + + const displayValue = active ? seekingValue : value; + + return ( + <> + + {title && ( + + {title} + + )} +
+
+
+
+ + { + onChange(Number(val)); + }} + /> + db + + + + ); +}; diff --git a/src/renderer/features/settings/components/advanced-audio/equalizer.tsx b/src/renderer/features/settings/components/advanced-audio/equalizer.tsx new file mode 100644 index 000000000..dff5f16de --- /dev/null +++ b/src/renderer/features/settings/components/advanced-audio/equalizer.tsx @@ -0,0 +1,94 @@ +import { Grid } from '@mantine/core'; +import { EqualizerSlider } from '/@/renderer/features/settings/components/advanced-audio/eqalizer-slider'; +import { AudioFrequencies, useAudioSettings, useSettingsStoreActions } from '/@/renderer/store'; +import isElectron from 'is-electron'; +import { useCallback } from 'react'; +import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; +import { Button } from '/@/renderer/components'; +import { bandsToAudioFilter } from '/@/renderer/utils'; + +const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; + +export const Equalizer = () => { + const settings = useAudioSettings(); + const { setSettings } = useSettingsStoreActions(); + + const setBandSetting = useCallback( + (gain: number, index: number) => { + const bands = [...settings.bands]; + bands[index].gain = gain; + + setSettings({ + audio: { + ...settings, + bands, + }, + }); + + if (mpvPlayer) { + mpvPlayer.setProperties({ + af: bandsToAudioFilter(bands), + }); + } + }, + [setSettings, settings], + ); + + const resetBand = useCallback(() => { + const bands = AudioFrequencies.map((info) => ({ + ...info, + gain: 0, + })); + + setSettings({ + audio: { + ...settings, + bands, + }, + }); + + if (mpvPlayer) { + mpvPlayer.setProperties({ + af: bandsToAudioFilter(bands), + }); + } + }, [setSettings, settings]); + + return ( + <> + resetBand()} + > + Reset to default + + } + title="Audio Equalization" + /> + + {settings.bands.map((band, idx) => ( + + setBandSetting(gain, idx)} + /> + + ))} + + + ); +}; diff --git a/src/renderer/features/settings/components/settings-content.tsx b/src/renderer/features/settings/components/settings-content.tsx index 7d4864e08..a21b55e77 100644 --- a/src/renderer/features/settings/components/settings-content.tsx +++ b/src/renderer/features/settings/components/settings-content.tsx @@ -28,6 +28,14 @@ const HotkeysTab = lazy(() => })), ); +const AdvancedAudioTab = lazy(() => + import('/@/renderer/features/settings/components/advanced-audio/advanced-audio-tab').then( + (module) => ({ + default: module.AdvancedAudioTab, + }), + ), +); + const TabContainer = styled.div` width: 100%; height: 100%; @@ -53,6 +61,9 @@ export const SettingsContent = () => { Playback Hotkeys {isElectron() && Window} + {(isElectron() || 'AudioContext' in window) && ( + Advanced Audio settings + )} @@ -68,6 +79,9 @@ export const SettingsContent = () => { )} + + + ); diff --git a/src/renderer/hooks/use-previous.ts b/src/renderer/hooks/use-previous.ts new file mode 100644 index 000000000..2283f18ed --- /dev/null +++ b/src/renderer/hooks/use-previous.ts @@ -0,0 +1,10 @@ +import { useEffect, useRef } from 'react'; + +// from https://stackoverflow.com/questions/53446020/how-to-compare-oldvalues-and-newvalues-on-react-hooks-useeffect +export const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 60ebda84f..7b6daa930 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -110,7 +110,27 @@ export enum BindingActions { ZOOM_OUT = 'zoomOut', } +export type BandInfo = { + frequency: number; + quality: number; +}; + +export type AudioBand = { + gain: number; +} & BandInfo; + +export const AudioFrequencies: BandInfo[] = [ + { frequency: 63, quality: 0.35555555555555557 }, // 20-200 + { frequency: 400, quality: 0.6666666666666666 }, // 200-800 + { frequency: 1250, quality: 1.0416666666666667 }, // 800-2000 + { frequency: 2830, quality: 1.415 }, // 2000 - 4000 + { frequency: 5600, quality: 1.4 }, // 4000-8000 + { frequency: 12500, quality: 1.0416666666666667 }, // 8000-20000 +]; export interface SettingsState { + audio: { + bands: AudioBand[]; + }; general: { defaultFullPlaylist: boolean; followSystemTheme: boolean; @@ -208,6 +228,12 @@ const getPlatformDefaultWindowBarStyle = (): Platform => { const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle(); const initialState: SettingsState = { + audio: { + bands: AudioFrequencies.map((info) => ({ + ...info, + gain: 0, + })), + }, general: { defaultFullPlaylist: true, followSystemTheme: false, @@ -538,3 +564,5 @@ export const useMpvSettings = () => export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow); export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow); + +export const useAudioSettings = () => useSettingsStore((state) => state.audio, shallow); diff --git a/src/renderer/utils/bands-to-audio-filter.ts b/src/renderer/utils/bands-to-audio-filter.ts new file mode 100644 index 000000000..a81b59f89 --- /dev/null +++ b/src/renderer/utils/bands-to-audio-filter.ts @@ -0,0 +1,10 @@ +import type { AudioBand } from '/@/renderer/store'; + +export const bandsToAudioFilter = (bands: AudioBand[]): string => { + return bands + .map( + (info) => + `lavfi=[equalizer=f=${info.frequency}:width_type=o:w=${info.quality}:g=${info.gain}]`, + ) + .join(','); +}; diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 63f206008..971bddd40 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -7,3 +7,4 @@ export * from './get-header-color'; export * from './parse-search-params'; export * from './format-duration-string'; export * from './rgb-to-rgba'; +export * from './bands-to-audio-filter';