mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
graphic eq + cleanup
This commit is contained in:
+142
-33
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user