mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-24 12:57:55 +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]
|
[Desktop Entry]
|
||||||
Name=Feishin
|
Name=Feishin
|
||||||
GenericName=Music player
|
GenericName=Music player
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
properties,
|
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
|
// After initialization, populate the queue if currentSrc is available
|
||||||
// Don't override queue if radio is active
|
// Don't override queue if radio is active
|
||||||
const radioState = useRadioStore.getState();
|
const radioState = useRadioStore.getState();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||||
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
|
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
|
||||||
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
|
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
|
||||||
|
import { useSettingsStore } from '/@/renderer/store';
|
||||||
import {
|
import {
|
||||||
updateQueueFavorites,
|
updateQueueFavorites,
|
||||||
updateQueueRatings,
|
updateQueueRatings,
|
||||||
@@ -196,11 +197,66 @@ const AudioPlayersContent = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gains = [context.createGain(), context.createGain()];
|
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
|
// 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 { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
|
||||||
import { AutoDJSettings } from '/@/renderer/features/settings/components/playback/auto-dj-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 { PlayerFilterSettings } from '/@/renderer/features/settings/components/playback/player-filter-settings';
|
||||||
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
|
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
|
||||||
import { useSettingsStore } from '/@/renderer/store';
|
import { useSettingsStore } from '/@/renderer/store';
|
||||||
@@ -37,6 +38,7 @@ export const PlaybackTab = memo(() => {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<AudioSettings />
|
<AudioSettings />
|
||||||
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
|
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
|
||||||
|
<EqSettings />
|
||||||
<Divider />
|
<Divider />
|
||||||
<TranscodeSettings />
|
<TranscodeSettings />
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -271,6 +271,26 @@ const MpvSettingsSchema = z.object({
|
|||||||
replayGainMode: z.enum(['album', 'no', 'track']),
|
replayGainMode: z.enum(['album', 'no', 'track']),
|
||||||
replayGainPreampDB: z.number().optional(),
|
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({
|
const CssSettingsSchema = z.object({
|
||||||
content: z.string().transform((val) => sanitizeCss(`<style>${val}`)),
|
content: z.string().transform((val) => sanitizeCss(`<style>${val}`)),
|
||||||
@@ -625,6 +645,8 @@ const PlayerFilterSchema = z.object({
|
|||||||
const PlaybackSettingsSchema = z.object({
|
const PlaybackSettingsSchema = z.object({
|
||||||
audioDeviceId: z.string().nullable().optional(),
|
audioDeviceId: z.string().nullable().optional(),
|
||||||
audioFadeOnStatusChange: z.boolean(),
|
audioFadeOnStatusChange: z.boolean(),
|
||||||
|
compressor: CompressorSettingsSchema,
|
||||||
|
equalizer: EqSettingsSchema,
|
||||||
filters: z.array(PlayerFilterSchema),
|
filters: z.array(PlayerFilterSchema),
|
||||||
mediaSession: z.boolean(),
|
mediaSession: z.boolean(),
|
||||||
mpvAudioDeviceId: z.string().nullable().optional(),
|
mpvAudioDeviceId: z.string().nullable().optional(),
|
||||||
@@ -1847,6 +1869,33 @@ const initialState: SettingsState = {
|
|||||||
playback: {
|
playback: {
|
||||||
audioDeviceId: undefined,
|
audioDeviceId: undefined,
|
||||||
audioFadeOnStatusChange: true,
|
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: [],
|
filters: [],
|
||||||
mediaSession: false,
|
mediaSession: false,
|
||||||
mpvAudioDeviceId: undefined,
|
mpvAudioDeviceId: undefined,
|
||||||
|
|||||||
@@ -288,6 +288,11 @@ export interface UniqueId {
|
|||||||
|
|
||||||
export type WebAudio = {
|
export type WebAudio = {
|
||||||
context: AudioContext;
|
context: AudioContext;
|
||||||
|
dsp: null | {
|
||||||
|
compressor: DynamicsCompressorNode;
|
||||||
|
eqFilters: BiquadFilterNode[];
|
||||||
|
preampGain: GainNode;
|
||||||
|
};
|
||||||
gains: GainNode[];
|
gains: GainNode[];
|
||||||
visualizerInputs?: AudioNode[];
|
visualizerInputs?: AudioNode[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user