Skip to content

Commit

Permalink
[feature]: Add Equalizer
Browse files Browse the repository at this point in the history
Values from [this message](https://discord.com/channels/922656312888811530/922656312888811535/1162427713567596666).
Works for both Electron and [modern browsers](https://caniuse.com/?search=createBiquadFilter)

Notes:
- it might be nice to have more filters, but I'm not touching that. Getting the Q value wrong leads to bad distortion
- it would also be nice to have low/highshelf for web/mpv, but when I tried the filter was considerably stronger in web audio
- better visualizer/UI? Getting the number input to behave was frustrating enough...
- *please* test this. I am not confident in audio processing.
  • Loading branch information
kgarner7 committed Oct 15, 2023
1 parent 3675146 commit d09a09c
Show file tree
Hide file tree
Showing 10 changed files with 312 additions and 4 deletions.
9 changes: 8 additions & 1 deletion src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand All @@ -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();
Expand Down Expand Up @@ -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();
};
Expand All @@ -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(() => {
Expand Down
34 changes: 31 additions & 3 deletions src/renderer/components/audio-player/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -35,6 +39,7 @@ const getDuration = (ref: any) => {

type WebAudio = {
context: AudioContext;
filters: BiquadFilterNode[];
gain: GainNode;
};

Expand All @@ -54,6 +59,8 @@ export const AudioPlayer = forwardRef(
}: AudioPlayerProps,
ref: any,
) => {
const { bands } = useAudioSettings();

const player1Ref = useRef<ReactPlayer>(null);
const player2Ref = useRef<ReactPlayer>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Divider, Stack } from '@mantine/core';
import { Equalizer } from '/@/renderer/features/settings/components/advanced-audio/equalizer';

export const AdvancedAudioTab = () => {
return (
<Stack spacing="md">
<Equalizer />
<Divider />
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -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)<NumberInputProps>`
& .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 (
<>
<Stack align="center">
{title && (
<Text
mt="sm"
ta="center"
>
{title}
</Text>
)}
<div
ref={ref}
style={{
backgroundColor: 'var(--input-bg)',
height: rem(120),
position: 'relative',
width: rem(16),
}}
>
<div
style={{
backgroundColor: 'var(--primary-color)',
bottom: 0,
height: `${(displayValue + DB_RADIUS) * DB_SCALE}%`,
position: 'absolute',
width: rem(16),
}}
/>
<div
style={{
backgroundColor: 'var(--input-active-fg)',
bottom: `calc(${(displayValue + DB_RADIUS) * DB_SCALE}% - ${rem(8)})`,
height: rem(16),
left: 0,
position: 'absolute',
width: rem(16),
}}
/>
</div>
<Group spacing={5}>
<EqualizerNumberInput
// why a key? Apparently without it the number input would
// not update its value when the slider changed......
key={displayValue}
max={DB_RADIUS}
min={-DB_RADIUS}
precision={1}
radius="xs"
step={0.1}
value={displayValue}
variant="unstyled"
width={rem(54)}
onChange={(val) => {
onChange(Number(val));
}}
/>
db
</Group>
</Stack>
</>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<>
<SettingsOptions
control={
<Button
compact
variant="filled"
onClick={() => resetBand()}
>
Reset to default
</Button>
}
title="Audio Equalization"
/>
<Grid
columns={AudioFrequencies.length}
gutter={20}
justify="center"
>
{settings.bands.map((band, idx) => (
<Grid.Col
key={band.frequency}
lg={2}
sm={3}
span={4}
xl={1}
>
<EqualizerSlider
title={`${band.frequency} Hz`}
value={band.gain}
onChange={(gain) => setBandSetting(gain, idx)}
/>
</Grid.Col>
))}
</Grid>
</>
);
};
14 changes: 14 additions & 0 deletions src/renderer/features/settings/components/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -53,6 +61,9 @@ export const SettingsContent = () => {
<Tabs.Tab value="playback">Playback</Tabs.Tab>
<Tabs.Tab value="hotkeys">Hotkeys</Tabs.Tab>
{isElectron() && <Tabs.Tab value="window">Window</Tabs.Tab>}
{(isElectron() || 'AudioContext' in window) && (
<Tabs.Tab value="advanced-audio">Advanced Audio settings</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="general">
<GeneralTab />
Expand All @@ -68,6 +79,9 @@ export const SettingsContent = () => {
<ApplicationTab />
</Tabs.Panel>
)}
<Tabs.Panel value="advanced-audio">
<AdvancedAudioTab />
</Tabs.Panel>
</Tabs>
</TabContainer>
);
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/hooks/use-previous.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends unknown>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
Loading

0 comments on commit d09a09c

Please sign in to comment.