- {PRESETS.map((p) => (
-
applyEqPreset(p)}
- className="h-7 px-2.5 text-xs capitalize"
- >
- {p}
-
- ))}
+ {!available && (
+
+ {getString("eq_unavailable")}
+
+ )}
+
+
+
+
+
+
+
+ {eq.hasCustom && (
+ restoreCustomEq(eq.state.mode)}
+ className="h-7 px-2.5 text-xs"
+ >
+ {getString("eq_preset_custom")}
+
+ )}
+ {eq.presets.map((preset) => (
+ pickEqPreset(preset.id)}
+ className="h-7 px-2.5 text-xs"
+ >
+ {preset.name}
+
+ ))}
+
+
+
+ {getString("eq_bass_boost")}
+ update((s) => withBassBoost(s, db))}
+ aria-label={getString("eq_bass_boost")}
+ className="flex-1"
+ />
+
+ {formatDb(eq.state.bassBoost)}
+
+
- {/* sliders */}
-
{
+ onClose();
+ setEditorOpen(true);
+ }}
+ className="mt-3 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-surface-container-high px-3 py-2 text-sm text-chrome-neutral-200 transition-colors duration-200 ease-out hover:bg-surface-container-highest"
>
- {EQ_BANDS.map((band, i) => (
-
-
- {formatGain(eqGains[i])}
-
- setEqBand(i, gain)}
- aria-label={`${band.label} Hz`}
- />
- {band.label}
-
- ))}
-
+
+ {getString("eq_edit")}
+
diff --git a/src/components/music/GlobalMusicAudio.tsx b/src/components/music/GlobalMusicAudio.tsx
index cd4d5edd..bfe31ab4 100644
--- a/src/components/music/GlobalMusicAudio.tsx
+++ b/src/components/music/GlobalMusicAudio.tsx
@@ -44,8 +44,6 @@ export function GlobalMusicAudio() {
const s = useMusicPlayerStore.getState();
musicAudioEngine.setVolume(s.volume);
musicAudioEngine.setMuted(s.isMuted);
- musicAudioEngine.setEqEnabled(s.eqEnabled);
- musicAudioEngine.setEqGains(s.eqGains);
bindMusicAudioSettings();
}, []);
diff --git a/src/components/music/GlobalMusicDock.tsx b/src/components/music/GlobalMusicDock.tsx
index 0b913971..d450dff6 100644
--- a/src/components/music/GlobalMusicDock.tsx
+++ b/src/components/music/GlobalMusicDock.tsx
@@ -29,6 +29,7 @@ import { useNowPlayingMenuActions } from "./useNowPlayingMenuActions";
import { MusicArtwork } from "./MusicArtwork";
import { MusicScrubber } from "./MusicScrubber";
import { EqPanel } from "./EqPanel";
+import { useEqActive } from "../../lib/audio/eq/useEqualizer";
import { VolumePopover } from "./VolumePopover";
import { useDominantColor } from "../../lib/useDominantColor";
import { accentForeground, accentSurface } from "../../lib/accentColor";
@@ -44,7 +45,7 @@ export function GlobalMusicDock() {
const isPlaying = useMusicPlayerStore((s) => s.isPlaying);
const isBuffering = useMusicPlayerStore((s) => s.isBuffering);
const loadingStreamId = useMusicPlayerStore((s) => s.loadingStreamId);
- const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled);
+ const eqEnabled = useEqActive();
const isShuffle = useMusicPlayerStore((s) => s.isShuffle);
const repeatMode = useMusicPlayerStore((s) => s.repeatMode);
const isMuted = useMusicPlayerStore((s) => s.isMuted);
diff --git a/src/components/music/MusicOverlay.tsx b/src/components/music/MusicOverlay.tsx
index 1a53d3bc..aa312143 100644
--- a/src/components/music/MusicOverlay.tsx
+++ b/src/components/music/MusicOverlay.tsx
@@ -38,6 +38,7 @@ import { MusicQueuePane } from "./MusicQueuePane";
import { MusicLyrics } from "./MusicLyrics";
import { AmbientBackdrop, isMusicBackgroundStyle } from "./AmbientBackdrop";
import { EqPanel } from "./EqPanel";
+import { useEqActive } from "../../lib/audio/eq/useEqualizer";
import { useLyrics } from "../../lib/lyrics/useLyrics";
import { useDominantColor } from "../../lib/useDominantColor";
import { accentForeground, accentSurface } from "../../lib/accentColor";
@@ -68,7 +69,7 @@ export function MusicOverlay() {
const loadingStreamId = useMusicPlayerStore((s) => s.loadingStreamId);
const repeatMode = useMusicPlayerStore((s) => s.repeatMode);
const isShuffle = useMusicPlayerStore((s) => s.isShuffle);
- const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled);
+ const eqEnabled = useEqActive();
const togglePlay = useMusicPlayerStore((s) => s.togglePlay);
const next = useMusicPlayerStore((s) => s.next);
diff --git a/src/components/music/eq/EqBandList.tsx b/src/components/music/eq/EqBandList.tsx
new file mode 100644
index 00000000..843a258c
--- /dev/null
+++ b/src/components/music/eq/EqBandList.tsx
@@ -0,0 +1,121 @@
+import { Plus, Trash2 } from "lucide-react";
+
+import { defaultQ, EQ_FILTER_TYPES, EqLimits, hasGain, type EqBand, type EqFilterType } from "../../../lib/audio/eq/models";
+import { getString, type StringKey } from "../../../lib/i18n/index";
+import { Select } from "../../ui/Select";
+import { Slider } from "../../ui/Slider";
+import { ToggleSwitch } from "../../ui/ToggleSwitch";
+import { EqNumberField } from "./EqNumberField";
+
+const TYPE_LABELS: Record = {
+ PEAK: "eq_type_peak",
+ LOW_SHELF: "eq_type_low_shelf",
+ HIGH_SHELF: "eq_type_high_shelf",
+ LOW_PASS: "eq_type_low_pass",
+ HIGH_PASS: "eq_type_high_pass",
+};
+
+// Q spans two decades, so its slider runs on a log scale: the middle is Q 1.4, not Q 10.
+const Q_STEPS = 1_000;
+const qToSlider = (q: number) =>
+ Math.round((Math.log(q / EqLimits.MIN_Q) / Math.log(EqLimits.MAX_Q / EqLimits.MIN_Q)) * Q_STEPS);
+const sliderToQ = (v: number) =>
+ Math.round(EqLimits.MIN_Q * Math.pow(EqLimits.MAX_Q / EqLimits.MIN_Q, v / Q_STEPS) * 100) / 100;
+
+export interface EqBandListProps {
+ bands: readonly EqBand[];
+ selected: number | null;
+ canAdd: boolean;
+ onSelect: (index: number) => void;
+ onChange: (index: number, band: EqBand) => void;
+ onRemove: (index: number) => void;
+ onAdd: () => void;
+}
+
+export function EqBandList({ bands, selected, canAdd, onSelect, onChange, onRemove, onAdd }: EqBandListProps) {
+ const typeOptions = EQ_FILTER_TYPES.map((type) => ({ value: type, label: getString(TYPE_LABELS[type]) }));
+
+ return (
+
+ {bands.length === 0 ? (
+
+ {getString("eq_no_bands")}
+
+ ) : (
+
+ {bands.map((band, index) => (
+ onSelect(index)}
+ onPointerDown={() => onSelect(index)}
+ className={`flex flex-wrap items-center gap-2 px-3 py-2 transition-colors duration-200 ease-out ${
+ index === selected ? "bg-surface-container-high" : "hover:bg-surface-container-low"
+ }`}
+ >
+ {index + 1}
+ {
+ const type = value as EqFilterType;
+ onChange(index, { ...band, type, q: defaultQ(type), gain: hasGain(type) ? band.gain : 0 });
+ }}
+ />
+ onChange(index, { ...band, frequency })}
+ />
+ onChange(index, { ...band, gain })}
+ />
+
+ {getString("eq_q")}
+ onChange(index, { ...band, q: sliderToQ(v) })}
+ aria-label={getString("eq_q")}
+ className="flex-1"
+ />
+ onChange(index, { ...band, q })}
+ />
+
+ onChange(index, { ...band, enabled })} />
+ onRemove(index)}
+ aria-label={getString("eq_remove_band")}
+ className="grid h-8 w-8 place-items-center rounded-full text-chrome-neutral-400 transition-colors duration-200 ease-out hover:bg-surface-container-highest hover:text-chrome-neutral-100"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ {getString("eq_add_band")}
+
+
+ );
+}
diff --git a/src/components/music/eq/EqCompareButton.tsx b/src/components/music/eq/EqCompareButton.tsx
new file mode 100644
index 00000000..f6bb592c
--- /dev/null
+++ b/src/components/music/eq/EqCompareButton.tsx
@@ -0,0 +1,37 @@
+import { getString } from "../../../lib/i18n/index";
+import { useEqStore } from "../../../store/useEqStore";
+
+/** Held down, the players hear the unequalized sound; let go, the equalizer comes back. */
+export function EqCompareButton({ className = "" }: { className?: string }) {
+ const bypass = useEqStore((s) => s.bypass);
+ const setBypass = useEqStore((s) => s.setBypass);
+ const release = () => setBypass(false);
+
+ return (
+ {
+ (event.target as Element).setPointerCapture(event.pointerId);
+ setBypass(true);
+ }}
+ onPointerUp={release}
+ onPointerCancel={release}
+ onLostPointerCapture={release}
+ onKeyDown={(event) => {
+ if ((event.key === " " || event.key === "Enter") && !event.repeat) setBypass(true);
+ }}
+ onKeyUp={(event) => {
+ if (event.key === " " || event.key === "Enter") release();
+ }}
+ onBlur={release}
+ className={`select-none rounded-full px-3 py-1.5 text-sm transition-colors duration-200 ease-out ${
+ bypass
+ ? "bg-chrome-zinc-100 text-chrome-zinc-950"
+ : "bg-surface-container-high text-chrome-neutral-200 hover:bg-surface-container-highest"
+ } ${className}`}
+ >
+ {getString("eq_compare")}
+
+ );
+}
diff --git a/src/components/music/eq/EqGraphicSliders.tsx b/src/components/music/eq/EqGraphicSliders.tsx
new file mode 100644
index 00000000..dbeb1237
--- /dev/null
+++ b/src/components/music/eq/EqGraphicSliders.tsx
@@ -0,0 +1,34 @@
+import { GraphicEq } from "../../../lib/audio/eq/limits";
+import type { EqBand } from "../../../lib/audio/eq/models";
+import { getString } from "../../../lib/i18n/index";
+import { Slider } from "../../ui/Slider";
+import { formatDb, formatFrequency } from "./eqFormat";
+
+export interface EqGraphicSlidersProps {
+ bands: readonly EqBand[];
+ disabled?: boolean;
+ onChange: (index: number, gain: number) => void;
+}
+
+/** Android's ten graphic bands, ±12 dB in half-dB steps. */
+export function EqGraphicSliders({ bands, disabled, onChange }: EqGraphicSlidersProps) {
+ return (
+
+ {bands.map((band, i) => (
+
+ {formatDb(band.gain)}
+ onChange(i, gain)}
+ aria-label={getString("eq_band_label", formatFrequency(band.frequency))}
+ />
+ {formatFrequency(band.frequency)}
+
+ ))}
+
+ );
+}
diff --git a/src/components/music/eq/EqImportExport.tsx b/src/components/music/eq/EqImportExport.tsx
new file mode 100644
index 00000000..c88b36ae
--- /dev/null
+++ b/src/components/music/eq/EqImportExport.tsx
@@ -0,0 +1,141 @@
+import { useMemo, useRef, useState, type ChangeEvent } from "react";
+import { ClipboardCopy, FileDown, FileUp } from "lucide-react";
+
+import { parseAutoEq } from "../../../lib/audio/eq/autoEqFormat";
+import { eqExportText, presetNameFromFile, saveEqTextFile } from "../../../lib/audio/eq/eqFiles";
+import { hasNonButterworthShelf } from "../../../lib/audio/eq/graphPlan";
+import { newPresetId } from "../../../lib/audio/eq/stateJson";
+import { importPreset, isNameAvailable, normalizedPresetName } from "../../../lib/audio/eq/stateOps";
+import { builtInPresetNames } from "../../../lib/audio/eq/useEqualizer";
+import { copyText } from "../../../lib/clipboard";
+import { getString } from "../../../lib/i18n/index";
+import { useEqStore } from "../../../store/useEqStore";
+import { useUiStore } from "../../../store/useUiStore";
+import { formatDb } from "./eqFormat";
+
+const BUTTON =
+ "inline-flex items-center gap-1.5 rounded-full bg-surface-container-high px-3 py-1.5 text-sm text-chrome-neutral-200 transition-colors duration-200 ease-out hover:bg-surface-container-highest disabled:opacity-40";
+
+/** AutoEQ / EqualizerAPO import from a file or pasted text, with a preview, and export. */
+export function EqImportExport() {
+ const state = useEqStore((s) => s.state);
+ const update = useEqStore((s) => s.update);
+ const showToast = useUiStore((s) => s.showToast);
+ const fileRef = useRef(null);
+ const [text, setText] = useState("");
+ const [name, setName] = useState("");
+
+ const parsed = useMemo(() => (text.trim() ? parseAutoEq(text) : null), [text]);
+ const wanted = normalizedPresetName(name || getString("eq_import_default_name"));
+ const nameFree = isNameAvailable(state, wanted, builtInPresetNames());
+
+ const onFile = async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ try {
+ setText(await file.text());
+ setName(presetNameFromFile(file.name));
+ } catch {
+ showToast({ message: getString("eq_import_read_failed"), variant: "error" });
+ }
+ };
+
+ const runImport = () => {
+ if (!parsed || !nameFree) return;
+ update((s) => importPreset(s, newPresetId(), wanted, parsed.curve));
+ showToast({ message: getString("eq_import_done", wanted), variant: "success" });
+ setText("");
+ setName("");
+ };
+
+ const exportToFile = async () => {
+ try {
+ const saved = await saveEqTextFile(eqExportText(state), getString("eq_export_file"), "ParametricEQ.txt");
+ if (saved) showToast({ message: getString("eq_export_saved"), variant: "success" });
+ } catch {
+ showToast({ message: getString("eq_export_failed"), variant: "error" });
+ }
+ };
+
+ const exportToClipboard = async () => {
+ const copied = await copyText(eqExportText(state));
+ showToast({
+ message: getString(copied ? "eq_export_copied" : "eq_export_failed"),
+ variant: copied ? "success" : "error",
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/music/eq/EqNumberField.tsx b/src/components/music/eq/EqNumberField.tsx
new file mode 100644
index 00000000..09b83462
--- /dev/null
+++ b/src/components/music/eq/EqNumberField.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from "react";
+
+export interface EqNumberFieldProps {
+ value: number;
+ decimals: number;
+ unit?: string;
+ disabled?: boolean;
+ ariaLabel: string;
+ onCommit: (value: number) => void;
+}
+
+/**
+ * A number typed in full before it applies: committing on every keystroke would play "1", then
+ * "10", then "100" Hz on the way to 1000.
+ */
+export function EqNumberField({ value, decimals, unit, disabled, ariaLabel, onCommit }: EqNumberFieldProps) {
+ const shown = value.toFixed(decimals);
+ const [draft, setDraft] = useState(shown);
+ useEffect(() => setDraft(shown), [shown]);
+
+ const commit = () => {
+ const parsed = Number(draft.replace(",", "."));
+ if (Number.isFinite(parsed) && draft.trim() !== "") onCommit(parsed);
+ else setDraft(shown);
+ };
+
+ return (
+
+ setDraft(event.target.value)}
+ onBlur={commit}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") commit();
+ if (event.key === "Escape") {
+ // Escape undoes the typing; the dialog around the field stays open.
+ event.stopPropagation();
+ setDraft(shown);
+ }
+ }}
+ className="w-14 bg-transparent font-mono text-xs tabular-nums text-chrome-neutral-100 outline-none disabled:opacity-40"
+ />
+ {unit && {unit} }
+
+ );
+}
diff --git a/src/components/music/eq/EqPresetBar.tsx b/src/components/music/eq/EqPresetBar.tsx
new file mode 100644
index 00000000..eef687ad
--- /dev/null
+++ b/src/components/music/eq/EqPresetBar.tsx
@@ -0,0 +1,198 @@
+import { useEffect, useState } from "react";
+import { Copy, Pencil, RotateCcw, Save, Trash2 } from "lucide-react";
+
+import { newPresetId } from "../../../lib/audio/eq/stateJson";
+import {
+ deletePreset,
+ duplicatePreset,
+ isNameAvailable,
+ normalizedPresetName,
+ renamePreset,
+ restorePreset,
+ revert,
+ saveActive,
+ saveActiveAs,
+ type DeletedEqPreset,
+} from "../../../lib/audio/eq/stateOps";
+import {
+ builtInPresetNames,
+ pickEqPreset,
+ restoreCustomEq,
+ useEqualizer,
+} from "../../../lib/audio/eq/useEqualizer";
+import { getString } from "../../../lib/i18n/index";
+import { useEqStore } from "../../../store/useEqStore";
+import { Chip } from "../../ui/Chip";
+
+type NameAction = "saveAs" | "rename" | "duplicate";
+
+const UNDO_MS = 6_000;
+
+const ACTION_BUTTON =
+ "inline-flex items-center gap-1.5 rounded-full bg-surface-container-high px-3 py-1.5 text-xs text-chrome-neutral-200 transition-colors duration-200 ease-out hover:bg-surface-container-highest disabled:opacity-40";
+
+/** Preset chips with Save, Save as, Rename, Duplicate, Delete and Undo. */
+export function EqPresetBar() {
+ const eq = useEqualizer();
+ const update = useEqStore((s) => s.update);
+ const [naming, setNaming] = useState(null);
+ const [name, setName] = useState("");
+ const [deleted, setDeleted] = useState(null);
+
+ useEffect(() => {
+ if (!deleted) return;
+ const timer = setTimeout(() => setDeleted(null), UNDO_MS);
+ return () => clearTimeout(timer);
+ }, [deleted]);
+
+ const selectedId = eq.active.presetId;
+ const exceptId = naming === "rename" ? selectedId : null;
+ const wanted = normalizedPresetName(name);
+ const nameFree = isNameAvailable(eq.state, wanted, builtInPresetNames(), exceptId);
+
+ const startNaming = (action: NameAction) => {
+ setNaming(action);
+ const base = eq.activeName ?? getString("eq_preset_custom");
+ setName(action === "rename" ? base : getString("eq_copy_name", base));
+ };
+
+ const submitName = () => {
+ if (!naming || !nameFree) return;
+ if (naming === "saveAs") update((s) => saveActiveAs(s, newPresetId(), wanted));
+ if (naming === "rename" && selectedId) update((s) => renamePreset(s, selectedId, wanted));
+ if (naming === "duplicate" && selectedId) update((s) => duplicatePreset(s, selectedId, newPresetId(), wanted));
+ setNaming(null);
+ };
+
+ const remove = () => {
+ if (!selectedId) return;
+ const [, removed] = deletePreset(eq.state, selectedId);
+ if (!removed) return;
+ update((s) => deletePreset(s, selectedId)[0]);
+ setDeleted(removed);
+ };
+
+ return (
+
+
+ {eq.hasCustom && (
+ restoreCustomEq(eq.state.mode)}
+ className="h-7 px-2.5 text-xs"
+ >
+ {getString("eq_preset_custom")}
+
+ )}
+ {eq.presets.map((preset) => (
+ pickEqPreset(preset.id)}
+ className="h-7 px-2.5 text-xs"
+ >
+ {preset.name}
+
+ ))}
+
+
+
+ {eq.edited && (
+ <>
+
+ {getString("eq_edited")}
+
+ {selectedId && (
+
update(revert)}>
+
+ {getString("eq_revert")}
+
+ )}
+ >
+ )}
+ {eq.canSave && (
+
update(saveActive)}>
+
+ {getString("eq_save")}
+
+ )}
+
startNaming("saveAs")}>
+
+ {getString("eq_save_as")}
+
+ {selectedId && (
+
startNaming("duplicate")}>
+
+ {getString("eq_duplicate")}
+
+ )}
+ {eq.selectedIsUserPreset && (
+ <>
+
startNaming("rename")}>
+
+ {getString("eq_rename")}
+
+
+
+ {getString("eq_delete")}
+
+ >
+ )}
+
+
+ {naming && (
+
+ )}
+
+ {deleted && (
+
+ {getString("eq_deleted", deleted.preset.name)}
+ {
+ update((s) => restorePreset(s, deleted));
+ setDeleted(null);
+ }}
+ >
+ {getString("undo")}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/music/eq/EqResponseGraph.tsx b/src/components/music/eq/EqResponseGraph.tsx
new file mode 100644
index 00000000..8773c810
--- /dev/null
+++ b/src/components/music/eq/EqResponseGraph.tsx
@@ -0,0 +1,198 @@
+import { useMemo, useRef, useState, type PointerEvent, type WheelEvent } from "react";
+
+import { fractionOf, frequencyAt, magnitudeDb } from "../../../lib/audio/eq/filterMath";
+import { useElementWidth } from "../../../lib/useElementWidth";
+import { GraphicEq } from "../../../lib/audio/eq/limits";
+import { EqLimits, hasGain, type EqBand } from "../../../lib/audio/eq/models";
+
+const POINTS = 160;
+const LABELS: [number, string][] = [
+ [31, "31"],
+ [125, "125"],
+ [500, "500"],
+ [2_000, "2k"],
+ [8_000, "8k"],
+ [16_000, "16k"],
+];
+
+export interface EqGraphHandle {
+ band: EqBand;
+ /** Graphic sliders only move up and down. */
+ verticalOnly: boolean;
+}
+
+export interface EqResponseGraphProps {
+ /** Every filter that shapes the sound, bass boost included. */
+ bands: readonly EqBand[];
+ /** Draggable points; none for the read-only mini graph. */
+ handles?: readonly EqGraphHandle[];
+ selected?: number | null;
+ height?: number;
+ rangeDb?: number;
+ onSelect?: (index: number) => void;
+ /** A point moved; the band it now describes. */
+ onDrag?: (index: number, band: EqBand) => void;
+ onDragEnd?: () => void;
+ onAddAt?: (frequency: number, gain: number) => void;
+ ariaLabel: string;
+}
+
+const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
+
+/** The equalizer's frequency response, drawn from the same maths that sets the filters. */
+export function EqResponseGraph({
+ bands,
+ handles = [],
+ selected = null,
+ height = 180,
+ rangeDb = 15,
+ onSelect,
+ onDrag,
+ onDragEnd,
+ onAddAt,
+ ariaLabel,
+}: EqResponseGraphProps) {
+ const svgRef = useRef(null);
+ const [dragging, setDragging] = useState(null);
+ // Drawn at its real pixel size, so points stay round and labels unstretched.
+ const width = useElementWidth(svgRef, 600);
+
+ const xOf = (frequency: number) => fractionOf(frequency) * width;
+ const yOf = (db: number) => height / 2 - (clamp(db, -rangeDb, rangeDb) / rangeDb) * (height / 2 - 6);
+
+ const path = useMemo(() => {
+ let d = "";
+ for (let i = 0; i <= POINTS; i++) {
+ const f = frequencyAt(i / POINTS);
+ d += `${i === 0 ? "M" : "L"}${((i / POINTS) * width).toFixed(1)},${yOf(magnitudeDb(bands, f)).toFixed(1)}`;
+ }
+ return d;
+ }, [bands, width, height, rangeDb]);
+
+ const pointerToValues = (event: PointerEvent | { clientX: number; clientY: number }) => {
+ const rect = svgRef.current?.getBoundingClientRect();
+ if (!rect || rect.width === 0) return null;
+ const x = clamp((event.clientX - rect.left) / rect.width, 0, 1);
+ const y = clamp((event.clientY - rect.top) / rect.height, 0, 1);
+ const gain = ((0.5 - y) * 2 * rangeDb * height) / (height - 12);
+ return { frequency: frequencyAt(x), gain: clamp(gain, -rangeDb, rangeDb) };
+ };
+
+ const moveHandle = (index: number, event: PointerEvent) => {
+ const handle = handles[index];
+ const values = pointerToValues(event);
+ if (!handle || !values || !onDrag) return;
+ const { band } = handle;
+ if (handle.verticalOnly) {
+ const step = GraphicEq.STEP;
+ const gain = clamp(Math.round(values.gain / step) * step, -GraphicEq.MAX_GAIN, GraphicEq.MAX_GAIN);
+ onDrag(index, { ...band, gain: gain + 0 });
+ return;
+ }
+ onDrag(index, {
+ ...band,
+ frequency: Math.round(values.frequency),
+ gain: hasGain(band.type) ? Math.round(values.gain * 10) / 10 : 0,
+ });
+ };
+
+ // The wheel widens or narrows the selected band, on the same log scale as its Q slider.
+ const onWheel = (index: number, event: WheelEvent) => {
+ const handle = handles[index];
+ if (!handle || handle.verticalOnly || !onDrag) return;
+ const factor = Math.pow(1.1, event.deltaY > 0 ? -1 : 1);
+ onDrag(index, { ...handle.band, q: clamp(handle.band.q * factor, EqLimits.MIN_Q, EqLimits.MAX_Q) });
+ onDragEnd?.();
+ };
+
+ return (
+ {
+ const values = onAddAt ? pointerToValues(event) : null;
+ if (values) onAddAt?.(Math.round(values.frequency), Math.round(values.gain * 10) / 10);
+ }}
+ >
+ {[-12, -6, 0, 6, 12]
+ .filter((db) => Math.abs(db) < rangeDb)
+ .map((db) => (
+
+ ))}
+ {LABELS.map(([f, label]) => (
+
+
+ {height >= 120 && (
+
+ {label}
+
+ )}
+
+ ))}
+
+ {handles.map((handle, index) => {
+ const { band } = handle;
+ const active = index === selected || index === dragging;
+ return (
+ {
+ event.stopPropagation();
+ (event.target as Element).setPointerCapture(event.pointerId);
+ setDragging(index);
+ onSelect?.(index);
+ }}
+ onPointerMove={(event) => {
+ if (dragging === index) moveHandle(index, event);
+ }}
+ onPointerUp={() => {
+ if (dragging !== index) return;
+ setDragging(null);
+ onDragEnd?.();
+ }}
+ onPointerCancel={() => {
+ setDragging(null);
+ onDragEnd?.();
+ }}
+ onWheel={(event) => onWheel(index, event)}
+ onDoubleClick={(event) => event.stopPropagation()}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/src/components/music/eq/EqualizerDialog.tsx b/src/components/music/eq/EqualizerDialog.tsx
new file mode 100644
index 00000000..f3907530
--- /dev/null
+++ b/src/components/music/eq/EqualizerDialog.tsx
@@ -0,0 +1,237 @@
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { X } from "lucide-react";
+
+import { GraphicEq } from "../../../lib/audio/eq/limits";
+import { band, EqLimits, type EqBand, type EqMode } from "../../../lib/audio/eq/models";
+import {
+ addBand,
+ removeBand,
+ withActiveCurve,
+ withBand,
+ withBassBoost,
+ withManualPreamp,
+} from "../../../lib/audio/eq/stateOps";
+import { useEqualizer, useMusicEqAvailable } from "../../../lib/audio/eq/useEqualizer";
+import { getString } from "../../../lib/i18n/index";
+import { useEqStore } from "../../../store/useEqStore";
+import { Chip } from "../../ui/Chip";
+import { Slider } from "../../ui/Slider";
+import { ToggleSwitch } from "../../ui/ToggleSwitch";
+import { EqBandList } from "./EqBandList";
+import { EqCompareButton } from "./EqCompareButton";
+import { EqGraphicSliders } from "./EqGraphicSliders";
+import { EqImportExport } from "./EqImportExport";
+import { EqPresetBar } from "./EqPresetBar";
+import { EqResponseGraph } from "./EqResponseGraph";
+import { formatDb } from "./eqFormat";
+
+const SECTION_LABEL = "text-xs font-semibold uppercase tracking-widest text-chrome-neutral-500";
+
+/** The full equalizer editor, opened from the quick panel's Edit button. Mounted once. */
+export function EqualizerDialog() {
+ const open = useEqStore((s) => s.editorOpen);
+ const setOpen = useEqStore((s) => s.setEditorOpen);
+ const setPreview = useEqStore((s) => s.setPreview);
+ const setBypass = useEqStore((s) => s.setBypass);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ // Nothing held or dragged outlives the editor.
+ setPreview(null);
+ setBypass(false);
+ };
+ }, [open, setOpen, setPreview, setBypass]);
+
+ if (!open) return null;
+ return createPortal(
+ {
+ if (event.target === event.currentTarget) setOpen(false);
+ }}
+ >
+ setOpen(false)} />
+
,
+ document.body,
+ );
+}
+
+function EqualizerEditor({ onClose }: { onClose: () => void }) {
+ const eq = useEqualizer();
+ const available = useMusicEqAvailable();
+ const update = useEqStore((s) => s.update);
+ const preview = useEqStore((s) => s.preview);
+ const setPreview = useEqStore((s) => s.setPreview);
+ const [selected, setSelected] = useState(null);
+
+ const { state, active } = eq;
+ const graphic = state.mode === "GRAPHIC";
+ const editedBands = (preview ?? active.curve).bands;
+
+ const dragBand = (index: number, next: EqBand) => {
+ const base = preview ?? active.curve;
+ setPreview({ ...base, bands: base.bands.map((b, i) => (i === index ? next : b)) });
+ };
+ const commitDrag = () => {
+ const curve = useEqStore.getState().preview;
+ if (curve) update((s) => withActiveCurve(s, curve));
+ };
+ const add = (next: EqBand) => {
+ update((s) => addBand(s, next));
+ setSelected(active.curve.bands.length);
+ };
+ const setMode = (mode: EqMode) => {
+ setSelected(null);
+ update((s) => ({ ...s, mode }));
+ };
+
+ return (
+
+
+
+ {!available && (
+
+
{getString("eq_unavailable")}
+
{getString("eq_unavailable_desc")}
+
+ )}
+
+
+
+ setMode("GRAPHIC")} className="h-8 px-3 text-sm">
+ {getString("eq_mode_graphic")}
+
+ setMode("PARAMETRIC")} className="h-8 px-3 text-sm">
+ {getString("eq_mode_parametric")}
+
+
+
+
+
+ {getString("eq_presets")}
+
+
+
+
+ ({ band: b, verticalOnly: graphic }))}
+ selected={selected}
+ rangeDb={graphic ? GraphicEq.MAX_GAIN + 3 : 18}
+ onSelect={setSelected}
+ onDrag={dragBand}
+ onDragEnd={commitDrag}
+ onAddAt={graphic ? undefined : (frequency, gain) => add(band(frequency, gain))}
+ ariaLabel={getString("eq_graph_label")}
+ />
+
+
+ {graphic ? (
+
{
+ const current = active.curve.bands[index];
+ if (current) update((s) => withBand(s, index, { ...current, gain }));
+ }}
+ />
+ ) : (
+ update((s) => withBand(s, index, next))}
+ onRemove={(index) => {
+ setSelected(null);
+ update((s) => removeBand(s, index));
+ }}
+ onAdd={() => add(band(1_000, 0))}
+ />
+ )}
+
+
+
+
+
+ {getString("eq_auto_preamp")}
+ {getString("eq_auto_preamp_desc")}
+
+ update((s) => ({ ...s, autoPreamp }))}
+ />
+
+ {state.autoPreamp ? (
+
+ {getString("eq_db_value", formatDb(eq.autoPreampDb))}
+
+ ) : (
+
+ update((s) => withManualPreamp(s, db))}
+ aria-label={getString("eq_preamp")}
+ className="flex-1"
+ />
+
+ {getString("eq_db_value", formatDb(active.curve.preamp))}
+
+
+ )}
+
+
+
+
{getString("eq_bass_boost")}
+
+ update((s) => withBassBoost(s, db))}
+ aria-label={getString("eq_bass_boost")}
+ className="flex-1"
+ />
+
+ {getString("eq_db_value", formatDb(state.bassBoost))}
+
+
+
+
+
+
+ {getString("eq_import_title")}
+
+
+
+
+ );
+}
diff --git a/src/components/music/eq/eqFormat.test.ts b/src/components/music/eq/eqFormat.test.ts
new file mode 100644
index 00000000..ed34651d
--- /dev/null
+++ b/src/components/music/eq/eqFormat.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+
+import { formatDb, formatFrequency } from "./eqFormat";
+
+describe("eqFormat", () => {
+ it("labels frequencies the way EQ axes do", () => {
+ expect(formatFrequency(31)).toBe("31");
+ expect(formatFrequency(1_000)).toBe("1k");
+ expect(formatFrequency(2_500)).toBe("2.5k");
+ expect(formatFrequency(16_000)).toBe("16k");
+ });
+
+ it("signs gains and drops a negative zero", () => {
+ expect(formatDb(3)).toBe("+3");
+ expect(formatDb(-1.5)).toBe("-1.5");
+ expect(formatDb(-0.04)).toBe("0");
+ });
+});
diff --git a/src/components/music/eq/eqFormat.ts b/src/components/music/eq/eqFormat.ts
new file mode 100644
index 00000000..0b339054
--- /dev/null
+++ b/src/components/music/eq/eqFormat.ts
@@ -0,0 +1,15 @@
+/** "1k", "16k", "250": the way frequency axes are usually labelled. */
+export function formatFrequency(hz: number): string {
+ if (hz >= 1_000) {
+ const k = hz / 1_000;
+ return `${Number.isInteger(k) ? k : k.toFixed(1)}k`;
+ }
+ return String(Math.round(hz));
+}
+
+/** A signed gain with one decimal where it has one: "+3", "-1.5", "0". */
+export function formatDb(db: number): string {
+ const rounded = Math.round(db * 10) / 10;
+ const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
+ return rounded > 0 ? `+${text}` : text;
+}
diff --git a/src/lib/audio/audioContext.ts b/src/lib/audio/audioContext.ts
new file mode 100644
index 00000000..c6dfac47
--- /dev/null
+++ b/src/lib/audio/audioContext.ts
@@ -0,0 +1,61 @@
+type AudioContextCtor = typeof AudioContext;
+
+function audioContextCtor(): AudioContextCtor | null {
+ if (typeof window === "undefined") return null;
+ return (
+ window.AudioContext ??
+ (window as unknown as { webkitAudioContext?: AudioContextCtor }).webkitAudioContext ??
+ null
+ );
+}
+
+export const webAudioSupported = (): boolean => audioContextCtor() !== null;
+
+export function createAudioContext(): AudioContext | null {
+ const Ctor = audioContextCtor();
+ return Ctor ? new Ctor() : null;
+}
+
+/**
+ * How long a start waits for a suspended context. WebView2 can leave `resume()` pending forever
+ * without a user gesture, so playback must never wait on it unbounded.
+ */
+const RESUME_TIMEOUT_MS = 400;
+
+/** Resumes `ctx`, giving up after a short wait; the element starts either way. */
+export async function resumeWithin(ctx: AudioContext, timeoutMs = RESUME_TIMEOUT_MS): Promise {
+ if (ctx.state === "running" || ctx.state === "closed") return;
+ let timer: ReturnType | undefined;
+ try {
+ await Promise.race([
+ ctx.resume(),
+ new Promise((resolve) => {
+ timer = setTimeout(resolve, timeoutMs);
+ }),
+ ]);
+ } catch {
+ // A rejected resume leaves the context as it was; the next state change retries.
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * Resumes `ctx` whenever the system suspends or interrupts it (sleep, a Bluetooth reconnect, an
+ * output device change) while something should be playing. Without this the element keeps
+ * advancing with no sound until the user pauses and plays again.
+ */
+export function keepRunning(ctx: AudioContext, shouldRun: () => boolean): () => void {
+ const recover = () => {
+ // WebKit reports "interrupted", which the DOM typings do not list.
+ const state = ctx.state as AudioContextState | "interrupted";
+ if (state !== "running" && state !== "closed" && shouldRun()) void resumeWithin(ctx);
+ };
+ ctx.addEventListener("statechange", recover);
+ const devices = typeof navigator !== "undefined" ? navigator.mediaDevices : undefined;
+ devices?.addEventListener?.("devicechange", recover);
+ return () => {
+ ctx.removeEventListener("statechange", recover);
+ devices?.removeEventListener?.("devicechange", recover);
+ };
+}
diff --git a/src/lib/audio/eq/autoEqFormat.test.ts b/src/lib/audio/eq/autoEqFormat.test.ts
new file mode 100644
index 00000000..7b5e0a1b
--- /dev/null
+++ b/src/lib/audio/eq/autoEqFormat.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest";
+
+import { formatAutoEq, parseAutoEq } from "./autoEqFormat";
+import { band, EqLimits, type EqCurve } from "./models";
+
+// Ported from Android's AutoEqFormatTest.
+describe("AutoEqFormat", () => {
+ it("reads an AutoEQ file and skips its empty filters", () => {
+ const text = [
+ "Preamp: -2.0 dB",
+ "Filter 1: ON PK Fc 60 Hz Gain 4.0 dB Q 0.710",
+ "Filter 2: ON LSC Fc 105 Hz Gain 5.5 dB Q 0.70",
+ "Filter 3: ON HSC Fc 10000 Hz Gain -2.1 dB Q 0.70",
+ "Filter 4: OFF PK Fc 0 Hz Gain 0.0 dB Q 0.000",
+ "Filter 5: OFF PK Fc 0 Hz Gain 0.0 dB Q 0.000",
+ ].join("\n");
+
+ const result = parseAutoEq(text)!;
+
+ expect(result.curve.preamp).toBe(-2);
+ expect(result.curve.bands).toEqual([
+ band(60, 4, 0.71, "PEAK"),
+ band(105, 5.5, 0.7, "LOW_SHELF"),
+ band(10_000, -2.1, 0.7, "HIGH_SHELF"),
+ ]);
+ expect(result.skippedFilters).toBe(2);
+ });
+
+ it("an ON filter at 0 Hz or Q 0 is refused rather than turned into NaN", () => {
+ const text = "Filter 1: ON PK Fc 0 Hz Gain 3 dB Q 1\nFilter 2: ON PK Fc 500 Hz Gain 3 dB Q 0\nFilter 3: ON PK Fc 800 Hz Gain 1 dB Q 1";
+ const result = parseAutoEq(text)!;
+ expect(result.curve.bands).toEqual([band(800, 1, 1)]);
+ expect(result.skippedFilters).toBe(2);
+ expect(parseAutoEq("Filter 1: ON PK Fc 0 Hz Gain 3 dB Q 0")).toBeNull();
+ });
+
+ it("accepts comma decimals, lower case, short type names and bandwidth", () => {
+ const text = [
+ "preamp: -3,5 dB",
+ "filter 1: on pk fc 1000 hz gain 2,5 db q 1,41",
+ "Filter 2: ON LS Fc 80 Hz Gain 3 dB",
+ "Filter 3: ON PK Fc 2000 Hz Gain -1 dB BW Oct 1.0",
+ "Filter 4: ON HP Fc 30 Hz Q 0.5",
+ "Filter 5: ON LSC 12 dB Fc 90 Hz Gain 2 dB",
+ ].join("\n");
+
+ const bands = parseAutoEq(text)!.curve.bands;
+
+ expect(bands[0]).toEqual(band(1_000, 2.5, 1.41, "PEAK"));
+ expect(bands[1]).toEqual(band(80, 3, EqLimits.DEFAULT_SHELF_Q, "LOW_SHELF"));
+ expect(Math.abs(bands[2]!.q - 1.414)).toBeLessThan(0.001);
+ expect(bands[3]).toEqual(band(30, 0, 0.5, "HIGH_PASS"));
+ expect(bands[4]).toEqual(band(90, 2, EqLimits.DEFAULT_SHELF_Q, "LOW_SHELF"));
+ });
+
+ it("a thousands separator in a frequency is not a decimal mark", () => {
+ expect(parseAutoEq("Filter 1: ON PK Fc 1,000 Hz Gain 2 dB Q 1")!.curve.bands[0]!.frequency).toBe(1_000);
+ });
+
+ it("counts unsupported filters and other commands", () => {
+ const text = [
+ "# headphone target",
+ "Device: Speakers",
+ "Channel: L",
+ "Filter 1: ON NO Fc 50 Hz Q 30",
+ "Filter 2: ON PK Fc 500 Hz Gain 1 dB Q 1",
+ ].join("\n");
+
+ const result = parseAutoEq(text)!;
+
+ expect(result.curve.bands).toHaveLength(1);
+ expect(result.skippedFilters).toBe(1);
+ expect(result.ignoredLines).toBe(2);
+ });
+
+ it("keeps the first twenty filters", () => {
+ const text = Array.from({ length: 25 }, (_, i) => `Filter ${i + 1}: ON PK Fc ${(i + 1) * 100} Hz Gain 1 dB Q 1`).join("\n");
+ const result = parseAutoEq(text)!;
+ expect(result.curve.bands).toHaveLength(EqLimits.MAX_BANDS);
+ expect(result.truncatedFilters).toBe(5);
+ });
+
+ it("clamps values outside the limits", () => {
+ expect(parseAutoEq("Filter 1: ON PK Fc 5 Hz Gain 40 dB Q 50")!.curve.bands[0]).toEqual(
+ band(EqLimits.MIN_FREQUENCY, EqLimits.MAX_GAIN, EqLimits.MAX_Q, "PEAK"),
+ );
+ });
+
+ it("text without filters or preamp is not a preset", () => {
+ expect(parseAutoEq("")).toBeNull();
+ expect(parseAutoEq("hello\nworld")).toBeNull();
+ });
+
+ it("exported text reads back to the same curve", () => {
+ const curve: EqCurve = {
+ preamp: -4.5,
+ bands: [
+ band(64, 3.5, 0.7, "LOW_SHELF"),
+ band(910, -2, 1.41),
+ band(15_000, 0, 0.707, "LOW_PASS"),
+ band(3_000, 2, 1, "PEAK", false),
+ ],
+ };
+
+ const text = formatAutoEq(curve);
+ const parsed = parseAutoEq(text)!;
+
+ expect(text).toContain("Filter 3: ON LPQ Fc 15000 Hz Q 0.707");
+ expect(parsed.curve).toEqual({ ...curve, bands: curve.bands.slice(0, 3) });
+ expect(parsed.skippedFilters).toBe(1);
+ });
+});
diff --git a/src/lib/audio/eq/autoEqFormat.ts b/src/lib/audio/eq/autoEqFormat.ts
new file mode 100644
index 00000000..b0eb61e7
--- /dev/null
+++ b/src/lib/audio/eq/autoEqFormat.ts
@@ -0,0 +1,126 @@
+import { sanitizeBand, sanitizeCurve } from "./limits";
+import { APO_CODES, defaultQ, EqLimits, hasGain, type EqBand, type EqCurve, type EqFilterType } from "./models";
+
+/** What an EqualizerAPO / AutoEQ `ParametricEQ.txt` import produced, and what it left out. */
+export interface AutoEqImport {
+ curve: EqCurve;
+ skippedFilters: number;
+ ignoredLines: number;
+ truncatedFilters: number;
+}
+
+/*
+ * Reads and writes the EqualizerAPO parametric text format that AutoEQ publishes, for example
+ * `Filter 1: ON PK Fc 105 Hz Gain -2.3 dB Q 0.70`. Ported from Android's `AutoEqFormat.kt`:
+ * lenient about case, spacing, comma decimals and the short type names; strict about anything that
+ * would break the audio, such as Q 0.
+ */
+const FILTER_PREFIX = /^filter\s*\d*\s*:/i;
+const PREAMP_PREFIX = /^preamp\s*:/i;
+const NUMBER = /^[-+]?\d+(?:[.,]\d+)?$/;
+const THOUSANDS = /^\d{1,2},\d{3}$/;
+
+const TYPES: Record = {
+ PK: "PEAK",
+ PEQ: "PEAK",
+ MODAL: "PEAK",
+ LS: "LOW_SHELF",
+ LSC: "LOW_SHELF",
+ LSQ: "LOW_SHELF",
+ HS: "HIGH_SHELF",
+ HSC: "HIGH_SHELF",
+ HSQ: "HIGH_SHELF",
+ LP: "LOW_PASS",
+ LPQ: "LOW_PASS",
+ HP: "HIGH_PASS",
+ HPQ: "HIGH_PASS",
+};
+
+const tokens = (text: string): string[] => text.trim().split(/\s+/).filter(Boolean);
+
+const toNumber = (text: string | undefined): number | null =>
+ text !== undefined && NUMBER.test(text) ? Number(text.replace(",", ".")) : null;
+
+/** "1,000" in a frequency is a thousands separator: no one sets a filter at 1 Hz. */
+const toFrequency = (text: string): number | null =>
+ THOUSANDS.test(text) ? Number(text.replace(",", "")) : toNumber(text);
+
+function bandwidthToQ(octaves: number): number | null {
+ if (octaves <= 0) return null;
+ const ratio = Math.pow(2, octaves);
+ return Math.sqrt(ratio) / (ratio - 1);
+}
+
+function parseFilter(parts: string[]): EqBand | null {
+ const [state, code] = parts;
+ if (state?.toUpperCase() !== "ON" || code === undefined) return null;
+ const type = TYPES[code.toUpperCase()];
+ if (!type) return null;
+ let frequency: number | null = null;
+ let gain = 0;
+ let q: number | null = null;
+ let i = 2;
+ while (i < parts.length) {
+ const key = parts[i]?.toLowerCase();
+ const next = parts[i + 1];
+ const value = toNumber(next);
+ if (key === "fc" && next !== undefined && value !== null) {
+ frequency = toFrequency(next);
+ i += 2;
+ } else if (key === "gain" && value !== null) {
+ gain = value;
+ i += 2;
+ } else if (key === "q" && value !== null) {
+ q = value;
+ i += 2;
+ } else if (key === "bw" && parts[i + 1]?.toLowerCase() === "oct") {
+ const octaves = toNumber(parts[i + 2]);
+ q = (octaves !== null ? bandwidthToQ(octaves) : null) ?? q;
+ i += 3;
+ } else {
+ i++;
+ }
+ }
+ if (frequency === null) return null;
+ return sanitizeBand({ frequency, gain, q: q ?? defaultQ(type), type, enabled: true });
+}
+
+/** Null when the text holds no usable filter and no preamp. */
+export function parseAutoEq(text: string): AutoEqImport | null {
+ let preamp: number | null = null;
+ const bands: EqBand[] = [];
+ let skipped = 0;
+ let ignored = 0;
+ for (const raw of text.split(/\r?\n|\r/)) {
+ const line = (raw.split("#")[0] ?? "").trim();
+ if (!line) continue;
+ const afterColon = line.slice(line.indexOf(":") + 1);
+ if (PREAMP_PREFIX.test(line)) {
+ preamp = tokens(afterColon).map(toNumber).find((n) => n !== null) ?? preamp;
+ } else if (FILTER_PREFIX.test(line)) {
+ const parsed = parseFilter(tokens(afterColon));
+ if (parsed) bands.push(parsed);
+ else skipped++;
+ } else {
+ ignored++;
+ }
+ }
+ if (bands.length === 0 && preamp === null) return null;
+ return {
+ curve: sanitizeCurve({ preamp: preamp ?? 0, bands: bands.slice(0, EqLimits.MAX_BANDS) }),
+ skippedFilters: skipped,
+ ignoredLines: ignored,
+ truncatedFilters: Math.max(0, bands.length - EqLimits.MAX_BANDS),
+ };
+}
+
+export function formatAutoEq(curve: EqCurve): string {
+ let out = `Preamp: ${curve.preamp.toFixed(1)} dB\n`;
+ curve.bands.forEach((b, index) => {
+ out += `Filter ${index + 1}: ${b.enabled ? "ON " : "OFF "}${APO_CODES[b.type]}`;
+ out += ` Fc ${b.frequency.toFixed(0)} Hz`;
+ if (hasGain(b.type)) out += ` Gain ${b.gain.toFixed(1)} dB`;
+ out += ` Q ${b.q.toFixed(3)}\n`;
+ });
+ return out;
+}
diff --git a/src/lib/audio/eq/builtInPresets.ts b/src/lib/audio/eq/builtInPresets.ts
new file mode 100644
index 00000000..068db8e2
--- /dev/null
+++ b/src/lib/audio/eq/builtInPresets.ts
@@ -0,0 +1,134 @@
+import type { StringKey } from "../../i18n/index";
+import { band, EqLimits, type EqBand, type EqCurve } from "./models";
+
+export interface BuiltInEqPreset {
+ id: string;
+ nameKey: StringKey;
+ curve: EqCurve;
+}
+
+/**
+ * The presets Flow ships, with Android's stable ids (`BuiltInEqPresets.kt`), so a preset picked on
+ * one device is the same preset on the other. Shelves use the Butterworth Q.
+ */
+export const FLAT_PRESET_ID = "builtin:flat";
+
+const SHELF_Q = EqLimits.DEFAULT_SHELF_Q;
+
+const preset = (key: string, nameKey: StringKey, preamp: number, ...bands: EqBand[]): BuiltInEqPreset => ({
+ id: `builtin:${key}`,
+ nameKey,
+ curve: { preamp, bands },
+});
+
+export const BUILT_IN_EQ_PRESETS: readonly BuiltInEqPreset[] = [
+ { id: FLAT_PRESET_ID, nameKey: "eq_preset_flat", curve: { preamp: 0, bands: [] } },
+ preset(
+ "acoustic",
+ "eq_preset_acoustic",
+ 0,
+ band(100, 2, 0.7, "PEAK"),
+ band(400, -1, 1, "PEAK"),
+ band(2_000, 1.5, 1, "PEAK"),
+ band(6_000, 3, SHELF_Q, "HIGH_SHELF"),
+ ),
+ preset("bass_boost", "eq_preset_bass_boost", 0, band(60, 9, SHELF_Q, "LOW_SHELF"), band(200, 3, 0.7, "PEAK")),
+ preset(
+ "classical",
+ "eq_preset_classical",
+ 0,
+ band(100, 3, SHELF_Q, "LOW_SHELF"),
+ band(500, -2, 1, "PEAK"),
+ band(2_500, 2, 1, "PEAK"),
+ band(10_000, 3, SHELF_Q, "HIGH_SHELF"),
+ ),
+ preset(
+ "electronic",
+ "eq_preset_electronic",
+ -1,
+ band(50, 7, SHELF_Q, "LOW_SHELF"),
+ band(400, -1, 1, "PEAK"),
+ band(2_000, 2.5, 1, "PEAK"),
+ band(8_000, 5, SHELF_Q, "HIGH_SHELF"),
+ ),
+ preset(
+ "heavy_metal",
+ "eq_preset_heavy_metal",
+ -1,
+ band(60, 6, SHELF_Q, "LOW_SHELF"),
+ band(250, 2, 1, "PEAK"),
+ band(700, -3, 1, "PEAK"),
+ band(2_000, 1, 1, "PEAK"),
+ band(4_000, 4, 1, "PEAK"),
+ band(10_000, 5, SHELF_Q, "HIGH_SHELF"),
+ ),
+ preset(
+ "hip_hop",
+ "eq_preset_hip_hop",
+ -1,
+ band(55, 8, SHELF_Q, "LOW_SHELF"),
+ band(250, 2, 1, "PEAK"),
+ band(1_000, -2, 1, "PEAK"),
+ band(5_000, 3, 1, "PEAK"),
+ ),
+ preset(
+ "jazz",
+ "eq_preset_jazz",
+ 0,
+ band(100, 2, SHELF_Q, "LOW_SHELF"),
+ band(500, -1, 1, "PEAK"),
+ band(2_500, 1.5, 1, "PEAK"),
+ band(10_000, 2, SHELF_Q, "HIGH_SHELF"),
+ ),
+ preset(
+ "metal",
+ "eq_preset_metal",
+ 0,
+ band(60, 4, SHELF_Q, "LOW_SHELF"),
+ band(300, 3, 1.41, "PEAK"),
+ band(1_500, 2, 1, "PEAK"),
+ band(4_000, 4, 1, "PEAK"),
+ ),
+ preset(
+ "pop",
+ "eq_preset_pop",
+ 0,
+ band(100, 4, 0.7, "PEAK"),
+ band(2_500, 2.5, 1, "PEAK"),
+ band(12_000, 3.5, SHELF_Q, "HIGH_SHELF"),
+ ),
+ preset(
+ "rnb",
+ "eq_preset_rnb",
+ 0,
+ band(70, 6, SHELF_Q, "LOW_SHELF"),
+ band(300, -2, 1, "PEAK"),
+ band(1_200, -1.5, 1, "PEAK"),
+ band(4_000, 2, 1, "PEAK"),
+ ),
+ preset(
+ "rock",
+ "eq_preset_rock",
+ 0,
+ band(60, 5, SHELF_Q, "LOW_SHELF"),
+ band(230, 3, 1, "PEAK"),
+ band(910, -2, 1, "PEAK"),
+ band(3_600, 3.5, 1, "PEAK"),
+ band(14_000, 5, SHELF_Q, "HIGH_SHELF"),
+ ),
+ preset(
+ "vocal",
+ "eq_preset_vocal",
+ 0,
+ band(100, -2, SHELF_Q, "LOW_SHELF"),
+ band(1_000, 3, 2, "PEAK"),
+ band(5_000, 2, 1, "PEAK"),
+ ),
+];
+
+const BY_ID = new Map(BUILT_IN_EQ_PRESETS.map((p) => [p.id, p]));
+
+export const builtInPreset = (id: string | null | undefined): BuiltInEqPreset | undefined =>
+ id ? BY_ID.get(id) : undefined;
+
+export const isBuiltInPreset = (id: string | null | undefined): boolean => !!id && BY_ID.has(id);
diff --git a/src/lib/audio/eq/eqFiles.ts b/src/lib/audio/eq/eqFiles.ts
new file mode 100644
index 00000000..5cfdacd3
--- /dev/null
+++ b/src/lib/audio/eq/eqFiles.ts
@@ -0,0 +1,29 @@
+import { writeBackupFile } from "../../api/files";
+import { isTauriEnv } from "../../api/env";
+import { pickSaveFile } from "../../dialogs";
+import { formatAutoEq } from "./autoEqFormat";
+import { autoPreampDb } from "./filterMath";
+import type { EqState } from "./models";
+import { activeCopy } from "./stateOps";
+
+/**
+ * The active curve as EqualizerAPO text. With auto preamp on, the file carries the preamp Flow
+ * would apply, so it plays without clipping in EqualizerAPO too.
+ */
+export function eqExportText(state: EqState): string {
+ const curve = activeCopy(state).curve;
+ const bands = curve.bands.filter((b) => b.enabled);
+ return formatAutoEq({ ...curve, preamp: state.autoPreamp ? autoPreampDb(bands) : curve.preamp });
+}
+
+/** Saves `text` where the user picks; false when they cancel. Throws when the write fails. */
+export async function saveEqTextFile(text: string, title: string, defaultName: string): Promise {
+ if (!(await isTauriEnv())) return false;
+ const path = await pickSaveFile(title, defaultName, [{ name: "EqualizerAPO", extensions: ["txt"] }]);
+ if (!path) return false;
+ await writeBackupFile(path, text);
+ return true;
+}
+
+/** A file's base name, for a preset imported from it. */
+export const presetNameFromFile = (fileName: string): string => fileName.replace(/\.[^.]+$/, "").trim();
diff --git a/src/lib/audio/eq/graphPlan.test.ts b/src/lib/audio/eq/graphPlan.test.ts
new file mode 100644
index 00000000..e0f64c08
--- /dev/null
+++ b/src/lib/audio/eq/graphPlan.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+
+import { band, EqLimits } from "./models";
+import { filterSetting, graphChangesSound, graphSpec, hasNonButterworthShelf, EQ_GRAPH_OFF } from "./graphPlan";
+import { defaultEqState, processingSpec, selectPreset, withBassBoost } from "./stateOps";
+
+const RATE = 48_000;
+
+describe("graphPlan", () => {
+ it("gives bass boost its own shelf and keeps Android's preamp", () => {
+ const state = withBassBoost(selectPreset(defaultEqState(), "builtin:rock"), 6);
+ const spec = graphSpec(state, false);
+ expect(spec.bassBoostDb).toBe(6);
+ expect(spec.bands).toHaveLength(5);
+ expect(spec.preampDb).toBe(processingSpec(state, false).preampDb);
+ });
+
+ it("is off when the equalizer is off or held for compare", () => {
+ const state = selectPreset(defaultEqState(), "builtin:rock");
+ expect(graphSpec({ ...state, enabled: false }, false)).toEqual(EQ_GRAPH_OFF);
+ expect(graphSpec(state, true)).toEqual(EQ_GRAPH_OFF);
+ expect(graphChangesSound(graphSpec(state, false), RATE)).toBe(true);
+ expect(graphChangesSound(graphSpec(defaultEqState(), false), RATE)).toBe(false);
+ });
+
+ it("hands WebAudio its own Q forms", () => {
+ expect(filterSetting(band(1_000, 3, 2), RATE)).toEqual({ type: "peaking", frequency: 1_000, q: 2, gain: 3 });
+ expect(filterSetting(band(80, 4, 0.707, "LOW_SHELF"), RATE)?.type).toBe("lowshelf");
+ const lowPass = filterSetting(band(8_000, 0, 0.707, "LOW_PASS"), RATE)!;
+ expect(lowPass.type).toBe("lowpass");
+ // Butterworth's 0.707 is -3 dB in WebAudio's resonance form.
+ expect(lowPass.q).toBeCloseTo(-3.01, 2);
+ expect(filterSetting(band(30, 0, 1, "HIGH_PASS"), RATE)!.q).toBeCloseTo(0, 6);
+ });
+
+ it("leaves flat, disabled and out-of-range bands flat", () => {
+ expect(filterSetting(undefined, RATE)).toBeNull();
+ expect(filterSetting(band(1_000, 0), RATE)).toBeNull();
+ expect(filterSetting(band(1_000, 3, 1, "PEAK", false), RATE)).toBeNull();
+ expect(filterSetting(band(EqLimits.MAX_FREQUENCY, 3), 22_050)).toBeNull();
+ });
+
+ it("notices shelves whose Q WebAudio cannot play", () => {
+ expect(hasNonButterworthShelf({ preamp: 0, bands: [band(100, 3, 0.7, "LOW_SHELF")] })).toBe(false);
+ expect(hasNonButterworthShelf({ preamp: 0, bands: [band(100, 3, 2, "LOW_SHELF")] })).toBe(true);
+ expect(hasNonButterworthShelf({ preamp: 0, bands: [band(100, 3, 2)] })).toBe(false);
+ });
+});
diff --git a/src/lib/audio/eq/graphPlan.ts b/src/lib/audio/eq/graphPlan.ts
new file mode 100644
index 00000000..c91eab40
--- /dev/null
+++ b/src/lib/audio/eq/graphPlan.ts
@@ -0,0 +1,74 @@
+import { isBypassed } from "./filterMath";
+import { EqLimits, type EqBand, type EqCurve, type EqState } from "./models";
+import { processingSpec } from "./stateOps";
+
+/** Filters in the WebAudio chain. Unused ones sit flat, so adding a band never rewires the graph. */
+export const EQ_FILTER_POOL = EqLimits.MAX_BANDS;
+
+/** What one player's equalizer chain runs: the curve, bass boost on its own shelf, and the preamp. */
+export interface EqGraphSpec {
+ enabled: boolean;
+ preampDb: number;
+ bands: EqBand[];
+ bassBoostDb: number;
+}
+
+export const EQ_GRAPH_OFF: EqGraphSpec = { enabled: false, preampDb: 0, bands: [], bassBoostDb: 0 };
+
+/**
+ * The chain's settings for `state`. The preamp is Android's `processingSpec`, so auto preamp
+ * counts bass boost; bass boost is split off again because it has its own filter.
+ */
+export function graphSpec(state: EqState, bypass: boolean, preview?: EqCurve | null): EqGraphSpec {
+ const spec = processingSpec(state, bypass, preview);
+ if (!spec.enabled) return EQ_GRAPH_OFF;
+ const boosted = state.bassBoost > 0;
+ return {
+ enabled: true,
+ preampDb: spec.preampDb,
+ bands: boosted ? spec.bands.slice(0, -1) : spec.bands,
+ bassBoostDb: boosted ? state.bassBoost : 0,
+ };
+}
+
+/** Whether running `spec` sounds different from not running it. */
+export function graphChangesSound(spec: EqGraphSpec, sampleRate: number): boolean {
+ return (
+ spec.enabled &&
+ (spec.preampDb !== 0 || spec.bassBoostDb > 0 || spec.bands.some((b) => !isBypassed(b, sampleRate)))
+ );
+}
+
+export interface FilterSetting {
+ type: BiquadFilterType;
+ frequency: number;
+ q: number;
+ gain: number;
+}
+
+/**
+ * One BiquadFilterNode's settings for `band`, or null for a pool slot that should sit flat.
+ * WebAudio's shelves have no Q (they are always Butterworth), and its low and high pass take Q in
+ * dB, which is 20·log10 of the RBJ Q every other part of the model uses.
+ */
+export function filterSetting(band: EqBand | undefined, sampleRate: number): FilterSetting | null {
+ if (!band || isBypassed(band, sampleRate)) return null;
+ switch (band.type) {
+ case "PEAK":
+ return { type: "peaking", frequency: band.frequency, q: band.q, gain: band.gain };
+ case "LOW_SHELF":
+ return { type: "lowshelf", frequency: band.frequency, q: 1, gain: band.gain };
+ case "HIGH_SHELF":
+ return { type: "highshelf", frequency: band.frequency, q: 1, gain: band.gain };
+ case "LOW_PASS":
+ return { type: "lowpass", frequency: band.frequency, q: 20 * Math.log10(band.q), gain: 0 };
+ case "HIGH_PASS":
+ return { type: "highpass", frequency: band.frequency, q: 20 * Math.log10(band.q), gain: 0 };
+ }
+}
+
+/** Whether a shelf in `curve` plays differently in WebAudio than its stored Q says. */
+export const hasNonButterworthShelf = (curve: EqCurve): boolean =>
+ curve.bands.some(
+ (b) => (b.type === "LOW_SHELF" || b.type === "HIGH_SHELF") && Math.abs(b.q - EqLimits.DEFAULT_SHELF_Q) > 0.01,
+ );
diff --git a/src/lib/audio/eq/legacyMigration.ts b/src/lib/audio/eq/legacyMigration.ts
new file mode 100644
index 00000000..16e8cfae
--- /dev/null
+++ b/src/lib/audio/eq/legacyMigration.ts
@@ -0,0 +1,47 @@
+import { GraphicEq, roundTo } from "./limits";
+import type { EqState } from "./models";
+import { defaultEqState, sanitizeState } from "./stateOps";
+
+/** Where the first desktop equalizer kept its switch and ten gains, beside the music volume. */
+export const LEGACY_MUSIC_CONFIG_KEY = "flow_music_player";
+
+export interface LegacyEqConfig {
+ eqEnabled?: unknown;
+ eqGains?: unknown;
+}
+
+/**
+ * The first desktop equalizer as an `EqState`, or null when it was never used. Its ten bands sat
+ * on the same octaves as Android's graphic mode (32 and 64 Hz are now 31 and 63), so the gains
+ * carry over as a graphic curve; its end bands were shelves and are now peaks, a small change at
+ * both ends of the range.
+ */
+export function migrateLegacyEq(config: LegacyEqConfig | null): EqState | null {
+ if (!config) return null;
+ const raw = Array.isArray(config.eqGains) ? config.eqGains : [];
+ const gains = GraphicEq.FREQUENCIES.map((_, i) => {
+ const value = Number(raw[i]);
+ const gain = Number.isFinite(value) ? Math.max(-GraphicEq.MAX_GAIN, Math.min(GraphicEq.MAX_GAIN, value)) : 0;
+ return roundTo(gain, GraphicEq.STEP);
+ });
+ const enabled = config.eqEnabled === true;
+ if (!enabled && gains.every((g) => g === 0)) return null;
+ return sanitizeState({
+ ...defaultEqState(),
+ enabled,
+ mode: "GRAPHIC",
+ graphic: { presetId: null, curve: GraphicEq.curveOf(gains) },
+ });
+}
+
+/** The legacy fields as the old store saved them, or null when there are none. */
+export function readLegacyEqConfig(): LegacyEqConfig | null {
+ try {
+ const saved = localStorage.getItem(LEGACY_MUSIC_CONFIG_KEY);
+ if (!saved) return null;
+ const parsed: unknown = JSON.parse(saved);
+ return typeof parsed === "object" && parsed !== null ? (parsed as LegacyEqConfig) : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/audio/eq/limits.ts b/src/lib/audio/eq/limits.ts
new file mode 100644
index 00000000..e1150b77
--- /dev/null
+++ b/src/lib/audio/eq/limits.ts
@@ -0,0 +1,124 @@
+import { frequencyAt, magnitudeDb } from "./filterMath";
+import { band, EqLimits, hasGain, type EqBand, type EqCurve } from "./models";
+
+const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
+
+/** Rounds to a multiple of `step` with ties to even, like Kotlin's `round`, and never leaves a negative zero. */
+export function roundTo(value: number, step: number): number {
+ const x = value / step;
+ const floor = Math.floor(x);
+ const diff = x - floor;
+ const whole = diff > 0.5 || (diff === 0.5 && floor % 2 !== 0) ? floor + 1 : floor;
+ return whole * step + 0;
+}
+
+/** Null when the band cannot be made valid (not a number, or a zero or negative frequency or Q). */
+export function sanitizeBand(b: EqBand): EqBand | null {
+ if (!Number.isFinite(b.frequency) || !Number.isFinite(b.gain) || !Number.isFinite(b.q)) return null;
+ if (b.frequency <= 0 || b.q <= 0) return null;
+ return {
+ ...b,
+ frequency: clamp(b.frequency, EqLimits.MIN_FREQUENCY, EqLimits.MAX_FREQUENCY),
+ gain: hasGain(b.type) ? clamp(b.gain, EqLimits.MIN_GAIN, EqLimits.MAX_GAIN) : 0,
+ q: clamp(b.q, EqLimits.MIN_Q, EqLimits.MAX_Q),
+ };
+}
+
+export function sanitizeCurve(curve: EqCurve): EqCurve {
+ return {
+ preamp: Number.isFinite(curve.preamp) ? clamp(curve.preamp, EqLimits.MIN_PREAMP, EqLimits.MAX_PREAMP) : 0,
+ bands: curve.bands
+ .map(sanitizeBand)
+ .filter((b): b is EqBand => b !== null)
+ .slice(0, EqLimits.MAX_BANDS),
+ };
+}
+
+const FIT_POINTS = 96;
+const FIT_PASSES = 4;
+const FIT_SMOOTHING = 6;
+
+/** Ten octave bands, 31 Hz to 16 kHz, all peaking, as on Flow for Android. */
+export const GraphicEq = {
+ FREQUENCIES: [31, 63, 125, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000] as const,
+ Q: 1.41,
+ MAX_GAIN: 12,
+ STEP: 0.5,
+
+ flatCurve(): EqCurve {
+ return GraphicEq.curveOf(GraphicEq.FREQUENCIES.map(() => 0));
+ },
+
+ curveOf(gains: readonly number[], preamp = 0): EqCurve {
+ return {
+ preamp,
+ bands: GraphicEq.FREQUENCIES.map((frequency, i) => band(frequency, gains[i] ?? 0, GraphicEq.Q)),
+ };
+ },
+
+ /**
+ * The ten gains whose response comes closest to `curve` across the whole range: a least-squares
+ * fit on a log grid, with a penalty on the step between neighbouring bands so the sliders do not
+ * zigzag, then refined against the real, overlapping filters.
+ */
+ fit(curve: EqCurve): EqCurve {
+ const grid = Array.from({ length: FIT_POINTS }, (_, k) => frequencyAt(k / (FIT_POINTS - 1)));
+ const target = grid.map((f) => magnitudeDb(curve.bands, f));
+ const basis = GraphicEq.FREQUENCIES.map((centre) => {
+ const unit = [band(centre, 1, GraphicEq.Q)];
+ return grid.map((f) => magnitudeDb(unit, f));
+ });
+ let gains: number[] = GraphicEq.FREQUENCIES.map(() => 0);
+ for (let pass = 0; pass < FIT_PASSES; pass++) {
+ const bands = GraphicEq.curveOf(gains).bands;
+ const residual = grid.map((f, k) => (target[k] ?? 0) - magnitudeDb(bands, f));
+ const step = solveDamped(basis, residual);
+ gains = gains.map((gain, i) => clamp(gain + (step[i] ?? 0), -GraphicEq.MAX_GAIN, GraphicEq.MAX_GAIN));
+ }
+ return GraphicEq.curveOf(
+ gains.map((g) => roundTo(g, GraphicEq.STEP)),
+ curve.preamp,
+ );
+ },
+
+ /** Reads a stored graphic curve back as ten gains; anything malformed becomes flat. */
+ gainsOf(curve: EqCurve): number[] {
+ if (curve.bands.length !== GraphicEq.FREQUENCIES.length) return GraphicEq.FREQUENCIES.map(() => 0);
+ return curve.bands.map((b) => clamp(b.gain, -GraphicEq.MAX_GAIN, GraphicEq.MAX_GAIN));
+ },
+};
+
+const dot = (x: readonly number[], y: readonly number[]) => x.reduce((sum, v, k) => sum + v * (y[k] ?? 0), 0);
+
+/** Solves (B·Bᵀ + λ·DᵀD)·x = B·r, D being the difference between neighbouring bands, by Gaussian elimination. */
+function solveDamped(basis: readonly number[][], residual: readonly number[]): number[] {
+ const n = basis.length;
+ const width = n + 1;
+ // Row-major augmented matrix; rows are swapped by index rather than moved.
+ const a = new Float64Array(n * width);
+ const rows = Array.from({ length: n }, (_, i) => i);
+ const get = (r: number, c: number) => a[(rows[r] ?? 0) * width + c] ?? 0;
+ const add = (r: number, c: number, v: number) => {
+ a[(rows[r] ?? 0) * width + c] = get(r, c) + v;
+ };
+ basis.forEach((bi, i) => {
+ basis.forEach((bj, j) => add(i, j, dot(bi, bj)));
+ add(i, i, FIT_SMOOTHING * (i === 0 || i === n - 1 ? 1 : 2));
+ if (i > 0) add(i, i - 1, -FIT_SMOOTHING);
+ if (i < n - 1) add(i, i + 1, -FIT_SMOOTHING);
+ add(i, n, dot(bi, residual));
+ });
+ for (let col = 0; col < n; col++) {
+ let pivot = col;
+ for (let row = col + 1; row < n; row++) {
+ if (Math.abs(get(row, col)) > Math.abs(get(pivot, col))) pivot = row;
+ }
+ [rows[col], rows[pivot]] = [rows[pivot] ?? 0, rows[col] ?? 0];
+ for (let row = 0; row < n; row++) {
+ if (row === col) continue;
+ const factor = get(row, col) / get(col, col);
+ for (let c = col; c <= n; c++) add(row, c, -factor * get(col, c));
+ }
+ }
+ return rows.map((_, i) => get(i, n) / get(i, i));
+}
diff --git a/src/lib/audio/eq/models.ts b/src/lib/audio/eq/models.ts
index f4caf589..9fcb070c 100644
--- a/src/lib/audio/eq/models.ts
+++ b/src/lib/audio/eq/models.ts
@@ -47,6 +47,49 @@ export const EqLimits = {
export const defaultQ = (type: EqFilterType): number =>
type === "PEAK" ? EqLimits.DEFAULT_PEAK_Q : EqLimits.DEFAULT_SHELF_Q;
+export interface EqCurve {
+ preamp: number;
+ bands: EqBand[];
+}
+
+export type EqMode = "PARAMETRIC" | "GRAPHIC";
+
+export interface EqPreset {
+ id: string;
+ name: string;
+ curve: EqCurve;
+ mode: EqMode;
+ imported: boolean;
+}
+
+/** The curve a mode is playing, and the preset it started from; null once that preset is gone. */
+export interface EqWorkingCopy {
+ presetId: string | null;
+ curve: EqCurve;
+}
+
+export interface EqState {
+ version: number;
+ enabled: boolean;
+ mode: EqMode;
+ parametric: EqWorkingCopy;
+ graphic: EqWorkingCopy;
+ autoPreamp: boolean;
+ bassBoost: number;
+ userPresets: EqPreset[];
+}
+
+export const EQ_STATE_VERSION = 1;
+
+/** What the audio graph runs: already resolved for bypass, bass boost and auto preamp. */
+export interface EqProcessingSpec {
+ enabled: boolean;
+ preampDb: number;
+ bands: EqBand[];
+}
+
+export const EQ_PROCESSING_OFF: EqProcessingSpec = { enabled: false, preampDb: 0, bands: [] };
+
/** A band with Android's defaults for whatever is left out. */
export function band(
frequency: number,
diff --git a/src/lib/audio/eq/stateJson.test.ts b/src/lib/audio/eq/stateJson.test.ts
new file mode 100644
index 00000000..68e4edeb
--- /dev/null
+++ b/src/lib/audio/eq/stateJson.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+
+import { GraphicEq } from "./limits";
+import { migrateLegacyEq } from "./legacyMigration";
+import { decodeEqState, encodeEqState } from "./stateJson";
+import { defaultEqState, statesEqual } from "./stateOps";
+
+// What Android's EqStateJson.encode writes (kotlinx, encodeDefaults), trimmed to one band.
+const ANDROID_JSON = `{"version":1,"enabled":true,"mode":"PARAMETRIC",
+"parametric":{"presetId":"builtin:rock","curve":{"preamp":0.0,"bands":[{"frequency":60.0,"gain":5.0,"q":0.707,"type":"LOW_SHELF","enabled":true}]}},
+"graphic":{"presetId":null,"curve":{"preamp":0.0,"bands":[]}},
+"autoPreamp":true,"bassBoost":2.5,
+"userPresets":[{"id":"user:1","name":"Car","curve":{"preamp":-1.0,"bands":[]},"mode":"GRAPHIC","imported":false}]}`;
+
+describe("EqStateJson", () => {
+ it("reads Android's JSON, repairing what the schema allows", () => {
+ const state = decodeEqState(ANDROID_JSON)!;
+ expect(state.parametric.presetId).toBe("builtin:rock");
+ expect(state.parametric.curve.bands[0]).toEqual({
+ frequency: 60,
+ gain: 5,
+ q: 0.707,
+ type: "LOW_SHELF",
+ enabled: true,
+ });
+ expect(state.bassBoost).toBe(2.5);
+ expect(state.userPresets[0]!.mode).toBe("GRAPHIC");
+ // A graphic curve that is not ten bands becomes flat, as Android's sanitizer does.
+ expect(state.graphic.curve).toEqual(GraphicEq.flatCurve());
+ });
+
+ it("writes every field Android reads, under the same names", () => {
+ const parsed = JSON.parse(encodeEqState(defaultEqState()));
+ expect(Object.keys(parsed)).toEqual([
+ "version",
+ "enabled",
+ "mode",
+ "parametric",
+ "graphic",
+ "autoPreamp",
+ "bassBoost",
+ "userPresets",
+ ]);
+ expect(parsed.parametric).toEqual({ presetId: "builtin:flat", curve: { preamp: 0, bands: [] } });
+ expect(parsed.graphic.presetId).toBeNull();
+ expect(parsed.graphic.curve.bands[0]).toEqual({ frequency: 31, gain: 0, q: 1.41, type: "PEAK", enabled: true });
+ });
+
+ it("an empty object is the default state, and a wrong type rejects the whole value", () => {
+ expect(statesEqual(decodeEqState("{}")!, defaultEqState())).toBe(true);
+ expect(decodeEqState(`{"enabled":"yes"}`)).toBeNull();
+ expect(decodeEqState(`{"mode":"SPECTRAL"}`)).toBeNull();
+ expect(decodeEqState("[]")).toBeNull();
+ });
+});
+
+describe("migrateLegacyEq", () => {
+ it("carries the old ten gains over as a graphic curve", () => {
+ const state = migrateLegacyEq({ eqEnabled: true, eqGains: [6, 5, 4, 2, 0, 0, 0, 0, 1, 2.3] })!;
+ expect(state.enabled).toBe(true);
+ expect(state.mode).toBe("GRAPHIC");
+ expect(state.graphic.presetId).toBeNull();
+ expect(GraphicEq.gainsOf(state.graphic.curve)).toEqual([6, 5, 4, 2, 0, 0, 0, 0, 1, 2.5]);
+ expect(state.graphic.curve.bands.every((b) => b.type === "PEAK")).toBe(true);
+ });
+
+ it("migrates nothing when the old equalizer was never used", () => {
+ expect(migrateLegacyEq(null)).toBeNull();
+ expect(migrateLegacyEq({ eqEnabled: false, eqGains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] })).toBeNull();
+ expect(migrateLegacyEq({ volume: 0.4 } as never)).toBeNull();
+ });
+
+ it("keeps a curve that was switched off, off", () => {
+ const state = migrateLegacyEq({ eqEnabled: false, eqGains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 3] })!;
+ expect(state.enabled).toBe(false);
+ expect(GraphicEq.gainsOf(state.graphic.curve)[9]).toBe(3);
+ });
+});
diff --git a/src/lib/audio/eq/stateJson.ts b/src/lib/audio/eq/stateJson.ts
new file mode 100644
index 00000000..2d4116c1
--- /dev/null
+++ b/src/lib/audio/eq/stateJson.ts
@@ -0,0 +1,145 @@
+import {
+ EQ_FILTER_TYPES,
+ EqLimits,
+ type EqBand,
+ type EqCurve,
+ type EqFilterType,
+ type EqMode,
+ type EqPreset,
+ type EqState,
+ type EqWorkingCopy,
+} from "./models";
+import { defaultEqState, sanitizeState } from "./stateOps";
+
+/*
+ * The equalizer as one JSON value: the form it is saved in, backed up and synced as. It is the
+ * schema Android's `EqStateJson` writes with kotlinx.serialization, so either app reads the
+ * other's. Like Android, a value that does not fit the schema is rejected whole rather than
+ * half-applied, and missing fields take their defaults.
+ */
+class Invalid extends Error {}
+
+type Json = Record;
+
+const isObject = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v);
+
+function field(obj: Json, key: string, read: (v: unknown) => T, fallback: () => T): T {
+ if (!(key in obj)) return fallback();
+ return read(obj[key]);
+}
+
+const num = (v: unknown): number => {
+ if (typeof v !== "number") throw new Invalid();
+ return v;
+};
+const bool = (v: unknown): boolean => {
+ if (typeof v !== "boolean") throw new Invalid();
+ return v;
+};
+const str = (v: unknown): string => {
+ if (typeof v !== "string") throw new Invalid();
+ return v;
+};
+const filterType = (v: unknown): EqFilterType => {
+ if (!EQ_FILTER_TYPES.includes(v as EqFilterType)) throw new Invalid();
+ return v as EqFilterType;
+};
+const mode = (v: unknown): EqMode => {
+ if (v !== "PARAMETRIC" && v !== "GRAPHIC") throw new Invalid();
+ return v;
+};
+const list = (read: (v: unknown) => T) => (v: unknown): T[] => {
+ if (!Array.isArray(v)) throw new Invalid();
+ return v.map(read);
+};
+const object = (v: unknown): Json => {
+ if (!isObject(v)) throw new Invalid();
+ return v;
+};
+
+function readBand(v: unknown): EqBand {
+ const o = object(v);
+ if (!("frequency" in o)) throw new Invalid();
+ return {
+ frequency: num(o.frequency),
+ gain: field(o, "gain", num, () => 0),
+ q: field(o, "q", num, () => EqLimits.DEFAULT_PEAK_Q),
+ type: field(o, "type", filterType, () => "PEAK" as const),
+ enabled: field(o, "enabled", bool, () => true),
+ };
+}
+
+function readCurve(v: unknown): EqCurve {
+ const o = object(v);
+ return {
+ preamp: field(o, "preamp", num, () => 0),
+ bands: field(o, "bands", list(readBand), () => []),
+ };
+}
+
+function readCopy(v: unknown): EqWorkingCopy {
+ const o = object(v);
+ if (!("curve" in o)) throw new Invalid();
+ const presetId = o.presetId;
+ if (presetId !== undefined && presetId !== null && typeof presetId !== "string") throw new Invalid();
+ return { presetId: presetId ?? null, curve: readCurve(o.curve) };
+}
+
+function readPreset(v: unknown): EqPreset {
+ const o = object(v);
+ if (!("id" in o) || !("name" in o) || !("curve" in o)) throw new Invalid();
+ return {
+ id: str(o.id),
+ name: str(o.name),
+ curve: readCurve(o.curve),
+ mode: field(o, "mode", mode, () => "PARAMETRIC" as const),
+ imported: field(o, "imported", bool, () => false),
+ };
+}
+
+/** Null when `raw` is not an equalizer state either app wrote. */
+export function decodeEqState(raw: string): EqState | null {
+ try {
+ const o = object(JSON.parse(raw));
+ const defaults = defaultEqState();
+ return sanitizeState({
+ version: field(o, "version", num, () => defaults.version),
+ enabled: field(o, "enabled", bool, () => defaults.enabled),
+ mode: field(o, "mode", mode, () => defaults.mode),
+ parametric: field(o, "parametric", readCopy, () => defaults.parametric),
+ graphic: field(o, "graphic", readCopy, () => defaults.graphic),
+ autoPreamp: field(o, "autoPreamp", bool, () => defaults.autoPreamp),
+ bassBoost: field(o, "bassBoost", num, () => defaults.bassBoost),
+ userPresets: field(o, "userPresets", list(readPreset), () => defaults.userPresets),
+ });
+ } catch {
+ return null;
+ }
+}
+
+/** Every field written, in Android's order, as kotlinx's `encodeDefaults` does. */
+export function encodeEqState(state: EqState): string {
+ const curve = (c: EqCurve) => ({
+ preamp: c.preamp,
+ bands: c.bands.map((b) => ({ frequency: b.frequency, gain: b.gain, q: b.q, type: b.type, enabled: b.enabled })),
+ });
+ const copy = (c: EqWorkingCopy) => ({ presetId: c.presetId, curve: curve(c.curve) });
+ return JSON.stringify({
+ version: state.version,
+ enabled: state.enabled,
+ mode: state.mode,
+ parametric: copy(state.parametric),
+ graphic: copy(state.graphic),
+ autoPreamp: state.autoPreamp,
+ bassBoost: state.bassBoost,
+ userPresets: state.userPresets.map((p) => ({
+ id: p.id,
+ name: p.name,
+ curve: curve(p.curve),
+ mode: p.mode,
+ imported: p.imported,
+ })),
+ });
+}
+
+export const newPresetId = (): string => `user:${crypto.randomUUID()}`;
diff --git a/src/lib/audio/eq/stateOps.test.ts b/src/lib/audio/eq/stateOps.test.ts
new file mode 100644
index 00000000..cceb474d
--- /dev/null
+++ b/src/lib/audio/eq/stateOps.test.ts
@@ -0,0 +1,252 @@
+import { describe, expect, it } from "vitest";
+
+import { builtInPreset, BUILT_IN_EQ_PRESETS } from "./builtInPresets";
+import { autoPreampDb, frequencyAt, magnitudeDb } from "./filterMath";
+import { GraphicEq } from "./limits";
+import { band, EQ_PROCESSING_OFF, EqLimits, type EqCurve, type EqPreset, type EqState } from "./models";
+import { decodeEqState, encodeEqState } from "./stateJson";
+import {
+ activeCopy,
+ addBand,
+ canSaveActive,
+ changesSound,
+ defaultEqState,
+ deletePreset,
+ importPreset,
+ isEdited,
+ isNameAvailable,
+ presetCurveFor,
+ processingSpec,
+ restorePreset,
+ revert,
+ sanitizeState,
+ saveActive,
+ saveActiveAs,
+ selectPreset,
+ statesEqual,
+ userPreset,
+ withActiveCurve,
+ withBand,
+ withBassBoost,
+ withManualPreamp,
+} from "./stateOps";
+
+// Ported from Android's EqStateOpsTest.
+const rock = "builtin:rock";
+const mine: EqPreset = {
+ id: "user:a",
+ name: "Mine",
+ curve: { preamp: 0, bands: [band(500, 3)] },
+ mode: "PARAMETRIC",
+ imported: false,
+};
+const state = (patch: Partial = {}): EqState => ({ ...defaultEqState(), ...patch });
+
+describe("EqStateOps", () => {
+ it("has Android's 13 built-in presets", () => {
+ expect(BUILT_IN_EQ_PRESETS.map((p) => p.id)).toEqual([
+ "builtin:flat",
+ "builtin:acoustic",
+ "builtin:bass_boost",
+ "builtin:classical",
+ "builtin:electronic",
+ "builtin:heavy_metal",
+ "builtin:hip_hop",
+ "builtin:jazz",
+ "builtin:metal",
+ "builtin:pop",
+ "builtin:rnb",
+ "builtin:rock",
+ "builtin:vocal",
+ ]);
+ });
+
+ it("selecting a preset loads its curve and is not an edit", () => {
+ const s = selectPreset(state(), rock);
+ expect(activeCopy(s).presetId).toBe(rock);
+ expect(activeCopy(s).curve).toEqual(builtInPreset(rock)!.curve);
+ expect(isEdited(s)).toBe(false);
+ });
+
+ it("an edited curve stays reachable and reverts to its preset", () => {
+ const edited = withBand(selectPreset(state(), rock), 0, band(60, 1, 0.707, "LOW_SHELF"));
+ expect(isEdited(edited)).toBe(true);
+ expect(activeCopy(edited).presetId).toBe(rock);
+ expect(isEdited(revert(edited))).toBe(false);
+ });
+
+ it("a built-in cannot be overwritten, a user preset can", () => {
+ const builtIn = addBand(selectPreset(state(), rock), band(8_000, 2));
+ expect(canSaveActive(builtIn)).toBe(false);
+ expect(saveActive(builtIn)).toBe(builtIn);
+
+ const own = addBand(selectPreset(state({ userPresets: [mine] }), mine.id), band(8_000, 2));
+ const saved = saveActive(own);
+ expect(userPreset(saved, mine.id)!.curve.bands).toHaveLength(2);
+ expect(isEdited(saved)).toBe(false);
+ });
+
+ it("save as creates a preset and selects it", () => {
+ const s = saveActiveAs(addBand(selectPreset(state(), rock), band(8_000, 2)), "user:b", "Car");
+ expect(activeCopy(s).presetId).toBe("user:b");
+ expect(userPreset(s, "user:b")!.name).toBe("Car");
+ expect(isEdited(s)).toBe(false);
+ });
+
+ it("deleting a selected preset keeps the sound and undo restores it in place", () => {
+ const other = { ...mine, id: "user:z", name: "Other" };
+ const s = selectPreset(state({ userPresets: [other, mine] }), mine.id);
+ const [deleted, removed] = deletePreset(s, mine.id);
+
+ expect(activeCopy(deleted).presetId).toBeNull();
+ expect(activeCopy(deleted).curve).toEqual(mine.curve);
+
+ const restored = restorePreset(deleted, removed!);
+ expect(restored.userPresets.map((p) => p.id)).toEqual(["user:z", mine.id]);
+ expect(activeCopy(restored).presetId).toBe(mine.id);
+ });
+
+ it("each mode keeps its own curve", () => {
+ const parametric = selectPreset(state(), rock);
+ const graphic = withActiveCurve({ ...parametric, mode: "GRAPHIC" }, GraphicEq.curveOf(Array(10).fill(3)));
+ const back = { ...graphic, mode: "PARAMETRIC" as const };
+ expect(activeCopy(back).curve).toEqual(activeCopy(parametric).curve);
+ expect(back.graphic.curve.bands.map((b) => b.gain)).toEqual(Array(10).fill(3));
+ });
+
+ it("importing adds a preset and plays it", () => {
+ const curve: EqCurve = { preamp: -2, bands: [band(910, 9, 0.71)] };
+ const s = importPreset(state(), "user:i", "Hefty Metal", curve);
+ expect(activeCopy(s).presetId).toBe("user:i");
+ expect(userPreset(s, "user:i")!.imported).toBe(true);
+ expect(s.mode).toBe("PARAMETRIC");
+ });
+
+ it("the processing spec adds bass boost and protects the peak", () => {
+ const spec = processingSpec(withBassBoost(selectPreset(state(), rock), 6), false);
+ expect(spec.enabled).toBe(true);
+ const boost = spec.bands[spec.bands.length - 1]!;
+ expect(boost.type).toBe("LOW_SHELF");
+ expect(boost.gain).toBe(6);
+ expect(spec.preampDb).toBeCloseTo(autoPreampDb(spec.bands), 2);
+ expect(spec.preampDb).toBeLessThan(0);
+ });
+
+ it("manual preamp is used when auto preamp is off", () => {
+ const s = withManualPreamp(selectPreset(state({ autoPreamp: false }), rock), -3);
+ expect(processingSpec(s, false).preampDb).toBe(-3);
+ });
+
+ it("off and compare both produce an unprocessed spec", () => {
+ const s = selectPreset(state(), rock);
+ expect(processingSpec({ ...s, enabled: false }, false)).toEqual(EQ_PROCESSING_OFF);
+ expect(processingSpec(s, true)).toEqual(EQ_PROCESSING_OFF);
+ });
+
+ it("a preview replaces the playing curve without touching the state", () => {
+ const preview: EqCurve = { preamp: 0, bands: [band(2_000, 5)] };
+ expect(processingSpec(selectPreset(state(), rock), false, preview).bands).toEqual([band(2_000, 5)]);
+ });
+
+ it("flat and off settings change nothing", () => {
+ expect(changesSound(state())).toBe(false);
+ expect(changesSound(selectPreset(state(), rock))).toBe(true);
+ expect(changesSound({ ...selectPreset(state(), rock), enabled: false })).toBe(false);
+ expect(changesSound(withBassBoost(state(), 3))).toBe(true);
+ expect(changesSound(addBand(state(), band(1_000, 0)))).toBe(false);
+ });
+
+ it("names are unique regardless of case, including built-in names", () => {
+ const s = state({ userPresets: [mine] });
+ expect(isNameAvailable(s, "mine", ["Rock"])).toBe(false);
+ expect(isNameAvailable(s, "ROCK", ["Rock"])).toBe(false);
+ expect(isNameAvailable(s, "Mine", ["Rock"], mine.id)).toBe(true);
+ expect(isNameAvailable(s, " ", ["Rock"])).toBe(false);
+ });
+
+ it("band edits respect the band limit and graphic mode", () => {
+ let full = state();
+ for (let i = 1; i <= EqLimits.MAX_BANDS; i++) full = addBand(full, band(i * 100, 1));
+ expect(addBand(full, band(5_000, 1))).toBe(full);
+ const graphic = state({ mode: "GRAPHIC" });
+ expect(addBand(graphic, band(5_000, 1))).toBe(graphic);
+ });
+
+ it("sanitising repairs a damaged state", () => {
+ const damaged = state({
+ graphic: { presetId: null, curve: { preamp: 0, bands: [band(1, 99)] } },
+ bassBoost: Number.NaN,
+ userPresets: [mine, mine, { id: "", name: "x", curve: { preamp: 0, bands: [] }, mode: "PARAMETRIC", imported: false }],
+ });
+ const clean = sanitizeState(damaged);
+ expect(clean.graphic.curve).toEqual(GraphicEq.flatCurve());
+ expect(clean.bassBoost).toBe(0);
+ expect(clean.userPresets).toEqual([mine]);
+ });
+
+ it("state survives its JSON form", () => {
+ const s = withBassBoost(selectPreset(state({ userPresets: [mine] }), mine.id), 4);
+ expect(statesEqual(decodeEqState(encodeEqState(s))!, s)).toBe(true);
+ expect(decodeEqState("not json")).toBeNull();
+ });
+
+ it("a preset picked in graphic mode is fitted onto the ten bands and stays graphic", () => {
+ const s = selectPreset(state({ mode: "GRAPHIC" }), rock);
+ const rockBands = builtInPreset(rock)!.curve.bands;
+
+ expect(s.mode).toBe("GRAPHIC");
+ expect(activeCopy(s).presetId).toBe(rock);
+ expect(isEdited(s)).toBe(false);
+ expect(s.parametric).toEqual(defaultEqState().parametric);
+ const grid = Array.from({ length: 81 }, (_, i) => frequencyAt(0.05 + (0.9 * i) / 80));
+ const error = grid.map((f) => magnitudeDb(s.graphic.curve.bands, f) - magnitudeDb(rockBands, f));
+ const rms = Math.sqrt(error.reduce((sum, e) => sum + e * e, 0) / error.length);
+ expect(rms).toBeLessThan(1);
+ const gains = GraphicEq.gainsOf(s.graphic.curve);
+ let zigzags = 0;
+ for (let i = 0; i + 2 < gains.length; i++) {
+ const [a, b, c] = [gains[i]!, gains[i + 1]!, gains[i + 2]!];
+ if ((b - a) * (c - b) < 0 && Math.abs(b - a) > 1.5 && Math.abs(c - b) > 1.5) zigzags++;
+ }
+ expect(zigzags).toBe(0);
+ });
+
+ it("revert in graphic mode returns to the fitted preset", () => {
+ const picked = selectPreset(state({ mode: "GRAPHIC" }), rock);
+ const moved = withActiveCurve(picked, GraphicEq.curveOf(Array(10).fill(6)));
+ expect(isEdited(moved)).toBe(true);
+ expect(statesEqual(revert(moved), picked)).toBe(true);
+ });
+
+ it("a graphic preset picked in parametric mode loads its bands and stays parametric", () => {
+ const car: EqPreset = {
+ id: "user:car",
+ name: "Car",
+ curve: GraphicEq.curveOf(Array.from({ length: 10 }, (_, i) => i)),
+ mode: "GRAPHIC",
+ imported: false,
+ };
+ const s = selectPreset(state({ userPresets: [car] }), car.id);
+ expect(s.mode).toBe("PARAMETRIC");
+ expect(activeCopy(s).curve.bands).toHaveLength(10);
+ expect(isEdited(s)).toBe(false);
+ });
+
+ it("importing from graphic mode switches to parametric, where the file plays exactly", () => {
+ const curve: EqCurve = { preamp: 0, bands: [band(910, 9, 0.71)] };
+ const s = importPreset(state({ mode: "GRAPHIC" }), "user:i", "Hefty Metal", curve);
+ expect(s.mode).toBe("PARAMETRIC");
+ expect(activeCopy(s).curve).toEqual(curve);
+ });
+
+ it("fitting flat gives flat and never writes a negative zero", () => {
+ const fit = GraphicEq.fit({ preamp: 0, bands: [] });
+ expect(fit).toEqual(GraphicEq.flatCurve());
+ expect(fit.bands.every((b) => !Object.is(b.gain, -0))).toBe(true);
+ });
+
+ it("presets apply in the current mode", () => {
+ expect(presetCurveFor(state(), "GRAPHIC", rock)!.bands).toHaveLength(10);
+ expect(presetCurveFor(state(), "PARAMETRIC", rock)!.bands).toHaveLength(5);
+ });
+});
diff --git a/src/lib/audio/eq/stateOps.ts b/src/lib/audio/eq/stateOps.ts
new file mode 100644
index 00000000..8b6b300e
--- /dev/null
+++ b/src/lib/audio/eq/stateOps.ts
@@ -0,0 +1,298 @@
+import { builtInPreset, FLAT_PRESET_ID, isBuiltInPreset } from "./builtInPresets";
+import { autoPreampDb, isBypassed, REFERENCE_SAMPLE_RATE } from "./filterMath";
+import { GraphicEq, sanitizeCurve } from "./limits";
+import {
+ band,
+ EQ_PROCESSING_OFF,
+ EQ_STATE_VERSION,
+ EqLimits,
+ type EqBand,
+ type EqCurve,
+ type EqMode,
+ type EqPreset,
+ type EqProcessingSpec,
+ type EqState,
+ type EqWorkingCopy,
+} from "./models";
+
+/**
+ * Every change the equalizer supports, as pure functions of `EqState`, ported from Android's
+ * `EqStateOps.kt`. The store applies them and saves the result.
+ */
+export function defaultEqState(): EqState {
+ return {
+ version: EQ_STATE_VERSION,
+ enabled: true,
+ mode: "PARAMETRIC",
+ parametric: { presetId: FLAT_PRESET_ID, curve: { preamp: 0, bands: [] } },
+ graphic: { presetId: null, curve: GraphicEq.flatCurve() },
+ autoPreamp: true,
+ bassBoost: 0,
+ userPresets: [],
+ };
+}
+
+const bandsEqual = (a: EqBand, b: EqBand) =>
+ a.frequency === b.frequency && a.gain === b.gain && a.q === b.q && a.type === b.type && a.enabled === b.enabled;
+
+export const curvesEqual = (a: EqCurve, b: EqCurve): boolean =>
+ a.preamp === b.preamp &&
+ a.bands.length === b.bands.length &&
+ a.bands.every((x, i) => {
+ const y = b.bands[i];
+ return y !== undefined && bandsEqual(x, y);
+ });
+
+const copiesEqual = (a: EqWorkingCopy, b: EqWorkingCopy) => a.presetId === b.presetId && curvesEqual(a.curve, b.curve);
+
+const presetsEqual = (a: EqPreset, b: EqPreset) =>
+ a.id === b.id && a.name === b.name && a.mode === b.mode && a.imported === b.imported && curvesEqual(a.curve, b.curve);
+
+export const statesEqual = (a: EqState, b: EqState): boolean =>
+ a.version === b.version &&
+ a.enabled === b.enabled &&
+ a.mode === b.mode &&
+ copiesEqual(a.parametric, b.parametric) &&
+ copiesEqual(a.graphic, b.graphic) &&
+ a.autoPreamp === b.autoPreamp &&
+ a.bassBoost === b.bassBoost &&
+ a.userPresets.length === b.userPresets.length &&
+ a.userPresets.every((p, i) => {
+ const q = b.userPresets[i];
+ return q !== undefined && presetsEqual(p, q);
+ });
+
+export const activeCopy = (state: EqState): EqWorkingCopy =>
+ state.mode === "PARAMETRIC" ? state.parametric : state.graphic;
+
+export const userPreset = (state: EqState, id: string | null | undefined): EqPreset | undefined =>
+ id ? state.userPresets.find((p) => p.id === id) : undefined;
+
+export const presetCurve = (state: EqState, id: string | null | undefined): EqCurve | undefined =>
+ builtInPreset(id)?.curve ?? userPreset(state, id)?.curve;
+
+const presetMode = (state: EqState, id: string | null | undefined): EqMode | undefined =>
+ isBuiltInPreset(id) ? "PARAMETRIC" : userPreset(state, id)?.mode;
+
+const defaultCurve = (mode: EqMode): EqCurve =>
+ mode === "PARAMETRIC" ? { preamp: 0, bands: [] } : GraphicEq.flatCurve();
+
+export function sanitizedFor(mode: EqMode, curve: EqCurve): EqCurve {
+ return mode === "GRAPHIC"
+ ? GraphicEq.curveOf(GraphicEq.gainsOf(curve), sanitizeCurve(curve).preamp)
+ : sanitizeCurve(curve);
+}
+
+// Presets are immutable values, so a fit is reused for as long as its source curve lives.
+const fits = new WeakMap();
+const fitted = (curve: EqCurve): EqCurve => {
+ let fit = fits.get(curve);
+ if (!fit) {
+ fit = GraphicEq.fit(curve);
+ fits.set(curve, fit);
+ }
+ return fit;
+};
+
+/**
+ * Preset `id` as `mode` plays it: a graphic preset's ten bands load as parametric bands as they
+ * are, and a parametric preset is fitted onto the ten graphic bands.
+ */
+export function presetCurveFor(state: EqState, mode: EqMode, id: string | null | undefined): EqCurve | undefined {
+ const curve = presetCurve(state, id);
+ if (!curve) return undefined;
+ if (mode === "PARAMETRIC") return sanitizeCurve(curve);
+ if (presetMode(state, id) === "GRAPHIC") return sanitizedFor("GRAPHIC", curve);
+ return fitted(curve);
+}
+
+/** The curve differs from the preset it came from, or there is no preset and it is not flat. */
+export function isEdited(state: EqState): boolean {
+ const active = activeCopy(state);
+ return !curvesEqual(active.curve, presetCurveFor(state, state.mode, active.presetId) ?? defaultCurve(state.mode));
+}
+
+/** The equalizer is on and something in it alters the signal. */
+export function changesSound(state: EqState): boolean {
+ const curve = activeCopy(state).curve;
+ return (
+ state.enabled &&
+ (state.bassBoost > 0 ||
+ curve.bands.some((b) => !isBypassed(b, REFERENCE_SAMPLE_RATE)) ||
+ (!state.autoPreamp && curve.preamp !== 0))
+ );
+}
+
+/** Save can overwrite the source preset only when it is one of the user's own. */
+export function canSaveActive(state: EqState): boolean {
+ return isEdited(state) && userPreset(state, activeCopy(state).presetId)?.mode === state.mode;
+}
+
+/** Puts `working` in the current mode, preset id and all. */
+export const withWorkingCopy = (state: EqState, working: EqWorkingCopy): EqState =>
+ state.mode === "PARAMETRIC" ? { ...state, parametric: working } : { ...state, graphic: working };
+
+export const withActiveCurve = (state: EqState, curve: EqCurve): EqState =>
+ withWorkingCopy(state, { ...activeCopy(state), curve: sanitizedFor(state.mode, curve) });
+
+/** Plays preset `id` in the current mode; the mode itself never changes here. */
+export function selectPreset(state: EqState, id: string): EqState {
+ const curve = presetCurveFor(state, state.mode, id);
+ return curve ? withWorkingCopy(state, { presetId: id, curve }) : state;
+}
+
+export const revert = (state: EqState): EqState =>
+ withActiveCurve(state, presetCurveFor(state, state.mode, activeCopy(state).presetId) ?? defaultCurve(state.mode));
+
+export function saveActive(state: EqState): EqState {
+ if (!canSaveActive(state)) return state;
+ const { presetId, curve } = activeCopy(state);
+ return { ...state, userPresets: state.userPresets.map((p) => (p.id === presetId ? { ...p, curve } : p)) };
+}
+
+export function saveActiveAs(state: EqState, id: string, name: string): EqState {
+ const curve = activeCopy(state).curve;
+ const next = { ...state, userPresets: [...state.userPresets, { id, name, curve, mode: state.mode, imported: false }] };
+ return withWorkingCopy(next, { presetId: id, curve });
+}
+
+export const renamePreset = (state: EqState, id: string, name: string): EqState => ({
+ ...state,
+ userPresets: state.userPresets.map((p) => (p.id === id ? { ...p, name } : p)),
+});
+
+export function duplicatePreset(state: EqState, sourceId: string, newId: string, name: string): EqState {
+ const curve = presetCurve(state, sourceId);
+ const mode = presetMode(state, sourceId);
+ if (!curve || !mode) return state;
+ return { ...state, userPresets: [...state.userPresets, { id: newId, name, curve, mode, imported: false }] };
+}
+
+/** A deleted preset and where it was, so Undo can put it back in place. */
+export interface DeletedEqPreset {
+ preset: EqPreset;
+ index: number;
+ selectedIn: EqMode[];
+}
+
+export function deletePreset(state: EqState, id: string): [EqState, DeletedEqPreset | null] {
+ const index = state.userPresets.findIndex((p) => p.id === id);
+ const preset = state.userPresets[index];
+ if (!preset) return [state, null];
+ const selectedIn: EqMode[] = [];
+ if (state.parametric.presetId === id) selectedIn.push("PARAMETRIC");
+ if (state.graphic.presetId === id) selectedIn.push("GRAPHIC");
+ const next: EqState = {
+ ...state,
+ userPresets: state.userPresets.filter((p) => p.id !== id),
+ parametric: state.parametric.presetId === id ? { ...state.parametric, presetId: null } : state.parametric,
+ graphic: state.graphic.presetId === id ? { ...state.graphic, presetId: null } : state.graphic,
+ };
+ return [next, { preset, index, selectedIn }];
+}
+
+export function restorePreset(state: EqState, deleted: DeletedEqPreset): EqState {
+ if (userPreset(state, deleted.preset.id)) return state;
+ const presets = [...state.userPresets];
+ presets.splice(Math.max(0, Math.min(deleted.index, presets.length)), 0, deleted.preset);
+ const id = deleted.preset.id;
+ const reselect = (mode: EqMode, copy: EqWorkingCopy) =>
+ deleted.selectedIn.includes(mode) && copy.presetId === null ? { ...copy, presetId: id } : copy;
+ return {
+ ...state,
+ userPresets: presets,
+ parametric: reselect("PARAMETRIC", state.parametric),
+ graphic: reselect("GRAPHIC", state.graphic),
+ };
+}
+
+export function importPreset(state: EqState, id: string, name: string, curve: EqCurve): EqState {
+ const next: EqState = {
+ ...state,
+ mode: "PARAMETRIC",
+ userPresets: [
+ ...state.userPresets,
+ { id, name, curve: sanitizeCurve(curve), mode: "PARAMETRIC", imported: true },
+ ],
+ };
+ return selectPreset(next, id);
+}
+
+export const withBassBoost = (state: EqState, db: number): EqState => ({
+ ...state,
+ bassBoost: Math.max(0, Math.min(EqLimits.MAX_BASS_BOOST, db)),
+});
+
+export const withManualPreamp = (state: EqState, db: number): EqState =>
+ withActiveCurve(state, { ...activeCopy(state).curve, preamp: db });
+
+export function withBand(state: EqState, index: number, next: EqBand): EqState {
+ const bands = activeCopy(state).curve.bands;
+ if (index < 0 || index >= bands.length) return state;
+ return withActiveCurve(state, { ...activeCopy(state).curve, bands: bands.map((b, i) => (i === index ? next : b)) });
+}
+
+export function addBand(state: EqState, next: EqBand): EqState {
+ const curve = activeCopy(state).curve;
+ if (state.mode !== "PARAMETRIC" || curve.bands.length >= EqLimits.MAX_BANDS) return state;
+ return withActiveCurve(state, { ...curve, bands: [...curve.bands, next] });
+}
+
+export function removeBand(state: EqState, index: number): EqState {
+ const curve = activeCopy(state).curve;
+ if (state.mode !== "PARAMETRIC" || index < 0 || index >= curve.bands.length) return state;
+ return withActiveCurve(state, { ...curve, bands: curve.bands.filter((_, i) => i !== index) });
+}
+
+export function insertBand(state: EqState, index: number, next: EqBand): EqState {
+ const curve = activeCopy(state).curve;
+ if (state.mode !== "PARAMETRIC" || curve.bands.length >= EqLimits.MAX_BANDS) return state;
+ const bands = [...curve.bands];
+ bands.splice(Math.max(0, Math.min(index, bands.length)), 0, next);
+ return withActiveCurve(state, { ...curve, bands });
+}
+
+export const normalizedPresetName = (raw: string): string => raw.trim().slice(0, EqLimits.MAX_NAME_LENGTH);
+
+/** True when `name` is free among the user's presets and the built-in names shown to them. */
+export function isNameAvailable(
+ state: EqState,
+ name: string,
+ builtInNames: readonly string[],
+ exceptId?: string | null,
+): boolean {
+ const wanted = name.trim().toLocaleLowerCase();
+ if (!wanted) return false;
+ const taken = [...state.userPresets.filter((p) => p.id !== exceptId).map((p) => p.name), ...builtInNames];
+ return taken.every((n) => n.toLocaleLowerCase() !== wanted);
+}
+
+/**
+ * What the audio graph should run. `preview` replaces the active curve while a point is dragged,
+ * so the sound follows the pointer without the saved state changing every frame.
+ */
+export function processingSpec(state: EqState, bypass: boolean, preview?: EqCurve | null): EqProcessingSpec {
+ if (!state.enabled || bypass) return EQ_PROCESSING_OFF;
+ const curve = preview ?? activeCopy(state).curve;
+ const bands = curve.bands.filter((b) => b.enabled);
+ if (state.bassBoost > 0) {
+ bands.push(band(EqLimits.BASS_BOOST_FREQUENCY, state.bassBoost, EqLimits.DEFAULT_SHELF_Q, "LOW_SHELF"));
+ }
+ const preampDb = state.autoPreamp ? autoPreampDb(bands) : curve.preamp;
+ return { enabled: true, preampDb, bands };
+}
+
+/** Clamps every value and repairs anything a hand-edited backup or an old version could hold. */
+export function sanitizeState(state: EqState): EqState {
+ const seen = new Set();
+ return {
+ ...state,
+ parametric: { ...state.parametric, curve: sanitizeCurve(state.parametric.curve) },
+ graphic: { ...state.graphic, curve: sanitizedFor("GRAPHIC", state.graphic.curve) },
+ bassBoost: Number.isFinite(state.bassBoost) ? Math.max(0, Math.min(EqLimits.MAX_BASS_BOOST, state.bassBoost)) : 0,
+ userPresets: state.userPresets
+ .filter((p) => p.id.trim() !== "" && p.name.trim() !== "")
+ .filter((p) => (seen.has(p.id) ? false : (seen.add(p.id), true)))
+ .map((p) => ({ ...p, name: normalizedPresetName(p.name), curve: sanitizedFor(p.mode, p.curve) })),
+ };
+}
diff --git a/src/lib/audio/eq/useEqualizer.ts b/src/lib/audio/eq/useEqualizer.ts
new file mode 100644
index 00000000..0093ee8b
--- /dev/null
+++ b/src/lib/audio/eq/useEqualizer.ts
@@ -0,0 +1,95 @@
+import { useMemo, useSyncExternalStore } from "react";
+
+import { getString } from "../../i18n/index";
+import { musicAudioEngine } from "../musicAudioEngine";
+import { useEqStore } from "../../../store/useEqStore";
+import { BUILT_IN_EQ_PRESETS, isBuiltInPreset } from "./builtInPresets";
+import type { EqMode, EqState } from "./models";
+import {
+ activeCopy,
+ canSaveActive,
+ changesSound,
+ isEdited,
+ processingSpec,
+ selectPreset,
+ withWorkingCopy,
+} from "./stateOps";
+
+export interface EqPresetEntry {
+ id: string;
+ name: string;
+ builtIn: boolean;
+}
+
+export const builtInPresetNames = (): string[] => BUILT_IN_EQ_PRESETS.map((p) => getString(p.nameKey));
+
+/** Built-ins first, in Android's order, then the user's own. */
+export function presetEntries(state: EqState): EqPresetEntry[] {
+ return [
+ ...BUILT_IN_EQ_PRESETS.map((p) => ({ id: p.id, name: getString(p.nameKey), builtIn: true })),
+ ...state.userPresets.map((p) => ({ id: p.id, name: p.name, builtIn: false })),
+ ];
+}
+
+export function presetName(state: EqState, id: string | null): string | null {
+ if (!id) return null;
+ return presetEntries(state).find((p) => p.id === id)?.name ?? null;
+}
+
+/**
+ * Plays preset `id` in the current mode. An unsaved curve it replaces is kept as Custom, so
+ * picking a preset never loses a curve the user tuned.
+ */
+export function pickEqPreset(id: string): void {
+ const { state, update, setCustom } = useEqStore.getState();
+ const active = activeCopy(state);
+ if (active.presetId === id) return;
+ // A flat curve with no preset is nothing to keep.
+ if (isEdited(state)) setCustom(state.mode, active);
+ update((s) => selectPreset(s, id));
+}
+
+/** Brings back the curve that was playing before a preset replaced it. */
+export function restoreCustomEq(mode: EqMode): void {
+ const { custom, update, setCustom } = useEqStore.getState();
+ const copy = custom[mode];
+ if (!copy) return;
+ setCustom(mode, undefined);
+ update((s) => (s.mode === mode ? withWorkingCopy(s, { presetId: null, curve: copy.curve }) : s));
+}
+
+/** The equalizer state the editor and the quick panel show, with what they derive from it. */
+export function useEqualizer() {
+ const state = useEqStore((s) => s.state);
+ const preview = useEqStore((s) => s.preview);
+ const custom = useEqStore((s) => s.custom[s.state.mode]);
+ return useMemo(() => {
+ const active = activeCopy(state);
+ const spec = processingSpec({ ...state, enabled: true }, false, preview);
+ return {
+ state,
+ active,
+ /** What the graph draws: the curve being dragged, or the active one, with bass boost. */
+ drawnBands: spec.bands,
+ autoPreampDb: spec.preampDb,
+ edited: isEdited(state),
+ canSave: canSaveActive(state),
+ changesSound: changesSound(state),
+ hasCustom: !!custom || active.presetId === null,
+ selectedIsUserPreset: !!active.presetId && !isBuiltInPreset(active.presetId),
+ presets: presetEntries(state),
+ activeName: presetName(state, active.presetId),
+ };
+ }, [state, preview, custom]);
+}
+
+/** Whether the equalizer is on and changes the sound, for the buttons that open it. */
+export const useEqActive = (): boolean => useEqStore((s) => changesSound(s.state));
+
+/** False when WebAudio failed for music, so the equalizer cannot be heard here. */
+export function useMusicEqAvailable(): boolean {
+ return useSyncExternalStore(
+ (onChange) => musicAudioEngine.onEqAvailabilityChange(onChange),
+ () => musicAudioEngine.isEqAvailable(),
+ );
+}
diff --git a/src/lib/audio/eqBands.test.ts b/src/lib/audio/eqBands.test.ts
deleted file mode 100644
index 4374ef2b..00000000
--- a/src/lib/audio/eqBands.test.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { EQ_FLAT, EQ_PRESETS, eqCurveBands } from "./eqBands";
-import { autoPreampDb, peakDb } from "./eq/filterMath";
-
-describe("EQ headroom", () => {
- it("takes the Bass preset's stacked boosts off before the filters", () => {
- const bands = eqCurveBands(EQ_PRESETS.bass);
- // +6, +5 and +4 dB on overlapping bands add up past any single slider.
- expect(peakDb(bands)).toBeGreaterThan(6);
- expect(autoPreampDb(bands)).toBeCloseTo(-peakDb(bands), 6);
- });
-
- it("leaves a flat or cut-only curve at unity", () => {
- expect(autoPreampDb(eqCurveBands(EQ_FLAT))).toBe(0);
- expect(autoPreampDb(eqCurveBands(EQ_FLAT.map(() => -3)))).toBe(0);
- });
-});
diff --git a/src/lib/audio/eqBands.ts b/src/lib/audio/eqBands.ts
deleted file mode 100644
index ff096ed8..00000000
--- a/src/lib/audio/eqBands.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { band, EqLimits, type EqBand } from "./eq/models";
-
-export interface EqBandConfig {
- frequency: number;
- type: BiquadFilterType;
- label: string;
-}
-
-export const EQ_BANDS: EqBandConfig[] = [
- { frequency: 32, type: "lowshelf", label: "32" },
- { frequency: 64, type: "peaking", label: "64" },
- { frequency: 125, type: "peaking", label: "125" },
- { frequency: 250, type: "peaking", label: "250" },
- { frequency: 500, type: "peaking", label: "500" },
- { frequency: 1000, type: "peaking", label: "1K" },
- { frequency: 2000, type: "peaking", label: "2K" },
- { frequency: 4000, type: "peaking", label: "4K" },
- { frequency: 8000, type: "peaking", label: "8K" },
- { frequency: 16000, type: "highshelf", label: "16K" },
-];
-
-export const EQ_BAND_COUNT = EQ_BANDS.length;
-
-/** Quality factor for the peaking bands. ~1.4 gives smooth, musical overlap. */
-export const EQ_PEAKING_Q = 1.41;
-
-/** Maximum boost/cut per band, in dB (slider range is ±this). */
-export const EQ_MAX_GAIN_DB = 12;
-
-/** A flat (0 dB everywhere) curve — the neutral default. */
-export const EQ_FLAT: number[] = EQ_BANDS.map(() => 0);
-
-export type EqPresetName =
- | "flat"
- | "bass"
- | "vocal"
- | "treble"
- | "electronic"
- | "acoustic";
-
-// Preset gains are ordered low → high to match EQ_BANDS exactly.
-export const EQ_PRESETS: Record = {
- flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
- bass: [6, 5, 4, 2, 0, 0, 0, 0, 1, 2],
- vocal: [-2, -1, 0, 2, 4, 4, 3, 2, 0, -1],
- treble: [0, 0, 0, 0, 0, 1, 2, 4, 5, 6],
- electronic: [5, 4, 1, 0, -1, 1, 0, 2, 3, 4],
- acoustic: [3, 2, 1, 1, 2, 2, 3, 3, 2, 1],
-};
-
-// Clamp an arbitrary gains array to exactly EQ_BAND_COUNT entries in range.
-export function normalizeEqGains(gains: number[] | null | undefined): number[] {
- const clampDb = (n: number) =>
- Math.max(-EQ_MAX_GAIN_DB, Math.min(EQ_MAX_GAIN_DB, Number.isFinite(n) ? n : 0));
- return EQ_BANDS.map((_, i) => clampDb(gains?.[i] ?? 0));
-}
-
-/**
- * The curve `gains` plays, in the shared EQ model. WebAudio's shelves ignore Q and always have the
- * Butterworth slope, so they are modelled at that Q.
- */
-export function eqCurveBands(gains: readonly number[]): EqBand[] {
- return EQ_BANDS.map((config, i) =>
- config.type === "peaking"
- ? band(config.frequency, gains[i] ?? 0, EQ_PEAKING_Q, "PEAK")
- : band(
- config.frequency,
- gains[i] ?? 0,
- EqLimits.DEFAULT_SHELF_Q,
- config.type === "lowshelf" ? "LOW_SHELF" : "HIGH_SHELF",
- ),
- );
-}
diff --git a/src/lib/audio/eqChain.ts b/src/lib/audio/eqChain.ts
new file mode 100644
index 00000000..12b3b406
--- /dev/null
+++ b/src/lib/audio/eqChain.ts
@@ -0,0 +1,72 @@
+import { EQ_FILTER_POOL, EQ_GRAPH_OFF, filterSetting, type EqGraphSpec } from "./eq/graphPlan";
+import { EqLimits } from "./eq/models";
+import { SOFT_CLIP_HEADROOM, softClipCurve } from "./softClip";
+
+/** Time constant every parameter glides with: quick enough to feel instant, slow enough not to click. */
+export const RAMP_TIME_CONSTANT = 0.012;
+
+let sharedClipCurve: Float32Array | null = null;
+
+/**
+ * One player's equalizer: preamp, a fixed pool of filters, a bass boost shelf and a soft clipper.
+ * Nodes are made once; a new curve only moves their parameters, so the sound glides from one
+ * setting to the next and nothing is ever reconnected while audio plays.
+ */
+export class EqChain {
+ readonly input: GainNode;
+ readonly output: AudioNode;
+ private readonly filters: BiquadFilterNode[];
+ private readonly bass: BiquadFilterNode;
+
+ constructor(private readonly ctx: BaseAudioContext) {
+ this.input = ctx.createGain();
+ this.filters = Array.from({ length: EQ_FILTER_POOL }, () => {
+ const f = ctx.createBiquadFilter();
+ f.type = "peaking";
+ f.gain.value = 0;
+ return f;
+ });
+ this.bass = ctx.createBiquadFilter();
+ this.bass.type = "lowshelf";
+ this.bass.frequency.value = EqLimits.BASS_BOOST_FREQUENCY;
+ this.bass.gain.value = 0;
+
+ // A WaveShaperNode clamps its input to ±1, so the signal goes in scaled down and the curve
+ // spans the headroom; below the knee the curve is the identity, so normal levels pass as-is.
+ const clipIn = ctx.createGain();
+ clipIn.gain.value = 1 / SOFT_CLIP_HEADROOM;
+ const clipper = ctx.createWaveShaper();
+ sharedClipCurve ??= softClipCurve();
+ clipper.curve = sharedClipCurve;
+
+ let previous: AudioNode = this.input;
+ for (const node of [...this.filters, this.bass, clipIn, clipper]) {
+ previous.connect(node);
+ previous = node;
+ }
+ this.output = clipper;
+ }
+
+ apply(spec: EqGraphSpec = EQ_GRAPH_OFF): void {
+ const t = this.ctx.currentTime;
+ const glide = (param: AudioParam, value: number) => param.setTargetAtTime(value, t, RAMP_TIME_CONSTANT);
+ const sampleRate = this.ctx.sampleRate;
+
+ this.filters.forEach((filter, i) => {
+ const setting = spec.enabled ? filterSetting(spec.bands[i], sampleRate) : null;
+ if (!setting) {
+ // Peaks and shelves go flat at 0 dB whatever their frequency, so only the gain moves; a
+ // low or high pass cannot be flattened and becomes a flat peak.
+ if (filter.type === "lowpass" || filter.type === "highpass") filter.type = "peaking";
+ glide(filter.gain, 0);
+ return;
+ }
+ if (filter.type !== setting.type) filter.type = setting.type;
+ glide(filter.frequency, setting.frequency);
+ glide(filter.Q, setting.q);
+ glide(filter.gain, setting.gain);
+ });
+ glide(this.bass.gain, spec.enabled ? spec.bassBoostDb : 0);
+ glide(this.input.gain, spec.enabled ? Math.pow(10, spec.preampDb / 20) : 1);
+ }
+}
diff --git a/src/lib/audio/musicAudioEngine.eq.test.ts b/src/lib/audio/musicAudioEngine.eq.test.ts
new file mode 100644
index 00000000..34da8779
--- /dev/null
+++ b/src/lib/audio/musicAudioEngine.eq.test.ts
@@ -0,0 +1,154 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { graphSpec, EQ_GRAPH_OFF } from "./eq/graphPlan";
+import { defaultEqState, selectPreset } from "./eq/stateOps";
+import { musicAudioEngine } from "./musicAudioEngine";
+
+// A minimal WebAudio stand-in: enough to see which nodes exist and what they were told.
+class FakeParam {
+ constructor(public value = 0) {}
+ setTargetAtTime = vi.fn((value: number) => {
+ this.value = value;
+ });
+}
+class FakeNode {
+ connect = vi.fn((node: unknown) => node);
+}
+class FakeGain extends FakeNode {
+ gain = new FakeParam(1);
+}
+class FakeBiquad extends FakeNode {
+ type = "lowpass";
+ frequency = new FakeParam(350);
+ Q = new FakeParam(1);
+ gain = new FakeParam(0);
+}
+class FakeShaper extends FakeNode {
+ curve: Float32Array | null = null;
+}
+
+const contexts: FakeContext[] = [];
+class FakeContext extends EventTarget {
+ state: AudioContextState = "running";
+ currentTime = 0;
+ sampleRate = 48_000;
+ destination = new FakeNode();
+ biquads: FakeBiquad[] = [];
+ sources: HTMLMediaElement[] = [];
+ resume = vi.fn(async () => {
+ this.state = "running";
+ });
+ constructor() {
+ super();
+ contexts.push(this);
+ }
+ createGain() {
+ return new FakeGain();
+ }
+ createBiquadFilter() {
+ const f = new FakeBiquad();
+ this.biquads.push(f);
+ return f;
+ }
+ createWaveShaper() {
+ return new FakeShaper();
+ }
+ createMediaElementSource(el: HTMLMediaElement) {
+ this.sources.push(el);
+ return new FakeNode();
+ }
+}
+
+const elements = (): [HTMLAudioElement, HTMLAudioElement] => {
+ const pair = [document.createElement("audio"), document.createElement("audio")] as [
+ HTMLAudioElement,
+ HTMLAudioElement,
+ ];
+ for (const el of pair) {
+ el.load = vi.fn();
+ el.pause = vi.fn();
+ el.play = vi.fn(async () => {});
+ }
+ return pair;
+};
+
+const rock = graphSpec(selectPreset(defaultEqState(), "builtin:rock"), false);
+
+// The engine is a singleton that keeps its context, so tests share the last one made.
+const lastContext = () => contexts[contexts.length - 1]!;
+
+beforeEach(() => {
+ vi.stubGlobal("AudioContext", FakeContext);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("music EQ graph", () => {
+ it("plays straight from the elements until the equalizer changes the sound", () => {
+ const pair = elements();
+ musicAudioEngine.attach(pair);
+ musicAudioEngine.setEq(EQ_GRAPH_OFF, false);
+ musicAudioEngine.setVolume(0.5);
+ musicAudioEngine.setLoudness(6, true);
+
+ expect(contexts).toHaveLength(0);
+ // Normalization turns the element down by itself: 6 dB over the target is about half.
+ expect(pair[0].volume).toBeCloseTo(0.25, 2);
+ expect(musicAudioEngine.isEqAvailable()).toBe(true);
+ });
+
+ it("builds one chain of 20 filters plus bass boost, and keeps it through a gapless swap", async () => {
+ const pair = elements();
+ musicAudioEngine.attach(pair);
+ musicAudioEngine.setEq(rock, true);
+
+ const ctx = lastContext();
+ expect(ctx.sources).toEqual(pair);
+ expect(ctx.biquads).toHaveLength(21);
+ expect(ctx.biquads[0]!.gain.value).toBe(rock.bands[0]!.gain);
+ expect(ctx.biquads[5]!.gain.value).toBe(0);
+
+ await musicAudioEngine.load("http://127.0.0.1/stream/a");
+ musicAudioEngine.preload("b", "http://127.0.0.1/stream/b", null);
+ Object.defineProperty(pair[1], "readyState", { value: HTMLMediaElement.HAVE_FUTURE_DATA, configurable: true });
+ expect(musicAudioEngine.activatePreloaded("b")).toBe(true);
+
+ // No new sources or filters: the swap only changes which element is heard.
+ expect(ctx.sources).toHaveLength(2);
+ expect(ctx.biquads).toHaveLength(21);
+ expect(ctx.biquads[0]!.gain.value).toBe(rock.bands[0]!.gain);
+
+ musicAudioEngine.setEq(EQ_GRAPH_OFF, false);
+ expect(ctx.biquads.every((f) => f.gain.value === 0)).toBe(true);
+ });
+
+ it("starts the element even when the context will not resume", async () => {
+ vi.useFakeTimers();
+ const pair = elements();
+ musicAudioEngine.attach(pair);
+ musicAudioEngine.setEq(rock, true);
+ const ctx = lastContext();
+ ctx.state = "suspended";
+ ctx.resume.mockImplementation(() => new Promise(() => {}));
+
+ const started = musicAudioEngine.play();
+ await vi.advanceTimersByTimeAsync(500);
+ await started;
+ expect(pair[0].play).toHaveBeenCalled();
+ });
+
+ it("resumes the context when the system suspends it mid-song", () => {
+ const pair = elements();
+ musicAudioEngine.attach(pair);
+ musicAudioEngine.setEq(rock, true);
+ const ctx = lastContext();
+ Object.defineProperty(pair[0], "paused", { value: false, configurable: true });
+
+ ctx.state = "suspended";
+ ctx.dispatchEvent(new Event("statechange"));
+ expect(ctx.resume).toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts
index 7c4166cf..50deaa65 100644
--- a/src/lib/audio/musicAudioEngine.ts
+++ b/src/lib/audio/musicAudioEngine.ts
@@ -1,23 +1,7 @@
import { loudnessFactor } from "../loudness";
-import {
- EQ_BANDS,
- EQ_PEAKING_Q,
- eqCurveBands,
- normalizeEqGains,
-} from "./eqBands";
-import { autoPreampDb } from "./eq/filterMath";
-import { SOFT_CLIP_HEADROOM, softClipCurve } from "./softClip";
-
-type AudioContextCtor = typeof AudioContext;
-
-function getAudioContextCtor(): AudioContextCtor | null {
- if (typeof window === "undefined") return null;
- return (
- window.AudioContext ??
- (window as unknown as { webkitAudioContext?: AudioContextCtor }).webkitAudioContext ??
- null
- );
-}
+import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from "./audioContext";
+import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan";
+import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain";
/**
* One of the two elements. While one plays, the other can hold the next
@@ -48,18 +32,18 @@ class MusicAudioEngine {
private slots: [Slot, Slot] = [emptySlot(), emptySlot()];
private activeIndex: 0 | 1 = 0;
private ctx: AudioContext | null = null;
- private preamp: GainNode | null = null;
- private filters: BiquadFilterNode[] = [];
+ private chain: EqChain | null = null;
private volGain: GainNode | null = null;
private graphReady = false;
private webAudioFailed = false;
+ private availabilityListeners = new Set<(available: boolean) => void>();
private volume = 1;
private muted = false;
- private eqEnabled = false;
- private eqGains: number[] = normalizeEqGains(null);
private normalizationEnabled = true;
+ private eqSpec: EqGraphSpec = EQ_GRAPH_OFF;
+ private eqEngaged = false;
private get active(): Slot {
return this.slots[this.activeIndex];
@@ -82,8 +66,8 @@ class MusicAudioEngine {
el.preload = "auto";
this.slots[i as 0 | 1].el = el;
}
- // Rebuild the graph lazily on the next play()
this.graphReady = false;
+ if (this.eqEngaged) this.ensureGraph();
this.applyGain();
}
@@ -92,41 +76,25 @@ class MusicAudioEngine {
return this.active.el;
}
+ /**
+ * Routes both elements through WebAudio. Done only once the equalizer changes the sound: a
+ * media element taken over by WebAudio can never play on its own again, and loudness
+ * normalization only ever turns down, which the element's own volume does.
+ */
private ensureGraph(): void {
- if (this.graphReady || this.webAudioFailed || !this.active.el) return;
+ if (this.graphReady || this.webAudioFailed || !this.slots[0].el || !this.slots[1].el) return;
try {
- const Ctor = getAudioContextCtor();
- if (!Ctor) {
- this.webAudioFailed = true;
- return;
+ if (!this.ctx) {
+ const ctx = createAudioContext();
+ if (!ctx) throw new Error("No AudioContext");
+ this.ctx = ctx;
+ keepRunning(ctx, () => !!this.active.el && !this.active.el.paused);
}
- if (!this.ctx) this.ctx = new Ctor();
const ctx = this.ctx;
- // preamp -> filters -> soft clipper -> volume. The preamp takes the curve's peak off
- // before the boosts, and the clipper bends whatever still goes past full scale.
- const preamp = ctx.createGain();
- this.preamp = preamp;
- this.filters = EQ_BANDS.map((band) => {
- const f = ctx.createBiquadFilter();
- f.type = band.type;
- f.frequency.value = band.frequency;
- f.Q.value = EQ_PEAKING_Q;
- f.gain.value = 0;
- return f;
- });
- const clipIn = ctx.createGain();
- clipIn.gain.value = 1 / SOFT_CLIP_HEADROOM;
- const clipper = ctx.createWaveShaper();
- clipper.curve = softClipCurve();
+ this.chain = new EqChain(ctx);
this.volGain = ctx.createGain();
- const filtersStart = this.filters.reduceRight((next, filter) => {
- filter.connect(next);
- return filter;
- }, clipIn);
- preamp.connect(filtersStart);
- clipIn.connect(clipper);
- clipper.connect(this.volGain);
+ this.chain.output.connect(this.volGain);
this.volGain.connect(ctx.destination);
// Each element keeps its own loudness gain, since the two hold different
@@ -136,18 +104,18 @@ class MusicAudioEngine {
slot.source = ctx.createMediaElementSource(slot.el);
slot.normGain = ctx.createGain();
slot.source.connect(slot.normGain);
- slot.normGain.connect(preamp);
+ slot.normGain.connect(this.chain.input);
}
this.graphReady = true;
this.applyGain();
- this.applyEq();
+ this.chain.apply(this.eqSpec);
+ // Turned on mid-song: the context has to run for the element to be heard.
+ if (this.active.el && !this.active.el.paused) void resumeWithin(ctx);
} catch (err) {
- console.warn(
- "[musicAudioEngine] WebAudio graph unavailable — degrading to element volume",
- err,
- );
+ console.warn("[musicAudioEngine] WebAudio graph unavailable, playing without the equalizer", err);
this.webAudioFailed = true;
+ this.notifyAvailability();
this.applyGain();
}
}
@@ -254,13 +222,7 @@ class MusicAudioEngine {
async play(): Promise {
const el = this.active.el;
if (!el) return;
- this.ensureGraph();
- if (this.ctx && this.ctx.state === "suspended") {
- try {
- await this.ctx.resume();
- } catch {
- }
- }
+ if (this.ctx) await resumeWithin(this.ctx);
try {
await el.play();
} catch (err) {
@@ -341,9 +303,9 @@ class MusicAudioEngine {
if (this.graphReady && this.ctx && this.volGain) {
const t = this.ctx.currentTime;
- this.volGain.gain.setTargetAtTime(vol, t, 0.012);
+ this.volGain.gain.setTargetAtTime(vol, t, RAMP_TIME_CONSTANT);
for (const slot of this.slots) {
- slot.normGain?.gain.setTargetAtTime(this.normalizationFactor(slot.loudnessDb), t, 0.012);
+ slot.normGain?.gain.setTargetAtTime(this.normalizationFactor(slot.loudnessDb), t, RAMP_TIME_CONSTANT);
if (slot.el) {
slot.el.volume = 1;
slot.el.muted = false;
@@ -361,33 +323,29 @@ class MusicAudioEngine {
// --- equalizer ----------------------------------------------------------
- setEqEnabled(enabled: boolean): void {
- this.eqEnabled = enabled;
- this.applyEq();
+ /**
+ * The curve to run, and whether it changes the sound. The graph is built the first time it
+ * does; after that every change, including switching off, only glides the filters.
+ */
+ setEq(spec: EqGraphSpec, engaged: boolean): void {
+ this.eqSpec = spec;
+ this.eqEngaged = engaged;
+ if (engaged) this.ensureGraph();
+ this.chain?.apply(spec);
}
- setEqGains(gains: number[]): void {
- this.eqGains = normalizeEqGains(gains);
- this.applyEq();
+ /** False once WebAudio failed here, so the equalizer cannot be heard. */
+ isEqAvailable(): boolean {
+ return !this.webAudioFailed && webAudioSupported();
}
- setEqBand(index: number, gainDb: number): void {
- if (index < 0 || index >= this.eqGains.length) return;
- const next = [...this.eqGains];
- next[index] = gainDb;
- this.eqGains = normalizeEqGains(next);
- this.applyEq();
+ onEqAvailabilityChange(listener: (available: boolean) => void): () => void {
+ this.availabilityListeners.add(listener);
+ return () => this.availabilityListeners.delete(listener);
}
- private applyEq(): void {
- if (!this.graphReady || !this.ctx || !this.preamp) return;
- const t = this.ctx.currentTime;
- const gains = this.eqEnabled ? this.eqGains : EQ_BANDS.map(() => 0);
- this.filters.forEach((f, i) => {
- f.gain.setTargetAtTime(gains[i] ?? 0, t, 0.012);
- });
- const preampDb = autoPreampDb(eqCurveBands(gains));
- this.preamp.gain.setTargetAtTime(Math.pow(10, preampDb / 20), t, 0.012);
+ private notifyAvailability(): void {
+ for (const listener of this.availabilityListeners) listener(this.isEqAvailable());
}
}
diff --git a/src/lib/audio/musicAudioSettings.ts b/src/lib/audio/musicAudioSettings.ts
index c2b06569..ef2d6c3c 100644
--- a/src/lib/audio/musicAudioSettings.ts
+++ b/src/lib/audio/musicAudioSettings.ts
@@ -1,13 +1,18 @@
import { SETTINGS } from "../settings/schema";
import { getSettingValue, useAppSettingsStore } from "../../store/useAppSettingsStore";
+import { bindEqPersistence, useEqStore } from "../../store/useEqStore";
import { useMusicPlayerStore } from "../../store/useMusicPlayerStore";
+import { REFERENCE_SAMPLE_RATE } from "./eq/filterMath";
+import { graphChangesSound, graphSpec } from "./eq/graphPlan";
+import { changesSound } from "./eq/stateOps";
+import { musicAudioEngine } from "./musicAudioEngine";
let bound = false;
/**
- * Keeps the music player on the saved music audio settings. They load from SQLite after the
- * player starts, and a restore or a sync can change them later, so they are followed rather
- * than read once.
+ * Keeps the music player on the saved music audio settings and the equalizer. They load from
+ * SQLite after the player starts, and a restore or a sync can change them later, so they are
+ * followed rather than read once.
*/
export function bindMusicAudioSettings(): void {
if (bound) return;
@@ -18,9 +23,26 @@ export function bindMusicAudioSettings(): void {
const player = useMusicPlayerStore.getState();
if (player.normalizationEnabled !== enabled) player.setNormalizationEnabled(enabled);
};
-
applyNormalization();
useAppSettingsStore.subscribe((state, previous) => {
if (state.values !== previous.values) applyNormalization();
});
+
+ const applyEq = () => {
+ const { state, bypass, preview } = useEqStore.getState();
+ // Compare and a drag preview never build the graph on their own: they only matter once the
+ // saved curve already changes the sound, or for a preview that does.
+ const spec = graphSpec(state, bypass, preview);
+ const engaged =
+ changesSound(state) ||
+ (preview !== null && graphChangesSound(graphSpec(state, false, preview), REFERENCE_SAMPLE_RATE));
+ musicAudioEngine.setEq(spec, engaged);
+ };
+ applyEq();
+ useEqStore.subscribe((state, previous) => {
+ if (state.state !== previous.state || state.bypass !== previous.bypass || state.preview !== previous.preview) {
+ applyEq();
+ }
+ });
+ bindEqPersistence();
}
diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json
index ff7f6382..063e7323 100644
--- a/src/lib/i18n/strings.json
+++ b/src/lib/i18n/strings.json
@@ -1038,6 +1038,77 @@
"music_equalizer": "Equalizer",
"music_loudness_normalization": "Loudness normalization",
"music_loudness_normalization_desc": "Turns down songs mastered louder than YouTube Music's target",
+ "eq_preset_flat": "Flat",
+ "eq_preset_acoustic": "Acoustic",
+ "eq_preset_bass_boost": "Bass Boost",
+ "eq_preset_classical": "Classical",
+ "eq_preset_electronic": "Electronic",
+ "eq_preset_heavy_metal": "Heavy Metal",
+ "eq_preset_hip_hop": "Hip-Hop",
+ "eq_preset_jazz": "Jazz",
+ "eq_preset_metal": "Metal",
+ "eq_preset_pop": "Pop",
+ "eq_preset_rnb": "R&B",
+ "eq_preset_rock": "Rock",
+ "eq_preset_vocal": "Vocal / Podcast",
+ "eq_preset_custom": "Custom",
+ "eq_presets": "Presets",
+ "eq_edit": "Edit",
+ "eq_edited": "Edited",
+ "eq_revert": "Revert",
+ "eq_save": "Save",
+ "eq_save_as": "Save as",
+ "eq_rename": "Rename",
+ "eq_duplicate": "Duplicate",
+ "eq_delete": "Delete",
+ "eq_deleted": "Deleted %s",
+ "eq_copy_name": "%s copy",
+ "eq_preset_name": "Preset name",
+ "eq_name_taken": "A preset already has this name",
+ "eq_mode_graphic": "Graphic",
+ "eq_mode_parametric": "Parametric",
+ "eq_compare": "Hold to compare",
+ "eq_graph_label": "Equalizer response",
+ "eq_band_label": "%s Hz",
+ "eq_no_bands": "No bands yet. Add one, double-click the graph or pick a preset.",
+ "eq_add_band": "Add band",
+ "eq_remove_band": "Remove band",
+ "eq_type_peak": "Peak",
+ "eq_type_low_shelf": "Low shelf",
+ "eq_type_high_shelf": "High shelf",
+ "eq_type_low_pass": "Low pass",
+ "eq_type_high_pass": "High pass",
+ "eq_frequency": "Frequency",
+ "eq_gain": "Gain",
+ "eq_q": "Q",
+ "eq_unit_hz": "Hz",
+ "eq_unit_db": "dB",
+ "eq_db_value": "%s dB",
+ "eq_auto_preamp": "Auto preamp",
+ "eq_auto_preamp_desc": "Turns the sound down by the curve's highest boost so it never clips",
+ "eq_preamp": "Preamp",
+ "eq_bass_boost": "Bass boost",
+ "eq_unavailable": "The equalizer is not available on this system",
+ "eq_unavailable_desc": "Audio processing could not start here, so music plays without the equalizer.",
+ "eq_import_title": "Import and export",
+ "eq_import": "Import",
+ "eq_import_paste": "Paste an AutoEQ or EqualizerAPO ParametricEQ.txt here",
+ "eq_import_file": "Choose file",
+ "eq_import_default_name": "Imported",
+ "eq_import_summary": "%s filters, preamp %s dB",
+ "eq_import_skipped": "%s filters left out: switched off, unsupported or invalid",
+ "eq_import_ignored": "%s other lines ignored",
+ "eq_import_truncated": "%s filters past the first 20 left out",
+ "eq_import_shelf_q": "Shelves play with the standard 0.707 slope here, so shelves with another Q sound slightly different",
+ "eq_import_invalid": "No filters or preamp found in this text",
+ "eq_import_read_failed": "Could not read that file",
+ "eq_import_done": "Imported %s",
+ "eq_export": "Export the current curve",
+ "eq_export_file": "Save to file",
+ "eq_export_copy": "Copy",
+ "eq_export_saved": "Equalizer saved",
+ "eq_export_copied": "Equalizer copied",
+ "eq_export_failed": "Could not export the equalizer",
"music_expand": "Expand",
"music_collapse": "Collapse",
"music_now_playing": "Now playing",
diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts
index c46d34af..9a25f9ea 100644
--- a/src/lib/settings/schema.ts
+++ b/src/lib/settings/schema.ts
@@ -108,6 +108,7 @@ export const SETTINGS = {
PREFERRED_AUDIO_LANGUAGE: "preferred_audio_language",
MUSIC_LOUDNESS_NORMALIZATION: "music_loudness_normalization",
+ EQ_STATE_JSON: "eq_state_json",
BUFFER_PROFILE: "buffer_profile",
MIN_BUFFER_MS: "min_buffer_ms",
@@ -317,6 +318,8 @@ export const SETTING_DEFINITIONS = [
str(SETTINGS.PREFERRED_AUDIO_LANGUAGE, "quality", "original", "persisted-only"),
bool(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION, "player", true, "wired"),
+ // Flow for Android's equalizer JSON, shared by music and video and synced as `equalizer`.
+ json(SETTINGS.EQ_STATE_JSON, "player", "{}", "wired"),
str(SETTINGS.BUFFER_PROFILE, "network", "STABLE", "wired", ["AGGRESSIVE", "STABLE", "DATASAVER", "CUSTOM"]),
num(SETTINGS.MIN_BUFFER_MS, "network", 30000, "wired", 5000, 120000),
diff --git a/src/lib/useElementWidth.ts b/src/lib/useElementWidth.ts
new file mode 100644
index 00000000..b84b86d4
--- /dev/null
+++ b/src/lib/useElementWidth.ts
@@ -0,0 +1,19 @@
+import { useLayoutEffect, useState, type RefObject } from "react";
+
+/** The element's content width in px, followed as it resizes; `fallback` until measured. */
+export function useElementWidth(ref: RefObject, fallback: number): number {
+ const [width, setWidth] = useState(fallback);
+
+ useLayoutEffect(() => {
+ const element = ref.current;
+ if (!element || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(([entry]) => {
+ const next = Math.round(entry?.contentRect.width ?? 0);
+ if (next > 0) setWidth((previous) => (previous === next ? previous : next));
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [ref]);
+
+ return width;
+}
diff --git a/src/store/useEqStore.test.ts b/src/store/useEqStore.test.ts
new file mode 100644
index 00000000..4f131dfe
--- /dev/null
+++ b/src/store/useEqStore.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { setSetting } = vi.hoisted(() => ({ setSetting: vi.fn(async (_key: string, _value: string) => {}) }));
+vi.mock("../lib/api/db", () => ({ getSetting: vi.fn(async () => null), setSetting }));
+
+import { SETTINGS } from "../lib/settings/schema";
+import { GraphicEq } from "../lib/audio/eq/limits";
+import { activeCopy, selectPreset, withBassBoost } from "../lib/audio/eq/stateOps";
+import { decodeEqState, encodeEqState } from "../lib/audio/eq/stateJson";
+import { defaultEqState } from "../lib/audio/eq/stateOps";
+
+// Each test gets fresh modules: the store reads the old config once, when it is first imported.
+async function load(legacy?: object) {
+ vi.resetModules();
+ localStorage.clear();
+ if (legacy) localStorage.setItem("flow_music_player", JSON.stringify(legacy));
+ const settings = await import("./useAppSettingsStore");
+ const eq = await import("./useEqStore");
+ eq.bindEqPersistence();
+ const settle = (raw: string) =>
+ settings.useAppSettingsStore.setState((s) => ({
+ loaded: true,
+ values: { ...s.values, [SETTINGS.EQ_STATE_JSON]: raw },
+ }));
+ return { eq: eq.useEqStore, settings: settings.useAppSettingsStore, settle };
+}
+
+const saved = () => setSetting.mock.calls.filter(([key]) => key === SETTINGS.EQ_STATE_JSON).map(([, value]) => value);
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ setSetting.mockClear();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("useEqStore", () => {
+ it("moves the old desktop gains into the graphic curve once, and saves them", async () => {
+ const { eq, settle } = await load({ volume: 0.7, eqEnabled: true, eqGains: [6, 5, 4, 2, 0, 0, 0, 0, 1, 2] });
+ settle("{}");
+
+ const state = eq.getState().state;
+ expect(state.mode).toBe("GRAPHIC");
+ expect(GraphicEq.gainsOf(state.graphic.curve)).toEqual([6, 5, 4, 2, 0, 0, 0, 0, 1, 2]);
+ await vi.advanceTimersByTimeAsync(400);
+ expect(saved()).toHaveLength(1);
+ expect(decodeEqState(saved()[0]!)!.mode).toBe("GRAPHIC");
+ });
+
+ it("saves a burst of edits once, 400 ms after the last", async () => {
+ const { eq, settle } = await load();
+ settle("{}");
+ for (let i = 1; i <= 5; i++) eq.getState().update((s) => withBassBoost(s, i));
+
+ await vi.advanceTimersByTimeAsync(300);
+ expect(saved()).toHaveLength(0);
+ await vi.advanceTimersByTimeAsync(100);
+ expect(saved()).toHaveLength(1);
+ expect(decodeEqState(saved()[0]!)!.bassBoost).toBe(5);
+ });
+
+ it("keeps drag previews and compare out of the saved state", async () => {
+ const { eq, settle } = await load();
+ settle("{}");
+ eq.getState().setPreview({ preamp: 0, bands: [{ frequency: 1_000, gain: 6, q: 1, type: "PEAK", enabled: true }] });
+ eq.getState().setBypass(true);
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(saved()).toHaveLength(0);
+ });
+
+ it("adopts a synced or restored value without writing it back", async () => {
+ const { eq, settle } = await load();
+ settle("{}");
+ const incoming = encodeEqState(selectPreset(defaultEqState(), "builtin:jazz"));
+ settle(incoming);
+
+ expect(activeCopy(eq.getState().state).presetId).toBe("builtin:jazz");
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(saved()).toHaveLength(0);
+ });
+
+ it("keeps an edit made before the saved state loaded", async () => {
+ const { eq, settle } = await load();
+ eq.getState().update((s) => ({ ...s, enabled: false }));
+ settle(encodeEqState(selectPreset(defaultEqState(), "builtin:rock")));
+
+ const state = eq.getState().state;
+ expect(state.enabled).toBe(false);
+ expect(activeCopy(state).presetId).toBe("builtin:rock");
+ await vi.advanceTimersByTimeAsync(400);
+ expect(decodeEqState(saved()[0]!)!.enabled).toBe(false);
+ });
+});
diff --git a/src/store/useEqStore.ts b/src/store/useEqStore.ts
new file mode 100644
index 00000000..74c33d9b
--- /dev/null
+++ b/src/store/useEqStore.ts
@@ -0,0 +1,118 @@
+import { create } from "zustand";
+
+import { migrateLegacyEq, readLegacyEqConfig } from "../lib/audio/eq/legacyMigration";
+import type { EqCurve, EqMode, EqState, EqWorkingCopy } from "../lib/audio/eq/models";
+import { decodeEqState, encodeEqState } from "../lib/audio/eq/stateJson";
+import { defaultEqState, sanitizeState, statesEqual } from "../lib/audio/eq/stateOps";
+import { sanitizeCurve } from "../lib/audio/eq/limits";
+import { SETTINGS } from "../lib/settings/schema";
+import { setSettingValue, useAppSettingsStore } from "./useAppSettingsStore";
+
+/** The stored value before anything was saved: the schema default. */
+const UNSAVED = "{}";
+const SAVE_DEBOUNCE_MS = 400;
+
+interface EqStoreState {
+ state: EqState;
+ loaded: boolean;
+ /** Plays in place of the active curve while a point is dragged; never saved. */
+ preview: EqCurve | null;
+ /** Held Compare: the players hear the unprocessed sound. Never saved. */
+ bypass: boolean;
+ editorOpen: boolean;
+ /**
+ * The unsaved curve each mode had before a preset replaced it, so it stays one click away as
+ * Custom. Kept for the session: Android's saved state has no field for it.
+ */
+ custom: Partial>;
+
+ /** Applies `transform`; an edit made before the saved state loads is replayed on top of it. */
+ update: (transform: (state: EqState) => EqState) => void;
+ setCustom: (mode: EqMode, copy: EqWorkingCopy | undefined) => void;
+ setPreview: (curve: EqCurve | null) => void;
+ setBypass: (bypass: boolean) => void;
+ setEditorOpen: (open: boolean) => void;
+}
+
+// The old desktop equalizer's gains, read before the music player rewrites its saved config.
+const legacy = readLegacyEqConfig();
+
+let earlyEdits: ((state: EqState) => EqState)[] = [];
+let saveTimer: ReturnType | null = null;
+// The value last written or adopted, so our own save is not read back as an outside change.
+let lastJson: string | null = null;
+
+function scheduleSave(): void {
+ if (saveTimer !== null) clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => {
+ saveTimer = null;
+ const json = encodeEqState(useEqStore.getState().state);
+ if (json === lastJson) return;
+ lastJson = json;
+ void setSettingValue(SETTINGS.EQ_STATE_JSON, json);
+ }, SAVE_DEBOUNCE_MS);
+}
+
+export const useEqStore = create((set, get) => ({
+ state: defaultEqState(),
+ loaded: false,
+ preview: null,
+ bypass: false,
+ editorOpen: false,
+ custom: {},
+
+ update: (transform) => {
+ const before = get().state;
+ const after = sanitizeState(transform(before));
+ if (!get().loaded) {
+ earlyEdits.push(transform);
+ set({ state: after });
+ return;
+ }
+ if (statesEqual(after, before)) return;
+ set({ state: after, preview: null });
+ scheduleSave();
+ },
+
+ setCustom: (mode, copy) => set((s) => ({ custom: { ...s.custom, [mode]: copy } })),
+ setPreview: (curve) => set({ preview: curve ? sanitizeCurve(curve) : null }),
+ setBypass: (bypass) => set({ bypass }),
+ setEditorOpen: (editorOpen) => set({ editorOpen }),
+}));
+
+/** Adopts the saved value, or the first-run migration of the old equalizer. */
+function adopt(raw: string): void {
+ if (raw === lastJson) return;
+ const store = useEqStore.getState();
+ let restored: EqState;
+ let save = false;
+ if (raw === UNSAVED && !store.loaded) {
+ const migrated = migrateLegacyEq(legacy);
+ restored = migrated ?? defaultEqState();
+ save = migrated !== null;
+ } else {
+ // A value either app wrote; one that cannot be read leaves the current state alone.
+ const decoded = decodeEqState(raw);
+ if (!decoded) return;
+ restored = decoded;
+ }
+ lastJson = raw;
+ const edits = store.loaded ? [] : earlyEdits;
+ earlyEdits = [];
+ const state = edits.reduce((s, edit) => sanitizeState(edit(s)), restored);
+ useEqStore.setState({ state, loaded: true, preview: null });
+ if (save || edits.length > 0) scheduleSave();
+}
+
+/** Loads the equalizer once settings have loaded, and follows restores and syncs after that. */
+export function bindEqPersistence(): void {
+ const follow = () => {
+ const settings = useAppSettingsStore.getState();
+ if (!settings.loaded) return;
+ adopt(settings.values[SETTINGS.EQ_STATE_JSON] ?? UNSAVED);
+ };
+ follow();
+ useAppSettingsStore.subscribe((settings, previous) => {
+ if (settings.loaded !== previous.loaded || settings.values !== previous.values) follow();
+ });
+}
diff --git a/src/store/useMusicPlayerStore.ts b/src/store/useMusicPlayerStore.ts
index 9bd1754b..4de40796 100644
--- a/src/store/useMusicPlayerStore.ts
+++ b/src/store/useMusicPlayerStore.ts
@@ -30,12 +30,6 @@ import {
import { radioSession } from "../lib/musicRadioSession";
import { POOL_LOW_WATER } from "../lib/musicRadioPlanner";
import { SETTINGS } from "../lib/settings/schema";
-import {
- EQ_FLAT,
- EQ_PRESETS,
- normalizeEqGains,
- type EqPresetName,
-} from "../lib/audio/eqBands";
import { getSettingValue } from "./useAppSettingsStore";
import { shouldSkipUnplayable } from "../lib/musicSkip";
import { MEDIA_FAILURE_KIND, RecoveryLedger, classifyMediaFailure } from "../lib/musicRecoveryPolicy";
@@ -54,8 +48,6 @@ const PERSIST_KEY = "flow_music_player";
interface PersistedMusicConfig {
volume: number;
isMuted: boolean;
- eqEnabled: boolean;
- eqGains: number[];
repeatMode: MusicRepeatMode;
isShuffle: boolean;
radioEnabled: boolean;
@@ -64,8 +56,6 @@ interface PersistedMusicConfig {
const DEFAULT_CONFIG: PersistedMusicConfig = {
volume: 1,
isMuted: false,
- eqEnabled: false,
- eqGains: [...EQ_FLAT],
repeatMode: "none",
isShuffle: false,
radioEnabled: true,
@@ -80,7 +70,6 @@ const loadConfig = (): PersistedMusicConfig => {
...DEFAULT_CONFIG,
...parsed,
volume: clamp01(parsed.volume ?? DEFAULT_CONFIG.volume),
- eqGains: normalizeEqGains(parsed.eqGains),
};
} catch (error) {
console.warn("Failed to load saved music player config", error);
@@ -96,8 +85,6 @@ const saveConfig = (get: () => MusicPlayerState) => {
const config: PersistedMusicConfig = {
volume: s.volume,
isMuted: s.isMuted,
- eqEnabled: s.eqEnabled,
- eqGains: s.eqGains,
repeatMode: s.repeatMode,
isShuffle: s.isShuffle,
radioEnabled: s.radioEnabled,
@@ -226,8 +213,6 @@ interface MusicPlayerState {
isMuted: boolean;
loudnessDb: number | null;
normalizationEnabled: boolean;
- eqEnabled: boolean;
- eqGains: number[]; // 10 bands, dB
// --- modes (persisted) ---
repeatMode: MusicRepeatMode;
@@ -292,10 +277,6 @@ interface MusicPlayerState {
openOverlay: (view?: Exclude) => void;
closeOverlay: () => void;
- setEqEnabled: (enabled: boolean) => void;
- setEqBand: (index: number, gainDb: number) => void;
- setEqGains: (gains: number[]) => void;
- applyEqPreset: (name: EqPresetName) => void;
/** Follows the saved music normalization setting (see `bindMusicAudioSettings`). */
setNormalizationEnabled: (enabled: boolean) => void;
@@ -330,8 +311,6 @@ export const useMusicPlayerStore = create((set, get) => ({
isMuted: initialConfig.isMuted,
loudnessDb: null,
normalizationEnabled: getSettingValue(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION) === "true",
- eqEnabled: initialConfig.eqEnabled,
- eqGains: initialConfig.eqGains,
repeatMode: initialConfig.repeatMode,
isShuffle: initialConfig.isShuffle,
@@ -847,32 +826,6 @@ export const useMusicPlayerStore = create((set, get) => ({
openOverlay: (view = "full") => set({ viewState: view }),
closeOverlay: () => set({ viewState: "dock" }),
- setEqEnabled: (enabled) => {
- musicAudioEngine.setEqEnabled(enabled);
- set({ eqEnabled: enabled });
- saveConfig(get);
- },
-
- setEqBand: (index, gainDb) => {
- const next = [...get().eqGains];
- next[index] = gainDb;
- const gains = normalizeEqGains(next);
- musicAudioEngine.setEqGains(gains);
- set({ eqGains: gains });
- saveConfig(get);
- },
-
- setEqGains: (gains) => {
- const normalized = normalizeEqGains(gains);
- musicAudioEngine.setEqGains(normalized);
- set({ eqGains: normalized });
- saveConfig(get);
- },
-
- applyEqPreset: (name) => {
- get().setEqGains([...(EQ_PRESETS[name] ?? EQ_FLAT)]);
- },
-
setNormalizationEnabled: (enabled) => {
musicAudioEngine.setLoudness(get().loudnessDb, enabled);
set({ normalizationEnabled: enabled });