Added Graphic EQ and Compressor (#1972)

* Adding Graphic Eq and Compressor with presets
This commit is contained in:
keebsxd
2026-06-16 10:38:40 +03:00
committed by GitHub
parent f7adcb8533
commit 2107d1c928
8 changed files with 1062 additions and 3 deletions
Regular → Executable
+1
View File
@@ -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 20Hz20kHz
// 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 />
+49
View File
@@ -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,
+5
View File
@@ -288,6 +288,11 @@ export interface UniqueId {
export type WebAudio = {
context: AudioContext;
dsp: null | {
compressor: DynamicsCompressorNode;
eqFilters: BiquadFilterNode[];
preampGain: GainNode;
};
gains: GainNode[];
visualizerInputs?: AudioNode[];
};