mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-22 20:07:42 +02:00
Added Graphic EQ and Compressor (#1972)
* Adding Graphic Eq and Compressor with presets
This commit is contained in:
Regular → Executable
+1
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env xdg-open
|
||||
[Desktop Entry]
|
||||
Name=Feishin
|
||||
GenericName=Music player
|
||||
|
||||
@@ -117,6 +117,15 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
properties,
|
||||
});
|
||||
|
||||
// Apply EQ and compressor filters after MPV has initialized
|
||||
const { compressor, equalizer } = useSettingsStore.getState().playback;
|
||||
const { buildMpvAudioFilters } =
|
||||
await import('/@/renderer/features/settings/components/playback/mpv-audio-filters');
|
||||
const filterStr = buildMpvAudioFilters(equalizer, compressor);
|
||||
if (filterStr) {
|
||||
mpvPlayer?.setProperties({ af: filterStr });
|
||||
}
|
||||
|
||||
// After initialization, populate the queue if currentSrc is available
|
||||
// Don't override queue if radio is active
|
||||
const radioState = useRadioStore.getState();
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
|
||||
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
import {
|
||||
updateQueueFavorites,
|
||||
updateQueueRatings,
|
||||
@@ -196,11 +197,66 @@ const AudioPlayersContent = ({
|
||||
}
|
||||
|
||||
const gains = [context.createGain(), context.createGain()];
|
||||
for (const gain of gains) {
|
||||
gain.connect(context.destination);
|
||||
|
||||
// Build DSP chain from persisted settings so EQ/compressor
|
||||
// are active immediately on first playback, not just after
|
||||
// the user opens the settings panel.
|
||||
const { compressor, equalizer } = useSettingsStore.getState().playback;
|
||||
|
||||
// Preamp gain — converts dB to linear
|
||||
const preampGain = context.createGain();
|
||||
preampGain.gain.value = equalizer.enabled ? Math.pow(10, equalizer.preamp / 20) : 1;
|
||||
|
||||
// One peaking BiquadFilterNode per EQ band
|
||||
const eqFilters: BiquadFilterNode[] = equalizer.bands.map((band) => {
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = 'peaking';
|
||||
filter.frequency.value = band.freq;
|
||||
// Q of 1.41 gives roughly 1-octave bandwidth per band
|
||||
filter.Q.value = 1.41;
|
||||
filter.gain.value = equalizer.enabled ? band.gain : 0;
|
||||
return filter;
|
||||
});
|
||||
|
||||
// DynamicsCompressorNode — always present, pass-through when disabled
|
||||
// (ratio=1, threshold=0 = mathematically transparent)
|
||||
const compressorNode = context.createDynamicsCompressor();
|
||||
if (compressor.enabled) {
|
||||
compressorNode.threshold.value = compressor.threshold;
|
||||
compressorNode.ratio.value = compressor.ratio;
|
||||
compressorNode.attack.value = compressor.attack / 1000;
|
||||
compressorNode.release.value = compressor.release / 1000;
|
||||
compressorNode.knee.value = compressor.knee;
|
||||
} else {
|
||||
compressorNode.threshold.value = 0;
|
||||
compressorNode.ratio.value = 1;
|
||||
compressorNode.attack.value = 0;
|
||||
compressorNode.release.value = 0.25;
|
||||
compressorNode.knee.value = 0;
|
||||
}
|
||||
|
||||
setWebAudio!({ context, gains });
|
||||
// Wire: each gain → preamp → eq[0] → eq[1] → ... → compressor → destination
|
||||
for (const gain of gains) {
|
||||
gain.connect(preampGain);
|
||||
}
|
||||
|
||||
if (eqFilters.length > 0) {
|
||||
preampGain.connect(eqFilters[0]);
|
||||
for (let i = 0; i < eqFilters.length - 1; i++) {
|
||||
eqFilters[i].connect(eqFilters[i + 1]);
|
||||
}
|
||||
eqFilters[eqFilters.length - 1].connect(compressorNode);
|
||||
} else {
|
||||
preampGain.connect(compressorNode);
|
||||
}
|
||||
|
||||
compressorNode.connect(context.destination);
|
||||
|
||||
setWebAudio!({
|
||||
context,
|
||||
dsp: { compressor: compressorNode, eqFilters, preampGain },
|
||||
gains,
|
||||
});
|
||||
}
|
||||
|
||||
// Intentionally ignore the sample rate dependency, as it makes things really messy
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
import { useMove } from '@mantine/hooks';
|
||||
import isElectron from 'is-electron';
|
||||
import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildMpvAudioFilters,
|
||||
type CompressorSettings,
|
||||
type EqSettings as EqSettingsType,
|
||||
} from './mpv-audio-filters';
|
||||
|
||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||
import {
|
||||
SettingOption,
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Slider } from '/@/shared/components/slider/slider';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
|
||||
const BAND_LABELS = [
|
||||
'31.5',
|
||||
'63',
|
||||
'125',
|
||||
'250',
|
||||
'500',
|
||||
'1k',
|
||||
'2k',
|
||||
'3k',
|
||||
'4k',
|
||||
'6.3k',
|
||||
'10k',
|
||||
'16k',
|
||||
];
|
||||
|
||||
const EQ_MIN = -12;
|
||||
const EQ_MAX = 12;
|
||||
const EQ_STEP = 0.5;
|
||||
|
||||
// ─── Built-in EQ presets ──────────────────────────────────────────────────────
|
||||
const EQ_PRESETS: Record<string, number[]> = {
|
||||
Acoustic: [2, 2, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1],
|
||||
'Bass Boost': [6, 5, 4, 2, 1, 0, 0, 0, 0, 0, 0, 0],
|
||||
'Bass Cut': [-6, -5, -4, -2, -1, 0, 0, 0, 0, 0, 0, 0],
|
||||
Classical: [0, 0, 0, 0, 0, 0, -1, -1, 0, 0, 0, -3],
|
||||
Electronic: [4, 3, 1, 0, -1, 0, 1, 0, 0, 2, 3, 4],
|
||||
Flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
'Hip-Hop': [5, 4, 2, 1, 0, -1, 0, 1, 0, 1, 2, 3],
|
||||
Jazz: [2, 1, 0, 1, 2, 2, 1, 0, 0, 1, 2, 2],
|
||||
Loudness: [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 6],
|
||||
Pop: [-1, 0, 2, 3, 3, 2, 0, -1, -1, 0, 0, 0],
|
||||
Rock: [3, 2, 1, 0, -1, 0, 1, 2, 2, 2, 3, 3],
|
||||
'Treble Boost': [0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6],
|
||||
'Treble Cut': [0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -6],
|
||||
'V-Shape': [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 5],
|
||||
'Vocal Boost': [-1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1],
|
||||
};
|
||||
|
||||
// ─── Built-in compressor presets ─────────────────────────────────────────────
|
||||
type CompressorPreset = Omit<CompressorSettings, 'enabled'>;
|
||||
const COMP_PRESETS: Record<string, CompressorPreset> = {
|
||||
Broadcast: { attack: 15, knee: 3, makeup: 6, ratio: 5, release: 200, threshold: -20 },
|
||||
Default: { attack: 20, knee: 2.83, makeup: 6, ratio: 4, release: 250, threshold: -24 },
|
||||
Gentle: { attack: 50, knee: 6, makeup: 2, ratio: 1.5, release: 500, threshold: -15 },
|
||||
Heavy: { attack: 10, knee: 2, makeup: 8, ratio: 8, release: 150, threshold: -30 },
|
||||
Light: { attack: 30, knee: 4, makeup: 3, ratio: 2, release: 400, threshold: -18 },
|
||||
Limiter: { attack: 1, knee: 1, makeup: 0, ratio: 20, release: 100, threshold: -3 },
|
||||
'Loud Master': { attack: 5, knee: 2, makeup: 10, ratio: 6, release: 100, threshold: -28 },
|
||||
Moderate: { attack: 20, knee: 3, makeup: 5, ratio: 4, release: 300, threshold: -24 },
|
||||
};
|
||||
|
||||
// ─── Storage helpers ──────────────────────────────────────────────────────────
|
||||
const LS_EQ_PRESETS = 'feishin_eq_custom_presets';
|
||||
const LS_COMP_PRESETS = 'feishin_comp_custom_presets';
|
||||
|
||||
function loadCustomPresets<T>(key: string): Record<string, T> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCustomPresets<T>(key: string, presets: Record<string, T>) {
|
||||
localStorage.setItem(key, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
// ─── Vertical EQ band slider ──────────────────────────────────────────────────
|
||||
// Mantine v8 does not include orientation="vertical" on Slider.
|
||||
// We use useMove from @mantine/hooks (the Mantine-recommended approach for
|
||||
// vertical sliders in v8) so drag direction is correct — dragging up
|
||||
// increases the value, dragging down decreases it.
|
||||
// Styling uses the same CSS variables as the existing Slider module CSS
|
||||
// so it inherits the app theme correctly.
|
||||
const TRACK_H = 120; // px — rendered height of the vertical track
|
||||
const THUMB_R = 6; // px — thumb radius
|
||||
|
||||
function EqBandSlider({
|
||||
gain,
|
||||
label,
|
||||
onChangeEnd,
|
||||
}: {
|
||||
freq: number;
|
||||
gain: number;
|
||||
label: string;
|
||||
onChangeEnd: (v: number) => void;
|
||||
}) {
|
||||
// currentGain drives the live visual during dragging.
|
||||
// It is synced from the `gain` prop when external changes arrive
|
||||
// (preset applied, reset).
|
||||
const [currentGain, setCurrentGain] = useState(gain);
|
||||
const currentGainRef = useRef(currentGain);
|
||||
|
||||
// Stable ref so onScrubEnd always calls the latest onChangeEnd even
|
||||
// though useMove's refCallback closes over the initial handlers object.
|
||||
const onChangeEndRef = useRef(onChangeEnd);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentGain(gain);
|
||||
currentGainRef.current = gain;
|
||||
}, [gain]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeEndRef.current = onChangeEnd;
|
||||
}, [onChangeEnd]);
|
||||
|
||||
// handleMove must be stable (empty deps) so useMove's internal
|
||||
// refCallback is only created once and listeners are not re-bound
|
||||
// on every render.
|
||||
const handleMove = useCallback(({ y }: { x: number; y: number }) => {
|
||||
// useMove gives y=0 at the top of the element and y=1 at the bottom.
|
||||
// Invert so dragging upward increases the value.
|
||||
const raw = EQ_MAX - y * (EQ_MAX - EQ_MIN);
|
||||
const stepped = Math.round(raw / EQ_STEP) * EQ_STEP;
|
||||
const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, stepped));
|
||||
setCurrentGain(clamped);
|
||||
currentGainRef.current = clamped;
|
||||
}, []);
|
||||
|
||||
const { active, ref } = useMove(handleMove, {
|
||||
onScrubEnd: () => {
|
||||
// Access ref so the latest onChangeEnd is called even though
|
||||
// this handler was captured in the initial closure.
|
||||
onChangeEndRef.current(currentGainRef.current);
|
||||
},
|
||||
});
|
||||
|
||||
// Percentage from bottom: 0 = min (-12dB), 100 = max (+12dB)
|
||||
const thumbPct = ((currentGain - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100;
|
||||
const zeroPct = ((0 - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100; // 50 for ±12 range
|
||||
|
||||
// Fill spans between zero line and thumb, regardless of direction
|
||||
const fillBottomPct = Math.min(thumbPct, zeroPct);
|
||||
const fillTopPct = 100 - Math.max(thumbPct, zeroPct);
|
||||
|
||||
return (
|
||||
<Stack align="center" gap={4}>
|
||||
{/* Manual value input — keyed on gain prop so it remounts
|
||||
when an external change arrives (preset, reset) */}
|
||||
<NumberInput
|
||||
defaultValue={gain}
|
||||
hideControls
|
||||
key={gain}
|
||||
max={EQ_MAX}
|
||||
min={EQ_MIN}
|
||||
onBlur={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value);
|
||||
if (!isNaN(val)) {
|
||||
const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val));
|
||||
setCurrentGain(clamped);
|
||||
currentGainRef.current = clamped;
|
||||
onChangeEndRef.current(clamped);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
size="xs"
|
||||
step={EQ_STEP}
|
||||
w={52}
|
||||
/>
|
||||
|
||||
{/* Vertical track — useMove attaches pointer listeners here */}
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
background: 'var(--mantine-color-default-border)',
|
||||
borderRadius: 4,
|
||||
cursor: active ? 'grabbing' : 'grab',
|
||||
height: TRACK_H,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
width: 8,
|
||||
}}
|
||||
>
|
||||
{/* Coloured fill between the zero line and the thumb */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-filled)',
|
||||
borderRadius: 2,
|
||||
bottom: `${fillBottomPct}%`,
|
||||
left: 1,
|
||||
position: 'absolute',
|
||||
right: 1,
|
||||
top: `${fillTopPct}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Zero-line tick mark */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--mantine-color-gray-5)',
|
||||
bottom: `${zeroPct}%`,
|
||||
height: 1,
|
||||
left: -2,
|
||||
position: 'absolute',
|
||||
right: -2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Thumb — centre is at thumbPct% from the bottom */}
|
||||
<div
|
||||
style={{
|
||||
// bottom: calc(thumbPct% - THUMB_R) places the
|
||||
// thumb centre exactly at thumbPct% of track height
|
||||
background: 'var(--theme-colors-foreground)',
|
||||
border: '2px solid var(--mantine-color-default-border)',
|
||||
borderRadius: '50%',
|
||||
bottom: `calc(${thumbPct}% - ${THUMB_R}px)`,
|
||||
height: THUMB_R * 2,
|
||||
left: '50%',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
transform: 'translateX(-50%)',
|
||||
width: THUMB_R * 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frequency label */}
|
||||
<Text isMuted size="xs" style={{ textAlign: 'center' }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export const EqSettings = memo(() => {
|
||||
const settings = usePlaybackSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
// Ref pattern to avoid stale closure when reading webAudio DSP nodes.
|
||||
// webAudio?.dsp is undefined at callback creation time; the closure
|
||||
// would capture that undefined even after AudioContext initialises.
|
||||
const webAudioContext = useContext(WebAudioContext);
|
||||
const webAudioContextRef = useRef(webAudioContext);
|
||||
useEffect(() => {
|
||||
webAudioContextRef.current = webAudioContext;
|
||||
}, [webAudioContext]);
|
||||
|
||||
// Custom preset state — stored in localStorage separately from main store
|
||||
const [customEqPresets, setCustomEqPresets] = useState<Record<string, number[]>>(() =>
|
||||
loadCustomPresets<number[]>(LS_EQ_PRESETS),
|
||||
);
|
||||
const [customCompPresets, setCustomCompPresets] = useState<Record<string, CompressorPreset>>(
|
||||
() => loadCustomPresets<CompressorPreset>(LS_COMP_PRESETS),
|
||||
);
|
||||
const [saveEqName, setSaveEqName] = useState('');
|
||||
const [saveCompName, setSaveCompName] = useState('');
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(eq: EqSettingsType, compressor: CompressorSettings) => {
|
||||
// ── MPV player ────────────────────────────────────────────────
|
||||
if (settings.type === PlayerType.LOCAL) {
|
||||
const filterStr = buildMpvAudioFilters(eq, compressor);
|
||||
mpvPlayer?.setProperties({ af: filterStr });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Web Audio player ──────────────────────────────────────────
|
||||
// Read from ref so we always get the current AudioContext state,
|
||||
// not the stale value captured when this callback was created.
|
||||
const dsp = webAudioContextRef.current.webAudio?.dsp;
|
||||
if (!dsp) return;
|
||||
|
||||
// Mutations to Web Audio API AudioParam values are intentional
|
||||
// side effects on the live audio graph, not React state mutations.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
dsp.preampGain.gain.value = eq.enabled ? Math.pow(10, eq.preamp / 20) : 1;
|
||||
|
||||
dsp.eqFilters.forEach((filter, i) => {
|
||||
const band = eq.bands[i];
|
||||
if (band) {
|
||||
filter.gain.value = eq.enabled ? band.gain : 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (compressor.enabled) {
|
||||
dsp.compressor.threshold.value = compressor.threshold;
|
||||
dsp.compressor.ratio.value = compressor.ratio;
|
||||
dsp.compressor.attack.value = compressor.attack / 1000;
|
||||
dsp.compressor.release.value = compressor.release / 1000;
|
||||
dsp.compressor.knee.value = compressor.knee;
|
||||
} else {
|
||||
dsp.compressor.threshold.value = 0;
|
||||
dsp.compressor.ratio.value = 1;
|
||||
dsp.compressor.attack.value = 0;
|
||||
dsp.compressor.release.value = 0.25;
|
||||
dsp.compressor.knee.value = 0;
|
||||
}
|
||||
},
|
||||
// settings.type is the only reactive dep — webAudioContextRef is a
|
||||
// stable ref that always holds the latest context value.
|
||||
[settings.type],
|
||||
);
|
||||
|
||||
// Re-apply filters when switching to Web Audio so DSP nodes reflect
|
||||
// persisted settings immediately without requiring a slider interaction.
|
||||
useEffect(() => {
|
||||
if (settings.type === PlayerType.WEB) {
|
||||
applyFilters(settings.equalizer, settings.compressor);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.type]);
|
||||
|
||||
// ── EQ handlers ──────────────────────────────────────────────────────────
|
||||
const handleEqToggle = (enabled: boolean) => {
|
||||
const newEq = { ...settings.equalizer, enabled };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const handlePreampChangeEnd = (preamp: number) => {
|
||||
const newEq = { ...settings.equalizer, preamp };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const handleBandChangeEnd = (index: number, gain: number) => {
|
||||
const newBands = settings.equalizer.bands.map((b, i) => (i === index ? { ...b, gain } : b));
|
||||
const newEq = { ...settings.equalizer, bands: newBands };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const applyEqPreset = (gains: number[]) => {
|
||||
const newBands = settings.equalizer.bands.map((b, i) => ({ ...b, gain: gains[i] ?? 0 }));
|
||||
const newEq = { ...settings.equalizer, bands: newBands, preamp: 0 };
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
const handleSaveEqPreset = () => {
|
||||
const name = saveEqName.trim();
|
||||
if (!name) return;
|
||||
const gains = settings.equalizer.bands.map((b) => b.gain);
|
||||
const updated = { ...customEqPresets, [name]: gains };
|
||||
setCustomEqPresets(updated);
|
||||
saveCustomPresets(LS_EQ_PRESETS, updated);
|
||||
setSaveEqName('');
|
||||
};
|
||||
|
||||
const handleDeleteEqPreset = (name: string) => {
|
||||
const updated = { ...customEqPresets };
|
||||
delete updated[name];
|
||||
setCustomEqPresets(updated);
|
||||
saveCustomPresets(LS_EQ_PRESETS, updated);
|
||||
};
|
||||
|
||||
const handleResetEq = () => {
|
||||
const newEq = {
|
||||
...settings.equalizer,
|
||||
bands: settings.equalizer.bands.map((b) => ({ ...b, gain: 0 })),
|
||||
preamp: 0,
|
||||
};
|
||||
setSettings({ playback: { equalizer: newEq } });
|
||||
applyFilters(newEq, settings.compressor);
|
||||
};
|
||||
|
||||
// ── Compressor handlers ───────────────────────────────────────────────────
|
||||
const handleCompToggle = (enabled: boolean) => {
|
||||
const newComp = { ...settings.compressor, enabled };
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
const handleCompChangeEnd = (key: keyof CompressorSettings, value: number) => {
|
||||
const newComp = { ...settings.compressor, [key]: value };
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
const applyCompPreset = (preset: CompressorPreset) => {
|
||||
const newComp = { ...settings.compressor, ...preset };
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
const handleSaveCompPreset = () => {
|
||||
const name = saveCompName.trim();
|
||||
if (!name) return;
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(settings.compressor).filter(([key]) => key !== 'enabled'),
|
||||
) as CompressorPreset;
|
||||
const updated = { ...customCompPresets, [name]: rest };
|
||||
setCustomCompPresets(updated);
|
||||
saveCustomPresets(LS_COMP_PRESETS, updated);
|
||||
setSaveCompName('');
|
||||
};
|
||||
|
||||
const handleDeleteCompPreset = (name: string) => {
|
||||
const updated = { ...customCompPresets };
|
||||
delete updated[name];
|
||||
setCustomCompPresets(updated);
|
||||
saveCustomPresets(LS_COMP_PRESETS, updated);
|
||||
};
|
||||
|
||||
const handleResetComp = () => {
|
||||
const newComp = {
|
||||
attack: 20,
|
||||
enabled: settings.compressor.enabled,
|
||||
knee: 2.83,
|
||||
makeup: 6,
|
||||
ratio: 4,
|
||||
release: 250,
|
||||
threshold: -24,
|
||||
};
|
||||
setSettings({ playback: { compressor: newComp } });
|
||||
applyFilters(settings.equalizer, newComp);
|
||||
};
|
||||
|
||||
// ── Preset select data ────────────────────────────────────────────────────
|
||||
const eqPresetSelectData = [
|
||||
{
|
||||
group: 'Built-in',
|
||||
items: Object.keys(EQ_PRESETS).map((name) => ({ label: name, value: name })),
|
||||
},
|
||||
...(Object.keys(customEqPresets).length > 0
|
||||
? [
|
||||
{
|
||||
group: 'Custom',
|
||||
items: Object.keys(customEqPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const compPresetSelectData = [
|
||||
{
|
||||
group: 'Built-in',
|
||||
items: Object.keys(COMP_PRESETS).map((name) => ({ label: name, value: name })),
|
||||
},
|
||||
...(Object.keys(customCompPresets).length > 0
|
||||
? [
|
||||
{
|
||||
group: 'Custom',
|
||||
items: Object.keys(customCompPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// ── EQ SettingsSection options ────────────────────────────────────────────
|
||||
const eqOptions: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
defaultChecked={settings.equalizer.enabled}
|
||||
onChange={(e) => handleEqToggle(e.currentTarget.checked)}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
settings.type === PlayerType.LOCAL
|
||||
? 'Parametric equalizer via FFmpeg lavfi (MPV)'
|
||||
: 'Parametric equalizer via Web Audio API',
|
||||
title: 'Equalizer',
|
||||
},
|
||||
...(settings.equalizer.enabled
|
||||
? ([
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
clearable
|
||||
data={eqPresetSelectData}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
const preset = customEqPresets[name] ?? EQ_PRESETS[name];
|
||||
if (preset) applyEqPreset(preset);
|
||||
}}
|
||||
placeholder="Select preset"
|
||||
searchable
|
||||
value={null}
|
||||
w={180}
|
||||
/>
|
||||
{Object.keys(customEqPresets).length > 0 && (
|
||||
<Select
|
||||
clearable
|
||||
data={Object.keys(customEqPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
}))}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
handleDeleteEqPreset(name);
|
||||
}}
|
||||
placeholder="Delete custom..."
|
||||
value={null}
|
||||
w={160}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
description: 'Apply a built-in or saved custom EQ curve',
|
||||
title: 'Preset',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
onChange={(e) => setSaveEqName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveEqPreset();
|
||||
}}
|
||||
placeholder="Preset name..."
|
||||
value={saveEqName}
|
||||
w={180}
|
||||
/>
|
||||
<Button
|
||||
disabled={!saveEqName.trim()}
|
||||
onClick={handleSaveEqPreset}
|
||||
variant="subtle"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
description: 'Save the current EQ settings as a named preset',
|
||||
title: 'Save preset',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<Slider
|
||||
label={(v) => `${v > 0 ? '+' : ''}${v} dB`}
|
||||
max={EQ_MAX}
|
||||
min={EQ_MIN}
|
||||
onChange={(v) => {
|
||||
setSettings({
|
||||
playback: {
|
||||
equalizer: { ...settings.equalizer, preamp: v },
|
||||
},
|
||||
});
|
||||
}}
|
||||
onChangeEnd={handlePreampChangeEnd}
|
||||
step={EQ_STEP}
|
||||
value={settings.equalizer.preamp}
|
||||
w={200}
|
||||
/>
|
||||
{/* Manual preamp input */}
|
||||
<NumberInput
|
||||
hideControls
|
||||
max={EQ_MAX}
|
||||
min={EQ_MIN}
|
||||
onBlur={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value);
|
||||
if (!isNaN(val)) {
|
||||
const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val));
|
||||
handlePreampChangeEnd(clamped);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
rightSection={
|
||||
<Text isMuted size="xs">
|
||||
dB
|
||||
</Text>
|
||||
}
|
||||
size="sm"
|
||||
step={EQ_STEP}
|
||||
value={settings.equalizer.preamp}
|
||||
w={70}
|
||||
/>
|
||||
<Button onClick={handleResetEq} variant="subtle">
|
||||
Reset all
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
description:
|
||||
'Input gain before EQ bands. Set negative when boosting bands to prevent clipping (MPV).',
|
||||
title: 'Preamp',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
// EqBandSlider uses useMove for correct vertical drag direction
|
||||
// (up = higher value, down = lower value). The NumberInput above
|
||||
// each band allows precise manual entry.
|
||||
<Group align="flex-end" gap={2} wrap="nowrap">
|
||||
{settings.equalizer.bands.map((band, i) => (
|
||||
<EqBandSlider
|
||||
freq={band.freq}
|
||||
gain={band.gain}
|
||||
key={band.freq}
|
||||
label={BAND_LABELS[i] ?? String(band.freq)}
|
||||
onChangeEnd={(v) => handleBandChangeEnd(i, v)}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
),
|
||||
description:
|
||||
'Per-band gain. Drag up/down or type a value. Range: -12 to +12 dB.',
|
||||
title: 'Bands',
|
||||
},
|
||||
] as SettingOption[])
|
||||
: []),
|
||||
];
|
||||
|
||||
// ── Compressor param definitions ──────────────────────────────────────────
|
||||
const compParams: {
|
||||
description: string;
|
||||
key: keyof CompressorSettings;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
title: string;
|
||||
unit: string;
|
||||
}[] = [
|
||||
{
|
||||
description: 'Signal level above which compression begins.',
|
||||
key: 'threshold',
|
||||
max: 0,
|
||||
min: -60,
|
||||
step: 1,
|
||||
title: 'Threshold',
|
||||
unit: 'dB',
|
||||
},
|
||||
{
|
||||
description: 'Compression ratio, e.g. 4 = 4:1.',
|
||||
key: 'ratio',
|
||||
max: 20,
|
||||
min: 1,
|
||||
step: 0.5,
|
||||
title: 'Ratio',
|
||||
unit: ':1',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'How quickly the compressor engages after the signal exceeds the threshold.',
|
||||
key: 'attack',
|
||||
max: 2000,
|
||||
min: 0.1,
|
||||
step: 1,
|
||||
title: 'Attack',
|
||||
unit: 'ms',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'How quickly the compressor releases after the signal drops below the threshold.',
|
||||
key: 'release',
|
||||
max: 9000,
|
||||
min: 1,
|
||||
step: 10,
|
||||
title: 'Release',
|
||||
unit: 'ms',
|
||||
},
|
||||
{
|
||||
description: 'Output gain applied after compression to restore loudness.',
|
||||
key: 'makeup',
|
||||
max: 30,
|
||||
min: 0,
|
||||
step: 0.5,
|
||||
title: 'Makeup Gain',
|
||||
unit: 'dB',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Soft-knee width. Higher values make the transition into compression more gradual.',
|
||||
key: 'knee',
|
||||
max: 10,
|
||||
min: 1,
|
||||
step: 0.5,
|
||||
title: 'Knee',
|
||||
unit: 'dB',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Compressor SettingsSection options ────────────────────────────────────
|
||||
const compressorOptions: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
defaultChecked={settings.compressor.enabled}
|
||||
onChange={(e) => handleCompToggle(e.currentTarget.checked)}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
settings.type === PlayerType.LOCAL
|
||||
? 'Dynamic range compressor via FFmpeg acompressor (MPV)'
|
||||
: 'Dynamic range compressor via Web Audio API',
|
||||
title: 'Compressor',
|
||||
},
|
||||
...(settings.compressor.enabled
|
||||
? ([
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
clearable
|
||||
data={compPresetSelectData}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
const preset = customCompPresets[name] ?? COMP_PRESETS[name];
|
||||
if (preset) applyCompPreset(preset);
|
||||
}}
|
||||
placeholder="Select preset"
|
||||
searchable
|
||||
value={null}
|
||||
w={180}
|
||||
/>
|
||||
{Object.keys(customCompPresets).length > 0 && (
|
||||
<Select
|
||||
clearable
|
||||
data={Object.keys(customCompPresets).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
}))}
|
||||
onChange={(name) => {
|
||||
if (!name) return;
|
||||
handleDeleteCompPreset(name);
|
||||
}}
|
||||
placeholder="Delete custom..."
|
||||
value={null}
|
||||
w={160}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
description: 'Apply a built-in or saved custom compressor setting',
|
||||
title: 'Preset',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
onChange={(e) => setSaveCompName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveCompPreset();
|
||||
}}
|
||||
placeholder="Preset name..."
|
||||
value={saveCompName}
|
||||
w={180}
|
||||
/>
|
||||
<Button
|
||||
disabled={!saveCompName.trim()}
|
||||
onClick={handleSaveCompPreset}
|
||||
variant="subtle"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
description: 'Save the current compressor settings as a named preset',
|
||||
title: 'Save preset',
|
||||
},
|
||||
// One SettingOption per compressor parameter — Slider + NumberInput
|
||||
...compParams.map(({ description, key, max, min, step, title, unit }) => ({
|
||||
control: (
|
||||
<Group align="center" gap="xs">
|
||||
<Slider
|
||||
label={(v) => `${v}${unit}`}
|
||||
max={max}
|
||||
min={min}
|
||||
onChange={(v) => {
|
||||
setSettings({
|
||||
playback: {
|
||||
compressor: { ...settings.compressor, [key]: v },
|
||||
},
|
||||
});
|
||||
}}
|
||||
onChangeEnd={(v) => handleCompChangeEnd(key, v)}
|
||||
step={step}
|
||||
value={settings.compressor[key] as number}
|
||||
w={200}
|
||||
/>
|
||||
{/* Manual value input — remounts with new defaultValue
|
||||
when settings change (preset applied, slider moved) */}
|
||||
<NumberInput
|
||||
hideControls
|
||||
max={max}
|
||||
min={min}
|
||||
onBlur={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value);
|
||||
if (!isNaN(val)) {
|
||||
const clamped = Math.max(min, Math.min(max, val));
|
||||
handleCompChangeEnd(key, clamped);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
rightSection={
|
||||
<Text isMuted size="xs">
|
||||
{unit}
|
||||
</Text>
|
||||
}
|
||||
size="sm"
|
||||
step={step}
|
||||
value={settings.compressor[key] as number}
|
||||
w={80}
|
||||
/>
|
||||
</Group>
|
||||
),
|
||||
description,
|
||||
title,
|
||||
})),
|
||||
{
|
||||
control: (
|
||||
<Button onClick={handleResetComp} variant="subtle">
|
||||
Reset to defaults
|
||||
</Button>
|
||||
),
|
||||
description: 'Restore all compressor parameters to their default values',
|
||||
title: 'Reset',
|
||||
},
|
||||
] as SettingOption[])
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<SettingsSection options={eqOptions} />
|
||||
<Divider />
|
||||
<SettingsSection options={compressorOptions} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Builds the MPV `af` audio filter chain string for EQ and compressor.
|
||||
// Uses FFmpeg lavfi filters, which MPV exposes natively.
|
||||
|
||||
export interface CompressorSettings {
|
||||
attack: number; // ms
|
||||
enabled: boolean;
|
||||
knee: number; // dB soft-knee width
|
||||
makeup: number; // dB post-compression gain
|
||||
ratio: number; // e.g. 4 (means 4:1)
|
||||
release: number; // ms
|
||||
threshold: number; // dB, e.g. -24
|
||||
}
|
||||
|
||||
export interface EqBand {
|
||||
freq: number;
|
||||
gain: number; // dB, clamped to [-12, 12]
|
||||
}
|
||||
|
||||
export interface EqSettings {
|
||||
bands: EqBand[];
|
||||
enabled: boolean;
|
||||
preamp: number; // dB pre-gain before bands, clamped to [-12, 12]
|
||||
}
|
||||
|
||||
// Octave widths for each band — tuned so 10 bands cover 20Hz–20kHz
|
||||
// with no gaps and gentle overlap.
|
||||
const BAND_WIDTHS: Record<number, number> = {
|
||||
31.5: 1.9,
|
||||
63: 1.3,
|
||||
125: 1.0,
|
||||
250: 1.0,
|
||||
500: 1.0,
|
||||
1000: 1.0,
|
||||
2000: 1.0,
|
||||
3000: 1.0,
|
||||
4000: 1.0,
|
||||
6300: 1.2,
|
||||
10000: 1.2,
|
||||
16000: 1.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the MPV `af` property value for the given EQ + compressor settings.
|
||||
* An empty string clears all filters (pass-through).
|
||||
*/
|
||||
export function buildMpvAudioFilters(eq: EqSettings, compressor: CompressorSettings): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (eq.enabled) {
|
||||
// Apply preamp as a straight input gain before the band filters.
|
||||
// The user is responsible for setting a negative preamp value when
|
||||
// boosting bands to avoid clipping — matching the behaviour of VLC,
|
||||
// foobar2000, and hardware EQs. The UI preamp slider exists for this purpose.
|
||||
if (eq.preamp !== 0) {
|
||||
parts.push(`volume=${eq.preamp}dB`);
|
||||
}
|
||||
|
||||
// One parametric EQ filter per non-zero band
|
||||
for (const band of eq.bands) {
|
||||
if (band.gain === 0) continue;
|
||||
const w = BAND_WIDTHS[band.freq] ?? 1.0;
|
||||
parts.push(`lavfi=[equalizer=f=${band.freq}:width_type=o:w=${w}:g=${band.gain}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (compressor.enabled) {
|
||||
const threshLinear = Math.pow(10, compressor.threshold / 20);
|
||||
const makeupLinear = Math.pow(10, compressor.makeup / 20);
|
||||
parts.push(
|
||||
`lavfi=[acompressor=` +
|
||||
`threshold=${threshLinear.toFixed(6)}:` +
|
||||
`ratio=${compressor.ratio}:` +
|
||||
`attack=${compressor.attack}:` +
|
||||
`release=${compressor.release}:` +
|
||||
`makeup=${makeupLinear.toFixed(6)}:` +
|
||||
`knee=${compressor.knee}` +
|
||||
`]`,
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(',');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
|
||||
import { AutoDJSettings } from '/@/renderer/features/settings/components/playback/auto-dj-settings';
|
||||
import { EqSettings } from '/@/renderer/features/settings/components/playback/eq-settings';
|
||||
import { PlayerFilterSettings } from '/@/renderer/features/settings/components/playback/player-filter-settings';
|
||||
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
@@ -37,6 +38,7 @@ export const PlaybackTab = memo(() => {
|
||||
<Stack gap="md">
|
||||
<AudioSettings />
|
||||
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
|
||||
<EqSettings />
|
||||
<Divider />
|
||||
<TranscodeSettings />
|
||||
<Divider />
|
||||
|
||||
@@ -271,6 +271,26 @@ const MpvSettingsSchema = z.object({
|
||||
replayGainMode: z.enum(['album', 'no', 'track']),
|
||||
replayGainPreampDB: z.number().optional(),
|
||||
});
|
||||
const EqSettingsSchema = z.object({
|
||||
bands: z.array(
|
||||
z.object({
|
||||
freq: z.number(),
|
||||
gain: z.number(),
|
||||
}),
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
preamp: z.number(),
|
||||
});
|
||||
|
||||
const CompressorSettingsSchema = z.object({
|
||||
attack: z.number(),
|
||||
enabled: z.boolean(),
|
||||
knee: z.number(),
|
||||
makeup: z.number(),
|
||||
ratio: z.number(),
|
||||
release: z.number(),
|
||||
threshold: z.number(),
|
||||
});
|
||||
|
||||
const CssSettingsSchema = z.object({
|
||||
content: z.string().transform((val) => sanitizeCss(`<style>${val}`)),
|
||||
@@ -625,6 +645,8 @@ const PlayerFilterSchema = z.object({
|
||||
const PlaybackSettingsSchema = z.object({
|
||||
audioDeviceId: z.string().nullable().optional(),
|
||||
audioFadeOnStatusChange: z.boolean(),
|
||||
compressor: CompressorSettingsSchema,
|
||||
equalizer: EqSettingsSchema,
|
||||
filters: z.array(PlayerFilterSchema),
|
||||
mediaSession: z.boolean(),
|
||||
mpvAudioDeviceId: z.string().nullable().optional(),
|
||||
@@ -1847,6 +1869,33 @@ const initialState: SettingsState = {
|
||||
playback: {
|
||||
audioDeviceId: undefined,
|
||||
audioFadeOnStatusChange: true,
|
||||
compressor: {
|
||||
attack: 20,
|
||||
enabled: false,
|
||||
knee: 2.83,
|
||||
makeup: 6,
|
||||
ratio: 4,
|
||||
release: 250,
|
||||
threshold: -24,
|
||||
},
|
||||
equalizer: {
|
||||
bands: [
|
||||
{ freq: 31.5, gain: 0 },
|
||||
{ freq: 63, gain: 0 },
|
||||
{ freq: 125, gain: 0 },
|
||||
{ freq: 250, gain: 0 },
|
||||
{ freq: 500, gain: 0 },
|
||||
{ freq: 1000, gain: 0 },
|
||||
{ freq: 2000, gain: 0 },
|
||||
{ freq: 3000, gain: 0 },
|
||||
{ freq: 4000, gain: 0 },
|
||||
{ freq: 6300, gain: 0 },
|
||||
{ freq: 10000, gain: 0 },
|
||||
{ freq: 16000, gain: 0 },
|
||||
],
|
||||
enabled: false,
|
||||
preamp: 0,
|
||||
},
|
||||
filters: [],
|
||||
mediaSession: false,
|
||||
mpvAudioDeviceId: undefined,
|
||||
|
||||
@@ -288,6 +288,11 @@ export interface UniqueId {
|
||||
|
||||
export type WebAudio = {
|
||||
context: AudioContext;
|
||||
dsp: null | {
|
||||
compressor: DynamicsCompressorNode;
|
||||
eqFilters: BiquadFilterNode[];
|
||||
preampGain: GainNode;
|
||||
};
|
||||
gains: GainNode[];
|
||||
visualizerInputs?: AudioNode[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user