From 2c3b9cdf09fa164cc51d1f5d4800bffedbefae5e Mon Sep 17 00:00:00 2001 From: Faizan Asad Date: Thu, 24 Sep 2026 03:21:31 +0500 Subject: [PATCH] feat: redesign equaliser with live response graph and custom presets Closes #141 Co-Authored-By: Claude Opus 5.5 --- src/ui/components/Equalizer.tsx | 688 +++++++++++++++++++++ src/ui/components/player/MiniEqualizer.tsx | 171 +---- src/ui/icons.tsx | 2 + src/ui/pages/SettingsPage.tsx | 174 +----- src/ui/settings/equalizer.ts | 281 ++++++--- src/ui/settings/equalizerCurve.check.ts | 105 ++++ src/ui/settings/equalizerCurve.ts | 105 ++++ 7 files changed, 1132 insertions(+), 394 deletions(-) create mode 100644 src/ui/components/Equalizer.tsx create mode 100644 src/ui/settings/equalizerCurve.check.ts create mode 100644 src/ui/settings/equalizerCurve.ts diff --git a/src/ui/components/Equalizer.tsx b/src/ui/components/Equalizer.tsx new file mode 100644 index 00000000..1c73c262 --- /dev/null +++ b/src/ui/components/Equalizer.tsx @@ -0,0 +1,688 @@ +import { + type FormEvent, + type KeyboardEvent, + type MouseEvent, + type PointerEvent, + type ReactNode, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { RangeSlider } from "@/components/motion/range-slider"; +import { Tooltip } from "@/components/motion/tooltip"; +import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; +import { cn } from "@/lib/utils"; +import { PencilIcon, SaveIcon, TrashIcon } from "@/ui/icons"; +import { + activeEqualizerPreset, + deleteEqualizerPreset, + EQUALIZER_CUSTOM_NAME, + EQUALIZER_PRESET_NAME_MAX, + equalizerPresetNameError, + getEqualizer, + isExistingEqualizerPreset, + renameEqualizerPreset, + saveEqualizerPreset, + setEqualizer, + suggestEqualizerPresetName, + useEqualizer, + useEqualizerCustom, + useEqualizerEnabled, + useEqualizerUserPresets, +} from "../settings/equalizer"; +import { + autoPreampDb, + CURVE_HZ, + EQUALIZER_BANDS_HZ, + EQUALIZER_MAX_DB, + EQUALIZER_PRESETS, + EQUALIZER_STEP_DB, + frequencyPosition, + responseCurveDb, + snapGain, +} from "../settings/equalizerCurve"; + +const BAND_COUNT = EQUALIZER_BANDS_HZ.length; + +/** What each band is for. "250 Hz" means nothing to most people; "Warmth" does. */ +const BAND_ROLES = ["Sub", "Bass", "Punch", "Warmth", "Body", "Mids", "Bite", "Presence", "Clarity", "Air"]; + +/** Wider than a band's ±12 dB: neighbouring boosts stack past it (ten at +12 peak near +18). */ +const DISPLAY_DB = 15; +const GRID_DB = [12, 6, 0, -6, -12]; + +/** How near a node, vertically, a press has to land to grab it instead of drawing. */ +const GRAB_RADIUS_PX = 14; + +const KEY_GAIN: Partial number>> = { + ArrowUp: (gain) => gain + EQUALIZER_STEP_DB, + ArrowDown: (gain) => gain - EQUALIZER_STEP_DB, + PageUp: (gain) => gain + 3, + PageDown: (gain) => gain - 3, + Home: () => -EQUALIZER_MAX_DB, + End: () => EQUALIZER_MAX_DB, + Delete: () => 0, + Backspace: () => 0, + "0": () => 0, +}; + +const shortHz = (hz: number) => (hz >= 1000 ? `${hz / 1000}k` : String(hz)); +const spokenHz = (hz: number) => (hz >= 1000 ? `${hz / 1000} kHz` : `${hz} Hz`); + +/** "+3.5", "0", "−2" — a real minus sign, and never "+0". */ +const formatDb = (db: number) => (db === 0 ? "0" : `${db > 0 ? "+" : "−"}${Math.abs(db)}`); + +/** Percent down the plot. Clamped, so a curve beyond the range runs along the frame. */ +const yOf = (db: number) => 50 - (Math.max(-DISPLAY_DB, Math.min(DISPLAY_DB, db)) / DISPLAY_DB) * 50; + +const CURVE_X = CURVE_HZ.map((hz) => (frequencyPosition(hz) * 100).toFixed(2)); + +/** Path in the plot's 0–100 viewBox. Fixed point count and format, so motion can morph between curves. */ +function curvePath(curve: readonly number[]): string { + return curve + .map((db, index) => `${index === 0 ? "M" : "L"}${CURVE_X[index]},${yOf(db).toFixed(2)}`) + .join(""); +} + +function locate(surface: Element, clientX: number, clientY: number) { + const rect = surface.getBoundingClientRect(); + const column = Math.floor(((clientX - rect.left) / rect.width) * BAND_COUNT); + const half = rect.height / 2; + return { + band: Math.min(BAND_COUNT - 1, Math.max(0, column)), + gain: snapGain(((rect.top + half - clientY) / half) * DISPLAY_DB), + rect, + }; +} + +/** Several band changes as one update, so a sweep across five columns is one push to Rust. */ +function setBands(changes: ReadonlyArray) { + const current = getEqualizer(); + const bandsDb = current.bandsDb.slice(); + for (const [band, gain] of changes) bandsDb[band] = gain; + setEqualizer({ ...current, bandsDb }); +} + +// Press a node to grab it (moves relative, no jump); press elsewhere to draw bands at the pointer's height. +type Gesture = + | { kind: "grab"; band: number; startY: number; startGain: number } + | { kind: "draw"; band: number; gain: number }; + +/** + * The equaliser as a draggable frequency-response graph: the real filter response, not a line + * through the nodes. Red above the dashed line is what the preamp hasn't made room for. + */ +export function EqualizerGraph({ compact = false, disabled = false }: { compact?: boolean; disabled?: boolean }) { + const { bandsDb, preampDb } = useEqualizer(); + const enabled = useEqualizerEnabled(); + const reduceMotion = useReducedMotion(); + const gesture = useRef(null); + const nodes = useRef>([]); + const [dragBand, setDragBand] = useState(null); + const [grabbing, setGrabbing] = useState(false); + const [hoverBand, setHoverBand] = useState(null); + const [keyboardBand, setKeyboardBand] = useState(null); + /** Roving tab stop: the one node the graph can be focused through. */ + const [tabBand, setTabBand] = useState(0); + // `url(#…)` has to survive useId's punctuation, which differs between React versions. + const svgId = `eq${useId().replace(/[^\w-]/g, "")}`; + + const line = useMemo(() => curvePath(responseCurveDb(bandsDb)), [bandsDb]); + const ceilingY = yOf(-preampDb); + const shownBand = disabled ? null : (dragBand ?? keyboardBand ?? hoverBand); + // Instant under the pointer (a trailing node feels broken); a glide for presets, keys and Auto. + const glide = dragBand !== null || reduceMotion ? { duration: 0 } : SPRING_LAYOUT; + + const onPointerDown = (event: PointerEvent) => { + if (disabled || event.button !== 0) return; + // Also keeps the press from moving focus to the page, so the node focused below keeps it. + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + const { band, gain, rect } = locate(event.currentTarget, event.clientX, event.clientY); + const current = getEqualizer().bandsDb[band]; + const nodeY = rect.top + (yOf(current) / 100) * rect.height; + const grab = Math.abs(event.clientY - nodeY) <= GRAB_RADIUS_PX; + gesture.current = grab + ? { kind: "grab", band, startY: event.clientY, startGain: current } + : { kind: "draw", band, gain }; + if (!grab) setBands([[band, gain]]); + setDragBand(band); + setGrabbing(grab); + // So the arrow keys fine-tune whichever band was touched last. + nodes.current[band]?.focus({ preventScroll: true }); + }; + + const onPointerMove = (event: PointerEvent) => { + const { band, gain, rect } = locate(event.currentTarget, event.clientX, event.clientY); + const current = gesture.current; + if (!current) { + if (event.pointerType === "mouse") setHoverBand(band); + return; + } + if (current.kind === "grab") { + const moved = ((current.startY - event.clientY) / (rect.height / 2)) * DISPLAY_DB; + setBands([[current.band, snapGain(current.startGain + moved)]]); + return; + } + // Interpolate across columns skipped since the last move, so a fast sweep leaves no gaps. + const changes: Array<[number, number]> = []; + const direction = band >= current.band ? 1 : -1; + for (let crossed = current.band; crossed !== band + direction; crossed += direction) { + const t = band === current.band ? 1 : (crossed - current.band) / (band - current.band); + changes.push([crossed, snapGain(current.gain + (gain - current.gain) * t)]); + } + setBands(changes); + gesture.current = { kind: "draw", band, gain }; + setDragBand(band); + }; + + const endGesture = () => { + gesture.current = null; + setDragBand(null); + setGrabbing(false); + }; + + // Flattens the band under the pointer. + const onDoubleClick = (event: MouseEvent) => { + if (disabled) return; + setBands([[locate(event.currentTarget, event.clientX, event.clientY).band, 0]]); + }; + + const onNodeKeyDown = (event: KeyboardEvent, band: number) => { + // Modified arrows are the app's own shortcuts (track skip, back and forward). + if (disabled || event.altKey || event.ctrlKey || event.metaKey) return; + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const next = band + (event.key === "ArrowLeft" ? -1 : 1); + if (next < 0 || next >= BAND_COUNT) return; + nodes.current[next]?.focus(); + setKeyboardBand(next); + return; + } + const adjust = KEY_GAIN[event.key]; + if (!adjust) return; + event.preventDefault(); + setKeyboardBand(band); + setBands([[band, snapGain(adjust(getEqualizer().bandsDb[band]))]]); + }; + + return ( +
+
+ {!compact && ( + + )} + +
setHoverBand(null)} + onDoubleClick={onDoubleClick} + className={cn( + "relative min-w-0 flex-1 touch-none select-none rounded-xl bg-background/40", + compact ? "h-24" : "h-52", + disabled ? "pointer-events-none" : grabbing ? "cursor-grabbing" : "cursor-crosshair", + )} + > + {GRID_DB.map((db) => ( + + + +
+ ); +} + +function BandReadout({ band, gain, compact }: { band: number; gain: number; compact: boolean }) { + const hz = EQUALIZER_BANDS_HZ[band]; + const x = frequencyPosition(hz) * 100; + return ( + + ); +} + +/** Built-ins, the user's presets, then Custom — lit exactly when nothing else is. `children` end the row. */ +export function EqualizerPresets({ + compact = false, + disabled = false, + children, +}: { + compact?: boolean; + disabled?: boolean; + children?: ReactNode; +}) { + const equalizer = useEqualizer(); + const userPresets = useEqualizerUserPresets(); + const custom = useEqualizerCustom(); + const reduceMotion = useReducedMotion(); + const pillId = useId(); + const active = activeEqualizerPreset(equalizer, userPresets); + + const chip = (name: string, isActive: boolean, onSelect: () => void) => ( + + ); + + return ( +
+ {[...EQUALIZER_PRESETS, ...userPresets].map((preset) => + chip(preset.name, preset === active, () => setEqualizer(preset.settings)), + )} + {custom !== null && !activeEqualizerPreset(custom, userPresets) + && chip(EQUALIZER_CUSTOM_NAME, active === undefined, () => setEqualizer(custom))} + {children} +
+ ); +} + +type Editing = { mode: "save" } | { mode: "rename"; from: string }; + +const ROW_BUTTON = + "flex h-7 items-center gap-1.5 rounded-full text-xs font-medium text-muted-foreground transition-colors hover:bg-card hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"; + +/** Actions for the lit chip: save Custom, or rename/delete a user preset. Built-ins have none. */ +function EqualizerPresetActions() { + const equalizer = useEqualizer(); + const userPresets = useEqualizerUserPresets(); + const [editing, setEditing] = useState(null); + const active = activeEqualizerPreset(equalizer, userPresets); + const activeUserPreset = active && userPresets.includes(active) ? active : undefined; + + // Close the editor if the chip it was opened for is no longer the lit one. + if (editing && (editing.mode === "save" ? active !== undefined : activeUserPreset?.name !== editing.from)) { + setEditing(null); + } + + if (editing) { + return ( + setEditing(null)} + /> + ); + } + + if (!active) { + return ( + + ); + } + + if (!activeUserPreset) return null; + const { name } = activeUserPreset; + return ( + + + + + + + + + ); +} + +function PresetNameEditor({ renaming, onDone }: { renaming?: string; onDone: () => void }) { + const [name, setName] = useState(() => renaming ?? suggestEqualizerPresetName()); + const errorId = useId(); + const error = equalizerPresetNameError(name, renaming); + // Saving under a taken name updates that preset; the button says so. + const replaces = renaming === undefined && isExistingEqualizerPreset(name); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (error) return; + if (renaming === undefined) saveEqualizerPreset(name); + else renameEqualizerPreset(renaming, name); + onDone(); + }; + + return ( +
+ setName(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + onDone(); + }} + aria-label={renaming ? `New name for ${renaming}` : "Preset name"} + aria-invalid={error !== null} + aria-describedby={error ? errorId : undefined} + className="h-7 w-36 rounded-full bg-background px-3 text-xs text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + /> + + + {error && ( + + {error} + + )} +
+ ); +} + +/** Preamp, and the headroom left at the curve's peak. Over 0 dB is flagged like the graph's red. */ +function EqualizerPreamp({ disabled }: { disabled: boolean }) { + const { bandsDb, preampDb } = useEqualizer(); + const enabled = useEqualizerEnabled(); + const peak = useMemo(() => Math.max(...responseCurveDb(bandsDb)), [bandsDb]); + const auto = useMemo(() => autoPreampDb(bandsDb), [bandsDb]); + const out = peak + preampDb; + const over = enabled && out > 0.05; + const setPreamp = (next: number) => setEqualizer({ ...getEqualizer(), preampDb: next }); + + return ( +
+
+ Preamp + + + {formatDb(preampDb)} dB + + + + +
+

+ {!enabled + ? "Bypassed — the track plays untouched." + : over + ? `Peaks ${out.toFixed(1)} dB over — the limiter is holding it back.` + : `${Math.abs(out).toFixed(1)} dB of headroom.`} +

+
+ ); +} + +/** The whole equaliser, as the Settings card lays it out. */ +export function EqualizerPanel({ disabled }: { disabled: boolean }) { + const enabled = useEqualizerEnabled(); + return ( +
+ {!disabled && } + + +

+ Changes play as you make them. Drag a point, or sweep across the graph to draw a curve; + double-click a band to flatten it. Anything over the dashed line is held back by the + limiter — lower the preamp, or let Auto do it. +

+
+ ); +} diff --git a/src/ui/components/player/MiniEqualizer.tsx b/src/ui/components/player/MiniEqualizer.tsx index bc128480..65ed881a 100644 --- a/src/ui/components/player/MiniEqualizer.tsx +++ b/src/ui/components/player/MiniEqualizer.tsx @@ -1,155 +1,23 @@ -import { useCallback, useId, useRef, useState } from "react"; -import type { KeyboardEvent, PointerEvent } from "react"; +import { useId } from "react"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/motion/switch"; import { - activeEqualizerPreset, - EQUALIZER_BANDS_HZ, - EQUALIZER_MAX_DB, - EQUALIZER_PRESETS, isEqualizerFlat, - setEqualizer, setEqualizerEnabled, useEqualizer, useEqualizerEnabled, } from "../../settings/equalizer"; import { useAudioEngineMode } from "../../settings/audioEngine"; +import { EqualizerGraph, EqualizerPresets } from "../Equalizer"; -/** Height of the bar well. Ten of these plus the preamp fit a 256px popup without scrolling. */ -const WELL_PX = 56; - -const clamp = (value: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, value)); - -/** - * One vertical bar: a bipolar gain control, filling up from centre for a boost and down for a - * cut. `RangeSlider` is horizontal only — built for a scale read left to right, not a bar read - * by height — so this is its own small control rather than a forced reuse of a component whose - * pointer maths run the wrong axis. - */ -function MiniEqBar({ - label, - value, - disabled, - onChange, -}: { - label: string; - value: number; - disabled: boolean; - onChange: (value: number) => void; -}) { - const trackRef = useRef(null); - const [dragging, setDragging] = useState(false); - - const commitFromY = useCallback( - (clientY: number) => { - const rect = trackRef.current?.getBoundingClientRect(); - if (!rect) return; - // Centre of the well is 0 dB; distance from it, signed, scales to the full range. - const half = rect.height / 2; - const fromCentre = rect.top + half - clientY; - const gain = Math.round(clamp((fromCentre / half) * EQUALIZER_MAX_DB, -EQUALIZER_MAX_DB, EQUALIZER_MAX_DB)); - onChange(gain); - }, - [onChange], - ); - - const onPointerDown = (event: PointerEvent) => { - if (disabled) return; - event.currentTarget.setPointerCapture(event.pointerId); - setDragging(true); - commitFromY(event.clientY); - }; - - const onPointerMove = (event: PointerEvent) => { - if (!dragging || disabled) return; - commitFromY(event.clientY); - }; - - const endDrag = (event: PointerEvent) => { - event.currentTarget.releasePointerCapture?.(event.pointerId); - setDragging(false); - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (disabled) return; - const map: Record = { - ArrowUp: value + 1, - ArrowDown: value - 1, - Home: -EQUALIZER_MAX_DB, - End: EQUALIZER_MAX_DB, - }; - if (event.key in map) { - event.preventDefault(); - onChange(clamp(map[event.key], -EQUALIZER_MAX_DB, EQUALIZER_MAX_DB)); - } - }; - - const ratio = value / EQUALIZER_MAX_DB; - const fillPx = Math.abs(ratio) * (WELL_PX / 2); - const fillTop = ratio >= 0 ? WELL_PX / 2 - fillPx : WELL_PX / 2; - - return ( -
-
- {/* Zero line — the reference every fill grows away from. */} -
-
- {/* The cap: what a finger or a screen reader's focus ring actually lands on. */} -
-
- {label} -
- ); -} - -/** - * The equaliser, shrunk to fit the speed/sleep-timer popup. - * - * Vertical bars rather than the settings page's horizontal ones — a popup this narrow has width - * for a label or a slider but not both stacked ten times, and a bar you can read by height at a - * glance is what an equaliser looks like everywhere outside this app's own settings page. - */ +/** The Settings graph and presets, compact, for the speed/sleep-timer popup. Naming presets stays in Settings. */ export function MiniEqualizer() { const equalizer = useEqualizer(); const enabled = useEqualizerEnabled(); const available = useAudioEngineMode() === "rust"; const flat = isEqualizerFlat(equalizer); - const activePreset = activeEqualizerPreset(equalizer); const labelId = useId(); - const setBand = (index: number, gain: number) => { - const bandsDb = equalizer.bandsDb.slice(); - bandsDb[index] = gain; - setEqualizer({ ...equalizer, bandsDb }); - }; - return (
@@ -170,37 +38,8 @@ export function MiniEqualizer() {
-
- {EQUALIZER_PRESETS.map((preset) => ( - - ))} -
- -
- {EQUALIZER_BANDS_HZ.map((hz, index) => ( - = 1000 ? `${hz / 1000}k` : String(hz)} - value={equalizer.bandsDb[index]} - disabled={!available} - onChange={(gain) => setBand(index, gain)} - /> - ))} -
+ +
); diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index afb4d830..4c82465d 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -116,6 +116,8 @@ export { KeyIcon } from "@solar-icons/react/linear/key"; export { BugIcon } from "@solar-icons/react/linear/bug"; export { ClockCircleIcon as ClockIcon } from "@solar-icons/react/linear/clock-circle"; export { SpeedometerMaxIcon as SpeedIcon } from "@solar-icons/react/linear/speedometer-max"; +export { Tuning2Icon as EqualizerIcon } from "@solar-icons/react/linear/tuning-2"; +export { DisketteIcon as SaveIcon } from "@solar-icons/react/linear/diskette"; export { CupHotIcon as CoffeeIcon } from "@solar-icons/react/linear/cup-hot"; export { MagicWandIcon as DiceIcon } from "@solar-icons/react/linear/magic-wand"; export { MagicWandIcon as DiceActiveIcon } from "@solar-icons/react/bold/magic-wand"; diff --git a/src/ui/pages/SettingsPage.tsx b/src/ui/pages/SettingsPage.tsx index 3826074b..90f79f52 100644 --- a/src/ui/pages/SettingsPage.tsx +++ b/src/ui/pages/SettingsPage.tsx @@ -9,17 +9,8 @@ import { import { Switch } from "@/components/motion/switch"; import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs"; import { RangeSlider } from "@/components/motion/range-slider"; -import { - activeEqualizerPreset, - EQUALIZER_BANDS_HZ, - EQUALIZER_MAX_DB, - EQUALIZER_PRESETS, - isEqualizerFlat, - setEqualizer, - setEqualizerEnabled, - useEqualizer, - useEqualizerEnabled, -} from "../settings/equalizer"; +import { setEqualizerEnabled, useEqualizerEnabled } from "../settings/equalizer"; +import { EqualizerPanel } from "../components/Equalizer"; import { MAX_CROSSFADE_SEC, setCrossfadeSec, @@ -41,6 +32,7 @@ import { import { BugIcon, DownloadIcon, + EqualizerIcon, FolderAddIcon, FolderIcon, FolderOpenIcon, @@ -259,113 +251,34 @@ function formatSessionAge(confirmedAt: number | null): string { const SETTINGS_FIELD = "min-w-0 rounded-lg bg-background px-2.5 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-ring/60"; -/** - * The ten-band equaliser. - * - * A component of its own rather than a run of `SettingRow`s because the bands are one control, - * not eleven: they share a scale, a reset and a set of presets, and reading one slider only - * means anything next to its neighbours. - * - * Sliders are horizontal, stacked. The usual picture of an equaliser is vertical, but that would - * mean a second slider component built to be rotated, and the frequency and the gain read more - * clearly written out than inferred from a bar's height. - * - * The switch is a bypass, not a reset — it never touches the stored curve, only whether Rust is - * currently told to apply it. See `setEqualizerEnabled`. - */ +/** The equaliser card. The switch is a bypass: off dims the curve but leaves it editable. */ function EqualizerSettings({ engineMode }: { engineMode: AudioEngineMode }) { - const equalizer = useEqualizer(); const enabled = useEqualizerEnabled(); - // Only the Rust engine has the samples. A track that fell back to the YouTube player plays - // unequalised no matter what these say, which the note below is there to admit. + // Only the Rust engine has the samples; the YouTube fallback plays unequalised. const available = engineMode === "rust"; - const flat = isEqualizerFlat(equalizer); - const labelId = useId(); - - const setBand = (index: number, gain: number) => { - const bandsDb = equalizer.bandsDb.slice(); - bandsDb[index] = gain; - setEqualizer({ ...equalizer, bandsDb }); - }; - - const activePreset = activeEqualizerPreset(equalizer); return ( -
-
-
- - Equaliser - - - {available - ? enabled - ? flat - ? "Flat" - : `${equalizer.preampDb > 0 ? "+" : ""}${equalizer.preampDb} dB preamp` - : "Off" - : "Needs the Rust playback method"} - -
- -
- - {/* - * Dimmed, not disabled: the curve is still worth shaping while off, the same way a - * hardware EQ's sliders keep moving with the bypass switch flipped — it is what makes - * flipping it back on show the shape you already built instead of the flat one it was - * silently holding underneath. - */} -
-
- {EQUALIZER_PRESETS.map((preset) => ( - - ))} -
- - setEqualizer({ ...equalizer, preampDb })} - /> - -
- - {EQUALIZER_BANDS_HZ.map((hz, index) => ( - = 1000 ? `${hz / 1000}k` : String(hz)} - value={equalizer.bandsDb[index]} +
+
- -

- Applies immediately, to the track playing. A limiter sits after the bands, so a heavy - boost is held back rather than clipped — lower the preamp to hear the difference instead. -

-
+ } + /> + + ); } @@ -424,41 +337,6 @@ function OutputDeviceSetting({ engineMode }: { engineMode: AudioEngineMode }) { ); } -function EqualizerBand({ - label, - value, - disabled, - onChange, -}: { - label: string; - value: number; - disabled: boolean; - onChange: (value: number) => void; -}) { - return ( -
- - {label} - - - - {value > 0 ? "+" : ""} - {value} dB - -
- ); -} - /** * One settings row: label and description on the left, control on the right. * @@ -2181,8 +2059,6 @@ export function SettingsPage({ - - + +
0), -}; - -/* - * Presets carry a negative preamp wherever they boost. - * - * A normalised track has very little headroom left, so lifting four bands by 6 dB without - * trimming the input hits the limiter and what comes out is compression rather than tone. Each - * preset gives back roughly what its largest boost takes. - */ -export const EQUALIZER_PRESETS: ReadonlyArray<{ name: string; settings: EqualizerSettings }> = [ - { name: "Flat", settings: EQUALIZER_FLAT }, - { - name: "Bass", - settings: { preampDb: -4, bandsDb: [6, 5, 4, 2, 0, 0, 0, 0, 0, 0] }, - }, - { - name: "Vocal", - settings: { preampDb: -3, bandsDb: [-2, -2, -1, 1, 3, 4, 3, 1, 0, 0] }, - }, - { - name: "Treble", - settings: { preampDb: -3, bandsDb: [0, 0, 0, 0, 0, 1, 2, 4, 5, 5] }, - }, -]; - -/** - * Which preset, if any, matches the current curve exactly. - * - * Shared by the full settings page and the player-bar mini equaliser so "which chip is lit" - * cannot drift into two slightly different definitions of "matches." - */ -export function activeEqualizerPreset( - settings: EqualizerSettings, -): (typeof EQUALIZER_PRESETS)[number] | undefined { - return EQUALIZER_PRESETS.find( - (preset) => - preset.settings.preampDb === settings.preampDb - && preset.settings.bandsDb.every((gain, index) => gain === settings.bandsDb[index]), - ); -} - const STORAGE_KEY = "equalizer-v1"; const CHANGE_EVENT = "equalizer-change"; @@ -90,6 +44,16 @@ const CHANGE_EVENT = "equalizer-change"; const ENABLED_STORAGE_KEY = "equalizer-enabled-v1"; const ENABLED_CHANGE_EVENT = "equalizer-enabled-change"; +/** Curves the user named. Built-ins live in `EQUALIZER_PRESETS` and are never stored. */ +const PRESETS_STORAGE_KEY = "equalizer-presets-v1"; + +// The Custom slot: the last curve that matched no preset, so clicking a preset never loses a tweak (#141). +const CUSTOM_STORAGE_KEY = "equalizer-custom-v1"; + +/** The Custom slot's chip label, reserved so no saved preset can be mistaken for it. */ +export const EQUALIZER_CUSTOM_NAME = "Custom"; +export const EQUALIZER_PRESET_NAME_MAX = 24; + function isEqualizerSettings(value: unknown): value is EqualizerSettings { if (typeof value !== "object" || value === null) return false; const candidate = value as EqualizerSettings; @@ -99,12 +63,24 @@ function isEqualizerSettings(value: unknown): value is EqualizerSettings { && candidate.bandsDb.every((gain) => typeof gain === "number"); } -/* - * Cached because the settings page reads this on every render of every slider, and the snapshot - * handed to `useSyncExternalStore` has to be reference-stable or React re-renders forever. - */ +function isPreset(value: unknown): value is EqualizerPreset { + if (typeof value !== "object" || value === null) return false; + const candidate = value as EqualizerPreset; + return typeof candidate.name === "string" + && candidate.name.trim() !== "" + && isEqualizerSettings(candidate.settings); +} + +// Cached: `useSyncExternalStore` snapshots must be reference-stable or React re-renders forever. let cached: EqualizerSettings | null = null; let enabledCached: boolean | null = null; +let presetsCached: EqualizerPreset[] | null = null; +/** `undefined` until read; `null` when nothing custom has been tuned yet. */ +let customCached: EqualizerSettings | null | undefined; +/** The preset the live curve last matched — what Save offers to overwrite after a tweak. */ +let basePresetName: string | null = null; + +const NO_PRESETS: EqualizerPreset[] = []; function readSettings(): EqualizerSettings { if (cached === null) { @@ -113,16 +89,36 @@ function readSettings(): EqualizerSettings { return cached; } +function readUserPresets(): EqualizerPreset[] { + if (presetsCached === null) { + // Filtered one by one: a malformed entry should cost that preset, not every preset. + presetsCached = (readLocalJsonSetting(PRESETS_STORAGE_KEY, Array.isArray) ?? []).filter(isPreset); + } + return presetsCached; +} + +function readCustom(): EqualizerSettings | null { + if (customCached === undefined) { + customCached = readLocalJsonSetting(CUSTOM_STORAGE_KEY, isEqualizerSettings); + } + return customCached; +} + +/** The preset that is exactly this curve, preamp included. Built-ins win ties. */ +export function activeEqualizerPreset( + settings: EqualizerSettings, + userPresets: readonly EqualizerPreset[], +): EqualizerPreset | undefined { + return [...EQUALIZER_PRESETS, ...userPresets].find((preset) => + sameEqualizerCurve(preset.settings, settings), + ); +} + /** True when every band and the preamp are at zero, so nothing is being changed. */ export function isEqualizerFlat(settings: EqualizerSettings): boolean { return settings.preampDb === 0 && settings.bandsDb.every((gain) => gain === 0); } -/** Whether the selected engine can apply it at all. */ -export function isEqualizerAvailable(): boolean { - return usesRustAudioEngine(); -} - /** The bypass switch — independent of the curve. See `ENABLED_STORAGE_KEY`. */ export function isEqualizerEnabled(): boolean { if (enabledCached === null) { @@ -153,25 +149,118 @@ function push(settings: EqualizerSettings): void { }); } +// Persisted once a drag settles: each `app_setting_set` rewrites the settings file on the +// same Tauri thread that applies the EQ, so per-move writes would lag the audio. +const PERSIST_DELAY_MS = 400; +let persistTimer: number | undefined; + +function persistSoon(): void { + window.clearTimeout(persistTimer); + persistTimer = window.setTimeout(persistNow, PERSIST_DELAY_MS); +} + +function persistNow(): void { + window.clearTimeout(persistTimer); + persistTimer = undefined; + writeLocalJsonSetting(STORAGE_KEY, readSettings()); + const custom = readCustom(); + if (custom) writeLocalJsonSetting(CUSTOM_STORAGE_KEY, custom); +} + +/** For event handlers: a drag reads the live curve, not the one its last render closed over. */ export function getEqualizer(): EqualizerSettings { return readSettings(); } export function setEqualizer(settings: EqualizerSettings): void { - const clamped: EqualizerSettings = { - preampDb: clamp(settings.preampDb), - bandsDb: settings.bandsDb.map(clamp), + const next: EqualizerSettings = { + preampDb: snapGain(settings.preampDb), + bandsDb: settings.bandsDb.map(snapGain), }; - cached = clamped; - writeLocalJsonSetting(STORAGE_KEY, clamped); - // Before the event, so a component that reads back on the change already sees it applied. - push(clamped); + // A drag reports every pointer move, and most of them land on the step it was already on. + if (sameEqualizerCurve(next, readSettings())) return; + + cached = next; + const preset = activeEqualizerPreset(next, readUserPresets()); + if (preset) basePresetName = preset.name; + else customCached = next; + + // Before the event, so a component that reads back on the change already hears it applied. + push(next); + persistSoon(); window.dispatchEvent(new Event(CHANGE_EVENT)); } -function clamp(gain: number): number { - if (!Number.isFinite(gain)) return 0; - return Math.min(EQUALIZER_MAX_DB, Math.max(-EQUALIZER_MAX_DB, gain)); +function writeUserPresets(presets: EqualizerPreset[]): void { + presetsCached = presets; + writeLocalJsonSetting(PRESETS_STORAGE_KEY, presets); + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +function findUserPreset(name: string): EqualizerPreset | undefined { + const key = name.trim().toLowerCase(); + return readUserPresets().find((preset) => preset.name.toLowerCase() === key); +} + +/** Whether saving under `name` would overwrite one of the user's presets instead of adding one. */ +export function isExistingEqualizerPreset(name: string): boolean { + return findUserPreset(name) !== undefined; +} + +/** Why `name` can't be used, or null. Saving over a user preset is allowed: that's how one is updated. */ +export function equalizerPresetNameError(name: string, renaming?: string): string | null { + const key = name.trim().toLowerCase(); + if (key === "") return "Give it a name"; + if ( + key === EQUALIZER_CUSTOM_NAME.toLowerCase() + || EQUALIZER_PRESETS.some((preset) => preset.name.toLowerCase() === key) + ) { + return "That name belongs to a built-in"; + } + if (renaming !== undefined && key !== renaming.toLowerCase() && findUserPreset(key)) { + return "Another preset already has that name"; + } + return null; +} + +/** The user preset the curve was tweaked from (so updating it is one Enter), else "My preset N". */ +export function suggestEqualizerPresetName(): string { + if (basePresetName !== null && findUserPreset(basePresetName)) return basePresetName; + for (let n = 1; ; n += 1) { + const name = n === 1 ? "My preset" : `My preset ${n}`; + if (!findUserPreset(name)) return name; + } +} + +/** Names the live curve, replacing a user preset that already has that name. */ +export function saveEqualizerPreset(name: string): void { + const saved: EqualizerPreset = { name: name.trim(), settings: readSettings() }; + const existing = findUserPreset(saved.name); + basePresetName = saved.name; + writeUserPresets( + existing + ? readUserPresets().map((preset) => (preset === existing ? saved : preset)) + : [...readUserPresets(), saved], + ); +} + +export function renameEqualizerPreset(from: string, to: string): void { + const name = to.trim(); + if (basePresetName === from) basePresetName = name; + writeUserPresets( + readUserPresets().map((preset) => (preset.name === from ? { ...preset, name } : preset)), + ); +} + +export function deleteEqualizerPreset(name: string): void { + const remaining = readUserPresets().filter((preset) => preset.name !== name); + // A deleted curve that is still playing stays recallable as Custom, so one-click delete is safe. + const current = readSettings(); + if (!activeEqualizerPreset(current, remaining)) { + customCached = current; + persistSoon(); + } + writeUserPresets(remaining); } function subscribe(callback: () => void) { @@ -193,23 +282,45 @@ function subscribeEnabled(callback: () => void) { } if (typeof window !== "undefined") { - window.addEventListener("storage", () => { + window.addEventListener("storage", (event) => { + // Own keys only: any key would drop a curve still waiting on its debounced write. + if (event.key !== null && !event.key.startsWith("equalizer-")) return; cached = null; enabledCached = null; + presetsCached = null; + customCached = undefined; + }); + // A close inside the debounce window would otherwise lose the last adjustment. + window.addEventListener("pagehide", () => { + if (persistTimer !== undefined) persistNow(); }); } export async function hydrateEqualizer(): Promise { - await hydrateLocalJsonSetting(STORAGE_KEY, isEqualizerSettings); - await hydrateLocalBooleanSetting(ENABLED_STORAGE_KEY, true, ENABLED_CHANGE_EVENT); + await Promise.all([ + hydrateLocalJsonSetting(STORAGE_KEY, isEqualizerSettings), + hydrateLocalJsonSetting(CUSTOM_STORAGE_KEY, isEqualizerSettings), + hydrateLocalJsonSetting(PRESETS_STORAGE_KEY, Array.isArray), + hydrateLocalBooleanSetting(ENABLED_STORAGE_KEY, true, ENABLED_CHANGE_EVENT), + ]); cached = null; enabledCached = null; + presetsCached = null; + customCached = undefined; + + const current = readSettings(); + // A curve tuned before the Custom slot existed seeds it, or the first preset click would lose it. + if (readCustom() === null && !activeEqualizerPreset(current, readUserPresets())) { + customCached = current; + writeLocalJsonSetting(CUSTOM_STORAGE_KEY, current); + } + /* * Rust starts flat every launch — the values are process state, not a file — so the stored * settings have to be pushed down or the equaliser silently does nothing until the user * touches a slider. */ - push(readSettings()); + push(current); window.dispatchEvent(new Event(CHANGE_EVENT)); } @@ -220,3 +331,13 @@ export function useEqualizer(): EqualizerSettings { export function useEqualizerEnabled(): boolean { return useSyncExternalStore(subscribeEnabled, isEqualizerEnabled, () => true); } + +/** The user's saved presets, in the order they were made. */ +export function useEqualizerUserPresets(): EqualizerPreset[] { + return useSyncExternalStore(subscribe, readUserPresets, () => NO_PRESETS); +} + +/** The Custom slot. See `CUSTOM_STORAGE_KEY`. */ +export function useEqualizerCustom(): EqualizerSettings | null { + return useSyncExternalStore(subscribe, readCustom, () => null); +} diff --git a/src/ui/settings/equalizerCurve.check.ts b/src/ui/settings/equalizerCurve.check.ts new file mode 100644 index 00000000..caea9f06 --- /dev/null +++ b/src/ui/settings/equalizerCurve.check.ts @@ -0,0 +1,105 @@ +// Self-check for the equaliser curve maths (`npm run check`). A wrong coefficient draws a plausible, wrong curve. +import { + autoPreampDb, + CURVE_HZ, + EQUALIZER_BANDS_HZ, + EQUALIZER_PRESETS, + EQUALIZER_STEP_DB, + frequencyPosition, + responseCurveDb, + sameEqualizerCurve, + snapGain, +} from "./equalizerCurve"; + +function check(condition: boolean, message: string): void { + if (!condition) throw new Error(`FAILED: ${message}`); +} + +function equal(actual: unknown, expected: unknown, message: string): void { + check(Object.is(actual, expected), `${message}: expected ${String(expected)}, got ${String(actual)}`); +} + +const LAST_POINT = CURVE_HZ.length - 1; +const pointAt = (hz: number) => Math.round(frequencyPosition(hz) * LAST_POINT); +const onlyBand = (band: number, gain: number) => + EQUALIZER_BANDS_HZ.map((_, index) => (index === band ? gain : 0)); +const peakOf = (bandsDb: number[]) => Math.max(...responseCurveDb(bandsDb)); + +equal(snapGain(0.3), 0.5, "an off-step gain snaps to the nearest half"); +equal(snapGain(0.2), 0, "and down as well as up"); +equal(snapGain(99), 12, "a huge boost clamps to the ceiling"); +equal(snapGain(-99), -12, "and a huge cut to the floor"); +equal(snapGain(Number.NaN), 0, "NaN cannot reach Rust"); +equal(snapGain(Number.POSITIVE_INFINITY), 0, "nor can Infinity"); + +// The pointer maths finds a band with `floor(x * 10)`, so each band must sit mid-column. +EQUALIZER_BANDS_HZ.forEach((hz, band) => { + const offset = Math.abs(frequencyPosition(hz) - (band + 0.5) / EQUALIZER_BANDS_HZ.length); + check(offset < 0.002, `${hz} Hz sits in the middle of column ${band} (off by ${offset})`); +}); + +check( + responseCurveDb(EQUALIZER_BANDS_HZ.map(() => 0)).every((db) => db === 0), + "a flat curve is exactly 0 dB everywhere, not merely close", +); + +EQUALIZER_BANDS_HZ.forEach((hz, band) => { + const boosted = responseCurveDb(onlyBand(band, 12)); + const atCentre = boosted[pointAt(hz)]; + check(Math.abs(atCentre - 12) < 0.05, `+12 dB at ${hz} Hz reads +12 at its centre, got ${atCentre}`); + + // A cut is the exact inverse filter, so its curve is the boost's mirror image. + const cut = responseCurveDb(onlyBand(band, -12)); + check( + cut.every((db, index) => Math.abs(db + boosted[index]) < 1e-9), + `-12 dB at ${hz} Hz mirrors +12`, + ); +}); + +// One band must not move the far end of the spectrum. +const midBoost = responseCurveDb(onlyBand(5, 12)); +check(midBoost[pointAt(62)] < 0.2, `1 kHz leaves 62 Hz alone, got ${midBoost[pointAt(62)]}`); +check(midBoost[pointAt(16000)] < 0.2, `1 kHz leaves 16 kHz alone, got ${midBoost[pointAt(16000)]}`); + +// Overlap is why the real curve is drawn: two neighbours at +6 stack past +6. +check(peakOf(onlyBand(4, 6).map((db, band) => (band === 5 ? 6 : db))) > 7, "neighbouring boosts stack"); + +equal(autoPreampDb(EQUALIZER_BANDS_HZ.map(() => 0)), 0, "a flat curve needs no trim"); +equal(autoPreampDb(onlyBand(3, 12)), -12, "a lone +12 is trimmed by exactly 12"); +equal(autoPreampDb(EQUALIZER_BANDS_HZ.map(() => 12)), -12, "the trim clamps like any other gain"); + +for (const bandsDb of [ + [3, 1.5, 0, 0, 2, 0, 0, 0, 4.5, 1], + [-6, -6, -6, -6, -6, -6, -6, -6, -6, -6], + [0, 0, 0, -3, 0, 0, 0, 0, 0, 0], +]) { + const preamp = autoPreampDb(bandsDb); + const netPeak = peakOf(bandsDb) + preamp; + equal(Math.abs(preamp % EQUALIZER_STEP_DB), 0, `auto preamp for [${bandsDb}] lands on a step`); + check(netPeak <= 1e-9 && netPeak > -EQUALIZER_STEP_DB, `[${bandsDb}] peaks at 0 dB after trim, got ${netPeak}`); +} + +// Built-ins: headroom-safe, on the grid, and uniquely named. +equal(EQUALIZER_PRESETS[0].name, "Flat", "Flat comes first"); +check( + EQUALIZER_PRESETS[0].settings.bandsDb.every((db) => db === 0) && EQUALIZER_PRESETS[0].settings.preampDb === 0, + "Flat is flat", +); +check( + new Set(EQUALIZER_PRESETS.map((preset) => preset.name.toLowerCase())).size === EQUALIZER_PRESETS.length, + "built-in names are unique", +); +for (const { name, settings } of EQUALIZER_PRESETS) { + check(settings.bandsDb.length === EQUALIZER_BANDS_HZ.length, `${name} has a gain per band`); + check(settings.bandsDb.every((db) => snapGain(db) === db), `${name} sits on the gain grid`); + const netPeak = peakOf(settings.bandsDb) + settings.preampDb; + check(netPeak <= 1e-9, `${name} stays out of the limiter (peaks at ${netPeak.toFixed(2)} dB)`); +} + +check(sameEqualizerCurve(EQUALIZER_PRESETS[1].settings, { ...EQUALIZER_PRESETS[1].settings }), "same curve"); +check( + !sameEqualizerCurve(EQUALIZER_PRESETS[1].settings, { ...EQUALIZER_PRESETS[1].settings, preampDb: 0 }), + "a different preamp is a different curve", +); + +console.log("equalizerCurve self-check passed"); diff --git a/src/ui/settings/equalizerCurve.ts b/src/ui/settings/equalizerCurve.ts new file mode 100644 index 00000000..c0ea3266 --- /dev/null +++ b/src/ui/settings/equalizerCurve.ts @@ -0,0 +1,105 @@ +// The equaliser as numbers: bands, limits, presets and the frequency response. Pure, so it can be checked. + +/** ISO octave centres. Matches `BAND_HZ` in `equalizer.rs`. */ +export const EQUALIZER_BANDS_HZ = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] as const; + +/** Matches `MAX_GAIN_DB` in `equalizer.rs`, which clamps to the same range on the way in. */ +export const EQUALIZER_MAX_DB = 12; + +/** Every stored gain is a multiple of this. Halves are exact in binary, so curves compare with `===`. */ +export const EQUALIZER_STEP_DB = 0.5; + +export type EqualizerSettings = { + preampDb: number; + bandsDb: number[]; +}; + +export type EqualizerPreset = { name: string; settings: EqualizerSettings }; + +/** Clamped to the range and snapped to the step. Anything that is not a number is 0 dB. */ +export function snapGain(db: number): number { + if (!Number.isFinite(db)) return 0; + const clamped = Math.min(EQUALIZER_MAX_DB, Math.max(-EQUALIZER_MAX_DB, db)); + return Math.round(clamped / EQUALIZER_STEP_DB) * EQUALIZER_STEP_DB; +} + +export function sameEqualizerCurve(a: EqualizerSettings, b: EqualizerSettings): boolean { + return a.preampDb === b.preampDb && a.bandsDb.every((gain, index) => gain === b.bandsDb[index]); +} + +/** Matches `BAND_Q` in `equalizer.rs`, so the curve drawn is the curve heard. */ +const BAND_Q = 1.414_213_6; + +/** Display only: the shapes barely move between 44.1 and 48 kHz. */ +const SAMPLE_RATE = 48_000; + +// Ten octaves from half an octave below the lowest band, so each band sits mid-column at (i + 0.5) / 10. +const LOWEST_HZ = 1000 * 2 ** -5.5; +const OCTAVES = 10; +const POINTS_PER_OCTAVE = 12; + +/** Semitone steps: close enough that the sampled peak is within a hair of the true one. */ +export const CURVE_HZ: readonly number[] = Array.from( + { length: OCTAVES * POINTS_PER_OCTAVE + 1 }, + (_, index) => LOWEST_HZ * 2 ** (index / POINTS_PER_OCTAVE), +); + +/** 0 at the graph's left edge, 1 at its right, on the log axis `CURVE_HZ` is spaced along. */ +export function frequencyPosition(hz: number): number { + return Math.log2(hz / LOWEST_HZ) / OCTAVES; +} + +/** One peaking biquad's gain at `hz`, from the same RBJ coefficients `Biquad::peaking` builds. */ +function peakingDb(centreHz: number, gainDb: number, hz: number): number { + const amplitude = 10 ** (gainDb / 40); + const omega = (2 * Math.PI * centreHz) / SAMPLE_RATE; + const alpha = Math.sin(omega) / (2 * BAND_Q); + const a0 = 1 + alpha / amplitude; + const b0 = (1 + alpha * amplitude) / a0; + const b1 = (-2 * Math.cos(omega)) / a0; // a1 is the same value for a peaking filter + const b2 = (1 - alpha * amplitude) / a0; + const a2 = (1 - alpha / amplitude) / a0; + + // |H(e^jw)|²: both polynomials evaluated on the unit circle. + const w = (2 * Math.PI * hz) / SAMPLE_RATE; + const [cos1, sin1, cos2, sin2] = [Math.cos(w), Math.sin(w), Math.cos(2 * w), Math.sin(2 * w)]; + const numerator = (b0 + b1 * cos1 + b2 * cos2) ** 2 + (b1 * sin1 + b2 * sin2) ** 2; + const denominator = (1 + b1 * cos1 + a2 * cos2) ** 2 + (b1 * sin1 + a2 * sin2) ** 2; + return 10 * Math.log10(numerator / denominator); +} + +/** The bands' combined gain in dB at each of `CURVE_HZ`. Neighbours overlap, so two +6s peak above 6. */ +export function responseCurveDb(bandsDb: readonly number[]): number[] { + return CURVE_HZ.map((hz) => + bandsDb.reduce( + (sum, gain, band) => (gain === 0 ? sum : sum + peakingDb(EQUALIZER_BANDS_HZ[band], gain, hz)), + 0, + ), + ); +} + +/** + * The preamp that puts the curve's peak at 0 dB. Floored to a step so rounding only ever leaves + * headroom; the epsilon stops float noise (12.000000001) costing a whole step. + */ +export function autoPreampDb(bandsDb: readonly number[]): number { + const peak = Math.max(...responseCurveDb(bandsDb)); + return snapGain(Math.floor(-peak / EQUALIZER_STEP_DB + 1e-6) * EQUALIZER_STEP_DB); +} + +// Preamps are derived, not hand-picked: the old ones ignored neighbouring bands stacking, so Bass ran 3 dB into the limiter. +const preset = (name: string, bandsDb: number[]): EqualizerPreset => ({ + name, + settings: { preampDb: autoPreampDb(bandsDb), bandsDb }, +}); + +export const EQUALIZER_PRESETS: readonly EqualizerPreset[] = [ + preset("Flat", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + preset("Bass", [6, 5, 4, 2, 0, 0, 0, 0, 0, 0]), + preset("Warm", [2, 3, 3, 2, 1, 0, -1, -1, -2, -2]), + preset("Vocal", [-2, -2, -1, 1, 3, 4, 3, 1, 0, 0]), + preset("Treble", [0, 0, 0, 0, 0, 1, 2, 4, 5, 5]), + preset("Loudness", [5, 4, 2, 0, -1, -1, 0, 2, 4, 4]), +]; + +export const EQUALIZER_FLAT: EqualizerSettings = EQUALIZER_PRESETS[0].settings;