graphic eq + cleanup

This commit is contained in:
Boof2015
2026-07-02 23:12:58 -04:00
parent 0003c7d778
commit 9ce825b673
474 changed files with 765 additions and 187 deletions
+142 -33
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { EQBand, EQPreset } from '@/types/audio';
import type { EQBand, EQMode, EQPreset } from '@/types/audio';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import {
@@ -13,6 +13,12 @@ import {
flattenBandsForNative,
} from '@/audio/eq';
import { createBuiltInPresets, createDefaultBands, FLAT_PRESET_ID, genEqId } from '@/audio/eqPresets';
import {
buildGraphicBands,
createFlatGraphicGains,
deriveGraphicGains,
parseGraphicGains,
} from '@/audio/graphicEq';
import { setEqBandsNative, setEqEnabledNative, setEqPreampNative } from '@/audio/eqNative';
/**
@@ -26,9 +32,20 @@ const PREAMP_KEY = 'eq_preamp';
const BANDS_KEY = 'eq_bands';
const ACTIVE_PRESET_KEY = 'eq_active_preset';
const CUSTOM_PRESETS_KEY = 'eq_custom_presets';
const MODE_KEY = 'eq_mode';
const GRAPHIC_GAINS_KEY = 'eq_graphic_gains';
const PERSIST_DEBOUNCE_MS = 250;
function safeJsonParse(json: string | null): unknown {
if (!json) return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}
function parseBands(json: string | null): EQBand[] | null {
if (!json) return null;
try {
@@ -46,17 +63,26 @@ function parseCustomPresets(json: string | null): EQPreset[] {
const arr = JSON.parse(json);
if (!Array.isArray(arr)) return [];
return arr
.filter((p): p is { id?: string; name: string; preamp?: number; bands?: unknown[] } => !!p && typeof p.name === 'string')
.map((p) => ({
// Keep the stored id so a persisted activePresetId still matches on reload.
id: typeof p.id === 'string' && p.id.length > 0 ? p.id : genEqId(),
name: p.name,
preamp: clampPreamp(typeof p.preamp === 'number' ? p.preamp : 0),
bands: Array.isArray(p.bands)
? p.bands.slice(0, EQ_MAX_BANDS).map((b) => createNormalizedEQBand(b as object, genEqId()))
: createDefaultBands(),
isCustom: true,
}));
.filter(
(p): p is { id?: string; name: string; preamp?: number; bands?: unknown[]; mode?: unknown; graphicGains?: unknown } =>
!!p && typeof p.name === 'string'
)
.map((p) => {
// Invalid/missing graphic gains degrade the preset to parametric — the
// compiled bands snapshot below sounds identical.
const graphicGains = p.mode === 'graphic' ? parseGraphicGains(p.graphicGains) : null;
return {
// Keep the stored id so a persisted activePresetId still matches on reload.
id: typeof p.id === 'string' && p.id.length > 0 ? p.id : genEqId(),
name: p.name,
preamp: clampPreamp(typeof p.preamp === 'number' ? p.preamp : 0),
bands: Array.isArray(p.bands)
? p.bands.slice(0, EQ_MAX_BANDS).map((b) => createNormalizedEQBand(b as object, genEqId()))
: createDefaultBands(),
isCustom: true,
...(graphicGains ? { mode: 'graphic' as const, graphicGains } : {}),
};
});
} catch {
return [];
}
@@ -66,6 +92,8 @@ interface EQStore {
enabled: boolean;
preamp: number; // dB
bands: EQBand[];
mode: EQMode; // which editor drives the native bands (preamp/enabled are shared)
graphicGains: number[]; // graphic-mode slider gains (dB), independent of `bands`
presets: EQPreset[]; // built-in + custom
activePresetId: string | null; // null = manually edited ("Custom")
activeBandId: string | null; // UI selection shared by curve / strip / panel
@@ -75,6 +103,8 @@ interface EQStore {
setEnabled: (enabled: boolean) => void;
toggleEnabled: () => void;
setPreamp: (db: number) => void;
setMode: (mode: EQMode) => void;
setGraphicGain: (index: number, gainDb: number) => void;
addBand: (band?: Partial<EQBand>) => void;
removeBand: (id: string) => void;
updateBand: (id: string, updates: Partial<EQBand>) => void;
@@ -92,10 +122,11 @@ let persistTimer: ReturnType<typeof setTimeout> | null = null;
export const useEQStore = create<EQStore>((set, get) => {
function syncToNative(): void {
const { enabled, preamp, bands } = get();
const { enabled, preamp, bands, mode, graphicGains } = get();
const activeBands = mode === 'graphic' ? buildGraphicBands(graphicGains) : bands;
setEqEnabledNative(enabled);
setEqPreampNative(enabled ? dbToLinear(preamp) : 1);
setEqBandsNative(flattenBandsForNative(bands));
setEqBandsNative(flattenBandsForNative(activeBands));
}
function schedulePersist(): void {
@@ -107,7 +138,7 @@ export const useEQStore = create<EQStore>((set, get) => {
}
async function persistNow(): Promise<void> {
const { enabled, preamp, bands, activePresetId, presets } = get();
const { enabled, preamp, bands, mode, graphicGains, activePresetId, presets } = get();
const custom = presets.filter((p) => p.isCustom);
try {
const db = await openLibraryDb();
@@ -115,12 +146,20 @@ export const useEQStore = create<EQStore>((set, get) => {
setSetting(db, ENABLED_KEY, enabled ? 'true' : 'false'),
setSetting(db, PREAMP_KEY, String(preamp)),
setSetting(db, BANDS_KEY, JSON.stringify(bands)),
setSetting(db, MODE_KEY, mode),
setSetting(db, GRAPHIC_GAINS_KEY, JSON.stringify(graphicGains)),
setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''),
setSetting(
db,
CUSTOM_PRESETS_KEY,
JSON.stringify(
custom.map((p) => ({ id: p.id, name: p.name, preamp: p.preamp, bands: p.bands }))
custom.map((p) => ({
id: p.id,
name: p.name,
preamp: p.preamp,
bands: p.bands,
...(p.mode === 'graphic' ? { mode: p.mode, graphicGains: p.graphicGains } : {}),
}))
)
),
]);
@@ -140,6 +179,8 @@ export const useEQStore = create<EQStore>((set, get) => {
enabled: false,
preamp: 0,
bands: createDefaultBands(),
mode: 'parametric',
graphicGains: createFlatGraphicGains(),
presets: createBuiltInPresets(),
activePresetId: FLAT_PRESET_ID,
activeBandId: null,
@@ -148,10 +189,12 @@ export const useEQStore = create<EQStore>((set, get) => {
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [enabledRaw, preampRaw, bandsRaw, activeRaw, customRaw] = await Promise.all([
const [enabledRaw, preampRaw, bandsRaw, modeRaw, gainsRaw, activeRaw, customRaw] = await Promise.all([
getSetting(db, ENABLED_KEY),
getSetting(db, PREAMP_KEY),
getSetting(db, BANDS_KEY),
getSetting(db, MODE_KEY),
getSetting(db, GRAPHIC_GAINS_KEY),
getSetting(db, ACTIVE_PRESET_KEY),
getSetting(db, CUSTOM_PRESETS_KEY),
]);
@@ -164,6 +207,9 @@ export const useEQStore = create<EQStore>((set, get) => {
enabled: enabledRaw === 'true',
preamp: clampPreamp(Number(preampRaw) || 0),
bands,
// Missing key (pre-graphic installs) → parametric.
mode: modeRaw === 'graphic' ? 'graphic' : 'parametric',
graphicGains: parseGraphicGains(safeJsonParse(gainsRaw)) ?? createFlatGraphicGains(),
presets,
// Stored active preset ids are regenerated on load (parseCustomPresets makes
// new ids), so only built-in ids survive a reload; fall back to "Custom".
@@ -188,6 +234,23 @@ export const useEQStore = create<EQStore>((set, get) => {
setPreamp: (db) => markEdited({ preamp: clampPreamp(db) }),
setMode: (mode) => {
if (get().mode === mode) return;
// Non-destructive: both modes keep their own band state; only the preset
// label stops describing what's audible.
set({ mode, activePresetId: null });
syncToNative();
schedulePersist();
},
setGraphicGain: (index, gainDb) => {
const gains = get().graphicGains;
if (index < 0 || index >= gains.length) return;
const next = gains.slice();
next[index] = clampEQGain(gainDb);
markEdited({ graphicGains: next });
},
addBand: (partial) => {
const { bands } = get();
if (bands.length >= EQ_MAX_BANDS) return;
@@ -234,28 +297,74 @@ export const useEQStore = create<EQStore>((set, get) => {
applyPreset: (presetId) => {
const preset = get().presets.find((p) => p.id === presetId);
if (!preset) return;
const bands = preset.bands.map((b) => createNormalizedEQBand(b, genEqId()));
set({
bands,
preamp: clampPreamp(preset.preamp),
activePresetId: presetId,
activeBandId: bands[0]?.id ?? null,
});
// Custom presets re-open in the mode they were saved in (each branch leaves
// the other mode's band state untouched); built-ins are mode-agnostic and
// apply in whichever mode is active.
const graphicGains = preset.mode === 'graphic' ? parseGraphicGains(preset.graphicGains) : null;
if (graphicGains) {
set({
mode: 'graphic',
graphicGains,
preamp: clampPreamp(preset.preamp),
activePresetId: presetId,
});
} else if (!preset.isCustom && get().mode === 'graphic') {
// Project the parametric built-in onto the 5 sliders (its response
// sampled at each band frequency) instead of yanking the user out of
// graphic mode.
set({
graphicGains: deriveGraphicGains(preset.bands),
preamp: clampPreamp(preset.preamp),
activePresetId: presetId,
});
} else {
const bands = preset.bands.map((b) => createNormalizedEQBand(b, genEqId()));
set({
mode: 'parametric',
bands,
preamp: clampPreamp(preset.preamp),
activePresetId: presetId,
activeBandId: bands[0]?.id ?? null,
});
}
syncToNative();
schedulePersist();
},
resetToFlat: () => get().applyPreset(FLAT_PRESET_ID),
resetToFlat: () => {
// In graphic mode, flatten in place — applying the parametric Flat preset
// would silently switch modes.
if (get().mode === 'graphic') {
set({ graphicGains: createFlatGraphicGains(), preamp: 0, activePresetId: null });
syncToNative();
schedulePersist();
return;
}
get().applyPreset(FLAT_PRESET_ID);
},
saveCustomPreset: (name) => {
const { bands, preamp, presets } = get();
const preset: EQPreset = {
id: genEqId(),
name: name.trim() || 'Custom Preset',
preamp,
bands: bands.map((b) => ({ ...b, id: genEqId() })),
isCustom: true,
};
const { bands, mode, graphicGains, preamp, presets } = get();
const preset: EQPreset =
mode === 'graphic'
? {
id: genEqId(),
name: name.trim() || 'Custom Preset',
preamp,
// Compiled snapshot so builds without graphic support (or corrupt
// gains) still load an identical parametric preset.
bands: buildGraphicBands(graphicGains).map((b) => ({ ...b, id: genEqId() })),
mode: 'graphic',
graphicGains: [...graphicGains],
isCustom: true,
}
: {
id: genEqId(),
name: name.trim() || 'Custom Preset',
preamp,
bands: bands.map((b) => ({ ...b, id: genEqId() })),
isCustom: true,
};
set({ presets: [...presets, preset], activePresetId: preset.id });
schedulePersist();
},