diff --git a/feishin.desktop.tmpl b/feishin.desktop.tmpl old mode 100644 new mode 100755 index 8f33a7298..c6b35ab85 --- a/feishin.desktop.tmpl +++ b/feishin.desktop.tmpl @@ -1,3 +1,4 @@ +#!/usr/bin/env xdg-open [Desktop Entry] Name=Feishin GenericName=Music player diff --git a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx index 4e5a5bc9f..acbf3fe97 100644 --- a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -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(); diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 1ada81279..ba868c50f 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -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 diff --git a/src/renderer/features/settings/components/playback/eq-settings.tsx b/src/renderer/features/settings/components/playback/eq-settings.tsx new file mode 100644 index 000000000..efe647c42 --- /dev/null +++ b/src/renderer/features/settings/components/playback/eq-settings.tsx @@ -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 = { + 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; +const COMP_PRESETS: Record = { + 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(key: string): Record { + try { + return JSON.parse(localStorage.getItem(key) || '{}'); + } catch { + return {}; + } +} + +function saveCustomPresets(key: string, presets: Record) { + 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 ( + + {/* Manual value input — keyed on gain prop so it remounts + when an external change arrives (preset, reset) */} + { + 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 */} +
+ {/* Coloured fill between the zero line and the thumb */} +
+ + {/* Zero-line tick mark */} +
+ + {/* Thumb — centre is at thumbPct% from the bottom */} +
+
+ + {/* Frequency label */} + + {label} + + + ); +} + +// ─── 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>(() => + loadCustomPresets(LS_EQ_PRESETS), + ); + const [customCompPresets, setCustomCompPresets] = useState>( + () => loadCustomPresets(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: ( + 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: ( + + ({ + label: name, + value: name, + }))} + onChange={(name) => { + if (!name) return; + handleDeleteEqPreset(name); + }} + placeholder="Delete custom..." + value={null} + w={160} + /> + )} + + ), + description: 'Apply a built-in or saved custom EQ curve', + title: 'Preset', + }, + { + control: ( + + setSaveEqName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveEqPreset(); + }} + placeholder="Preset name..." + value={saveEqName} + w={180} + /> + + + ), + description: 'Save the current EQ settings as a named preset', + title: 'Save preset', + }, + { + control: ( + + `${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 */} + { + 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={ + + dB + + } + size="sm" + step={EQ_STEP} + value={settings.equalizer.preamp} + w={70} + /> + + + ), + 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. + + {settings.equalizer.bands.map((band, i) => ( + handleBandChangeEnd(i, v)} + /> + ))} + + ), + 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: ( + 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: ( + + ({ + label: name, + value: name, + }))} + onChange={(name) => { + if (!name) return; + handleDeleteCompPreset(name); + }} + placeholder="Delete custom..." + value={null} + w={160} + /> + )} + + ), + description: 'Apply a built-in or saved custom compressor setting', + title: 'Preset', + }, + { + control: ( + + setSaveCompName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveCompPreset(); + }} + placeholder="Preset name..." + value={saveCompName} + w={180} + /> + + + ), + 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: ( + + `${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) */} + { + 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={ + + {unit} + + } + size="sm" + step={step} + value={settings.compressor[key] as number} + w={80} + /> + + ), + description, + title, + })), + { + control: ( + + ), + description: 'Restore all compressor parameters to their default values', + title: 'Reset', + }, + ] as SettingOption[]) + : []), + ]; + + return ( + <> + + + + + + ); +}); diff --git a/src/renderer/features/settings/components/playback/mpv-audio-filters.ts b/src/renderer/features/settings/components/playback/mpv-audio-filters.ts new file mode 100644 index 000000000..7c581541a --- /dev/null +++ b/src/renderer/features/settings/components/playback/mpv-audio-filters.ts @@ -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 = { + 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(','); +} diff --git a/src/renderer/features/settings/components/playback/playback-tab.tsx b/src/renderer/features/settings/components/playback/playback-tab.tsx index 71f87696b..ac5cffa5b 100644 --- a/src/renderer/features/settings/components/playback/playback-tab.tsx +++ b/src/renderer/features/settings/components/playback/playback-tab.tsx @@ -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(() => { }>{hasFancyAudio && } + diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 5f2981417..2b0c10899 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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(`