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
+50 -7
View File
@@ -12,7 +12,9 @@ import { BandStrip } from '@/components/eq/BandStrip';
import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel';
import { EQSlider } from '@/components/eq/EQSlider';
import { EqSheet, EqSheetItem } from '@/components/eq/EqSheet';
import { EQModeSwitcher } from '@/components/eq/EQModeSwitcher';
import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel';
import { PresetSheet } from '@/components/eq/PresetSheet';
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
import { colors, radius, spacing } from '@/theme';
@@ -68,6 +70,7 @@ export default function EQScreen() {
}, [])
);
const isGraphic = eq.mode === 'graphic';
const activeBand = eq.bands.find((b) => b.id === eq.activeBandId) ?? null;
const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1;
const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom';
@@ -100,6 +103,12 @@ export default function EQScreen() {
</Pressable>
);
const modeSwitcherEl = (
<View style={[styles.modeSwitcherWrap, isWide && styles.sideItem]}>
<EQModeSwitcher value={eq.mode} onChange={eq.setMode} />
</View>
);
const graphEl = (
<EQGraph
bands={eq.bands}
@@ -111,6 +120,14 @@ export default function EQScreen() {
/>
);
// Graphic editor card — the panel draws its response curve behind the sliders
// in the tracks' own coordinate space, so it stays glued to the thumbs.
const graphicEditorEl = (
<View style={styles.graphicEditor}>
<GraphicEQPanel gains={eq.graphicGains} enabled={eq.enabled} onChangeGain={eq.setGraphicGain} />
</View>
);
const stripEl = (
<BandStrip
bands={eq.bands}
@@ -185,21 +202,33 @@ export default function EQScreen() {
{ paddingLeft: spacing.lg + insets.left, paddingRight: spacing.lg + insets.right },
]}
>
<View style={styles.wideGraphPane}>{graphEl}</View>
<View style={styles.wideGraphPane}>{isGraphic ? graphicEditorEl : graphEl}</View>
<View style={{ width: sidePaneWidth }}>
{modeSwitcherEl}
{presetRowEl}
{stripEl}
<View style={styles.sideDetail}>{detailEl}</View>
{isGraphic ? null : (
<>
{stripEl}
<View style={styles.sideDetail}>{detailEl}</View>
</>
)}
<View style={styles.wideSpacer} />
{bottomBarEl}
</View>
</View>
) : (
<>
{modeSwitcherEl}
{presetRowEl}
<View style={styles.graphWrap}>{graphEl}</View>
<View style={styles.section}>{stripEl}</View>
<View style={styles.section}>{detailEl}</View>
{isGraphic ? (
<View style={styles.graphWrap}>{graphicEditorEl}</View>
) : (
<>
<View style={styles.graphWrap}>{graphEl}</View>
<View style={styles.section}>{stripEl}</View>
<View style={styles.section}>{detailEl}</View>
</>
)}
{bottomBarEl}
</>
)}
@@ -226,7 +255,7 @@ export default function EQScreen() {
{sheet === 'overflow' ? (
<EqSheet onClose={closeSheet}>
<EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} />
{eq.bands.length > 1 && activeBand ? (
{!isGraphic && eq.bands.length > 1 && activeBand ? (
<EqSheetItem
label={`Remove band ${activeBandNumber}`}
icon="remove-circle-outline"
@@ -377,6 +406,20 @@ const styles = StyleSheet.create({
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
modeSwitcherWrap: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
},
graphicEditor: {
flex: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.md,
},
presetRow: {
flexDirection: 'row',
alignItems: 'center',
+73
View File
@@ -0,0 +1,73 @@
// Graphic EQ mode — a fixed 5-band beginner front-end over the parametric engine.
// Only gains are user-editable; frequencies/types/Q are locked and compiled into
// regular EQBands for the same native bridge (no DSP changes).
import type { EQBand, EQBandType } from '@/types/audio';
import { clampEQGain, computeCombinedEQMagnitude } from './eq';
// Matches the graph's display sample rate (eqGraphMath GRAPH_SAMPLE_RATE).
const DERIVE_SAMPLE_RATE = 48000;
export interface GraphicBandDef {
key: string;
label: string;
frequency: number;
type: EQBandType;
Q: number;
}
// Same layout as DEFAULT_BAND_SEEDS (shelves at the extremes, peaking between).
// Peaking Q 0.8 instead of the parametric default 1.0: bands sit ~2 octaves apart
// and the wider skirts let adjacent boosted sliders sum smoothly instead of
// leaving a valley between them. Shelf Q 0.707 matches every built-in preset.
export const GRAPHIC_BANDS: readonly GraphicBandDef[] = [
{ key: 'bass', label: 'Bass', frequency: 60, type: 'lowshelf', Q: 0.707 },
{ key: 'low-mid', label: 'Low Mid', frequency: 250, type: 'peaking', Q: 0.8 },
{ key: 'mid', label: 'Mid', frequency: 1000, type: 'peaking', Q: 0.8 },
{ key: 'vocals', label: 'Vocals', frequency: 4000, type: 'peaking', Q: 0.8 },
{ key: 'treble', label: 'Treble', frequency: 12000, type: 'highshelf', Q: 0.707 },
];
export const GRAPHIC_BAND_COUNT = GRAPHIC_BANDS.length;
export function createFlatGraphicGains(): number[] {
return GRAPHIC_BANDS.map(() => 0);
}
/** Compile slider gains into EQBands (stable ids — never mixed into the parametric set). */
export function buildGraphicBands(gains: readonly number[]): EQBand[] {
return GRAPHIC_BANDS.map((def, i) => ({
id: `graphic-${def.key}`,
type: def.type,
frequency: def.frequency,
gain: clampEQGain(Number(gains[i]) || 0),
Q: def.Q,
enabled: true,
}));
}
/**
* Project a parametric band set onto the graphic sliders: the preset's combined
* response sampled at each graphic band frequency (clamped ±12). An
* approximation — lets mode-agnostic built-in presets apply in graphic mode.
*/
export function deriveGraphicGains(bands: readonly EQBand[]): number[] {
return GRAPHIC_BANDS.map((def) =>
clampEQGain(computeCombinedEQMagnitude(bands, def.frequency, DERIVE_SAMPLE_RATE))
);
}
/**
* Strict gains validation — exactly GRAPHIC_BAND_COUNT finite numbers or null.
* The single guard for corrupt/wrong-length graphic data in settings or presets;
* callers fall back to flat gains (store) or parametric bands (presets).
*/
export function parseGraphicGains(value: unknown): number[] | null {
if (!Array.isArray(value) || value.length !== GRAPHIC_BAND_COUNT) return null;
const gains: number[] = [];
for (const raw of value) {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return null;
gains.push(clampEQGain(raw));
}
return gains;
}
+67
View File
@@ -0,0 +1,67 @@
import { Pressable, StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import type { EQMode } from '@/types/audio';
const MODES: { key: EQMode; label: string }[] = [
{ key: 'parametric', label: 'Parametric' },
{ key: 'graphic', label: 'Graphic' },
];
/** Two-segment Parametric | Graphic control (ViewModeSwitcher styling, fixed row). */
export function EQModeSwitcher({
value,
onChange,
}: {
value: EQMode;
onChange: (mode: EQMode) => void;
}) {
return (
<View style={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
<Pressable
key={mode.key}
onPress={() => onChange(mode.key)}
style={[styles.pill, active && styles.pillActive]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text variant="label" style={[styles.label, active && styles.labelActive]}>
{mode.label}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: spacing.sm,
},
pill: {
flex: 1,
alignItems: 'center',
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingVertical: spacing.xs + 2,
},
pillActive: {
borderColor: colors.accent,
backgroundColor: 'rgba(56, 189, 248, 0.08)',
},
label: {
color: colors.textSecondary,
},
labelActive: {
color: colors.accent,
},
});
export default EQModeSwitcher;
+101
View File
@@ -0,0 +1,101 @@
import { StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { EQ_MAX_GAIN_DB, EQ_MIN_GAIN_DB } from '@/audio/eq';
import { GRAPHIC_BANDS } from '@/audio/graphicEq';
import { GraphicResponseCurve } from './GraphicResponseCurve';
import { VerticalEQSlider } from './VerticalEQSlider';
import { formatFreqHz, formatGain, gainColor } from './format';
interface GraphicEQPanelProps {
gains: number[];
enabled: boolean;
onChangeGain: (index: number, gainDb: number) => void;
}
/**
* Graphic-mode editor: the response curve behind one vertical gain slider per
* fixed band. Readouts and labels live in their own rows so the curve canvas
* and the slider tracks share the exact same box — thumb centers sit on the
* curve's scale. All cells are gap-less flex:1 so column centers match the
* curve's evenly spaced band positions.
*/
export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelProps) {
return (
<View style={styles.container}>
<View style={styles.metaRow}>
{GRAPHIC_BANDS.map((def, i) => (
<Text
key={def.key}
variant="mono"
style={[styles.value, { color: gainColor(gains[i] ?? 0) }]}
>
{formatGain(gains[i] ?? 0)}
</Text>
))}
</View>
<View style={styles.trackRow}>
<View style={StyleSheet.absoluteFill}>
<GraphicResponseCurve gains={gains} enabled={enabled} />
</View>
{GRAPHIC_BANDS.map((def, i) => (
<VerticalEQSlider
key={def.key}
label={def.label}
value={gains[i] ?? 0}
min={EQ_MIN_GAIN_DB}
max={EQ_MAX_GAIN_DB}
onChange={(v) => onChangeGain(i, v)}
/>
))}
</View>
<View style={styles.metaRow}>
{GRAPHIC_BANDS.map((def) => (
<View key={def.key} style={styles.labelCell}>
<Text variant="label" style={styles.centered}>
{def.label}
</Text>
<Text variant="caption" style={[styles.centered, styles.caption]}>
{formatFreqHz(def.frequency)}
</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
metaRow: {
flexDirection: 'row',
},
trackRow: {
flex: 1,
flexDirection: 'row',
},
// The row padding absorbs the fader cap's half-height overshoot (16px) at
// the ±12 dB extremes.
value: {
flex: 1,
fontSize: 12,
textAlign: 'center',
paddingBottom: spacing.lg,
},
labelCell: {
flex: 1,
paddingTop: spacing.lg,
},
centered: {
textAlign: 'center',
},
caption: {
color: colors.textTertiary,
},
});
export default GraphicEQPanel;
+149
View File
@@ -0,0 +1,149 @@
import { useMemo, useState } from 'react';
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Canvas, DashPathEffect, Group, Path, Skia, type SkPath } from '@shopify/react-native-skia';
import { colors } from '@/theme';
import type { EQBand } from '@/types/audio';
import {
EQ_MAX_FREQUENCY,
EQ_MAX_GAIN_DB,
EQ_MIN_FREQUENCY,
computeCombinedEQMagnitude,
} from '@/audio/eq';
import { GRAPHIC_BANDS, buildGraphicBands } from '@/audio/graphicEq';
import { GRAPH_SAMPLE_RATE, buildResponseFill } from './eqGraphMath';
const SAMPLES = 96;
interface GraphicResponseCurveProps {
gains: number[];
enabled: boolean;
}
/**
* Response curve drawn in the slider row's coordinate space so it tracks the
* thumbs 1:1: band frequencies land on the evenly spaced column centers
* (piecewise-log x between them), and gain spans the full track height with the
* 0 dB midline at the track midline — the same mapping as the thumb centers.
* Rendered behind the sliders; transparent background (the editor card owns
* the chrome).
*/
export function GraphicResponseCurve({ gains, enabled }: GraphicResponseCurveProps) {
const [size, setSize] = useState({ width: 0, height: 0 });
const width = size.width;
const height = size.height;
const bands = useMemo(() => buildGraphicBands(gains), [gains]);
const linePath = useMemo(() => buildAlignedResponsePath(bands, width, height), [bands, width, height]);
const fillPath = useMemo(
() => buildResponseFill(linePath, width, height),
[linePath, width, height]
);
const onLayout = (e: LayoutChangeEvent) => {
const next = { width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height };
setSize((prev) => (prev.width === next.width && prev.height === next.height ? prev : next));
};
const curveColor = enabled ? colors.accent : colors.textTertiary;
return (
<View style={styles.container} onLayout={onLayout} pointerEvents="none">
{width > 0 && height > 0 ? (
<Canvas style={StyleSheet.absoluteFill}>
{/* Grid: ±6 dB lines + dashed 0 dB midline (track coordinates). */}
<Group>
<Path
path={hLine(gainToTrackY(6, height), width)}
color={colors.glassBorder}
style="stroke"
strokeWidth={1}
/>
<Path
path={hLine(gainToTrackY(-6, height), width)}
color={colors.glassBorder}
style="stroke"
strokeWidth={1}
/>
<Path path={hLine(height / 2, width)} color={colors.glassBorder} style="stroke" strokeWidth={1}>
<DashPathEffect intervals={[3, 5]} />
</Path>
</Group>
<Path path={fillPath} color={withAlpha(curveColor, 0.1)} style="fill" />
<Path
path={linePath}
color={curveColor}
style="stroke"
strokeWidth={2}
strokeJoin="round"
strokeCap="round"
/>
</Canvas>
) : null}
</View>
);
}
/** Gain → y across the full track height (thumb-center scale, no graph padding). */
function gainToTrackY(db: number, height: number): number {
const clamped = Math.max(-EQ_MAX_GAIN_DB, Math.min(EQ_MAX_GAIN_DB, db));
return (1 - (clamped + EQ_MAX_GAIN_DB) / (2 * EQ_MAX_GAIN_DB)) * height;
}
/**
* x → frequency with band frequencies pinned to the column centers: log-lerp
* between adjacent bands, extending to the EQ range limits at the edges.
*/
function xToAlignedFreq(x: number, width: number): number {
const n = GRAPHIC_BANDS.length;
const center = (i: number) => ((i + 0.5) / n) * width;
if (x <= center(0)) {
return logLerp(EQ_MIN_FREQUENCY, GRAPHIC_BANDS[0].frequency, x / Math.max(1, center(0)));
}
if (x >= center(n - 1)) {
const t = (x - center(n - 1)) / Math.max(1, width - center(n - 1));
return logLerp(GRAPHIC_BANDS[n - 1].frequency, EQ_MAX_FREQUENCY, t);
}
const i = Math.min(n - 2, Math.max(0, Math.floor((x / width) * n - 0.5)));
const t = (x - center(i)) / Math.max(1, center(i + 1) - center(i));
return logLerp(GRAPHIC_BANDS[i].frequency, GRAPHIC_BANDS[i + 1].frequency, t);
}
function logLerp(a: number, b: number, t: number): number {
return 10 ** (Math.log10(a) + (Math.log10(b) - Math.log10(a)) * Math.max(0, Math.min(1, t)));
}
function buildAlignedResponsePath(bands: readonly EQBand[], width: number, height: number): SkPath {
const path = Skia.Path.Make();
if (width <= 0 || height <= 0) return path;
for (let s = 0; s <= SAMPLES; s++) {
const x = (s / SAMPLES) * width;
const db = computeCombinedEQMagnitude(bands, xToAlignedFreq(x, width), GRAPH_SAMPLE_RATE);
const y = gainToTrackY(db, height);
if (s === 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
return path;
}
function hLine(y: number, width: number): SkPath {
const p = Skia.Path.Make();
p.moveTo(0, y);
p.lineTo(width, y);
return p;
}
function withAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default GraphicResponseCurve;
+9
View File
@@ -14,6 +14,14 @@ interface PresetSheetProps {
onClose: () => void;
}
/**
* Leading row icon telling a custom preset's editor mode apart at a glance.
* Built-ins get no icon — they're mode-agnostic and apply in the active mode.
*/
function modeIcon(preset: EQPreset): 'options-outline' | 'analytics-outline' {
return preset.mode === 'graphic' ? 'options-outline' : 'analytics-outline';
}
/** Preset hub: pick a built-in or custom preset, delete custom ones, or save a new one. */
export function PresetSheet({
presets,
@@ -55,6 +63,7 @@ export function PresetSheet({
<EqSheetItem
key={p.id}
label={p.name}
icon={modeIcon(p)}
selected={p.id === activePresetId}
onPress={() => {
onApply(p.id);
+156
View File
@@ -0,0 +1,156 @@
import { useRef, useState } from 'react';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
} from 'react-native';
import { colors, radius } from '@/theme';
// Vertical fader cap (mixer-style): tall capsule with a horizontal grip line
// marking the exact value position. Half its height overshoots the rail at the
// extremes — the panel's meta-row padding absorbs (almost) all of it.
const PILL_W = 16;
const PILL_H = 32;
const HALO_W = 30;
const HALO_H = 50;
const TRACK_W = 4;
interface VerticalEQSliderProps {
/** Accessibility label ("Bass"); the visible text lives in the panel's rows. */
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
}
const clamp01 = (f: number) => Math.min(1, Math.max(0, f));
/**
* Vertical gain rail for the graphic EQ — EQSlider's gesture pattern rotated:
* anchor the fraction + pageY on grant, then track absolute page deltas so
* drifting off the column can't corrupt the value. The rail spans the full
* measured height and the pill center travels [0, height] — the exact scale
* the GraphicResponseCurve behind it draws in. The bipolar fill runs from the
* 0 dB midline to the handle, colored by sign (indigo boost / amber cut, the
* same code as the readouts).
*/
export function VerticalEQSlider({ label, value, min, max, onChange }: VerticalEQSliderProps) {
const [height, setHeight] = useState(0);
const [active, setActive] = useState(false);
const heightRef = useRef(0);
const grantRef = useRef({ fraction: 0, pageY: 0 });
// fraction 0 = bottom (min), 1 = top (max).
const fraction = clamp01((value - min) / (max - min));
const onLayout = (e: LayoutChangeEvent) => {
heightRef.current = e.nativeEvent.layout.height;
setHeight(e.nativeEvent.layout.height);
};
const handleGrant = (e: GestureResponderEvent) => {
setActive(true);
const f = 1 - clamp01(e.nativeEvent.locationY / Math.max(1, heightRef.current));
grantRef.current = { fraction: f, pageY: e.nativeEvent.pageY };
onChange(min + f * (max - min));
};
const handleMove = (e: GestureResponderEvent) => {
const delta = (grantRef.current.pageY - e.nativeEvent.pageY) / Math.max(1, heightRef.current);
onChange(min + clamp01(grantRef.current.fraction + delta) * (max - min));
};
const centerY = (1 - fraction) * height;
// Bipolar fill: from the 0 dB midline to the pill center.
const fillTop = fraction >= 0.5 ? centerY : height / 2;
const fillHeight = Math.abs(fraction - 0.5) * height;
return (
<View
style={styles.touch}
onLayout={onLayout}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderTerminationRequest={() => false}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={() => setActive(false)}
onResponderTerminate={() => setActive(false)}
accessibilityRole="adjustable"
accessibilityLabel={label}
>
<View style={styles.track}>
<View
style={[
styles.fill,
{
top: fillTop,
height: fillHeight,
backgroundColor: value < 0 ? colors.warning : colors.accent,
},
]}
/>
</View>
{active ? (
<View pointerEvents="none" style={[styles.halo, { top: centerY - HALO_H / 2 }]} />
) : null}
<View
pointerEvents="none"
style={[styles.pill, active && styles.pillActive, { top: centerY - PILL_H / 2 }]}
>
<View style={styles.pillLine} />
</View>
</View>
);
}
const styles = StyleSheet.create({
touch: {
flex: 1,
alignItems: 'center',
},
track: {
flex: 1,
width: TRACK_W,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
overflow: 'hidden',
},
fill: {
position: 'absolute',
width: TRACK_W,
borderRadius: radius.pill,
},
// Soft indigo glow behind the pill while dragging.
halo: {
position: 'absolute',
width: HALO_W,
height: HALO_H,
borderRadius: radius.pill,
backgroundColor: colors.accentGlow,
},
pill: {
position: 'absolute',
width: PILL_W,
height: PILL_H,
borderRadius: radius.pill,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
pillActive: {
transform: [{ scale: 1.08 }],
backgroundColor: colors.accentHover,
},
// The fader's grip line — sits exactly on the value position (pill center).
pillLine: {
width: PILL_W - 6,
height: 2,
borderRadius: 1,
backgroundColor: colors.bgSecondary,
},
});
export default VerticalEQSlider;
+9
View File
@@ -11,6 +11,15 @@ export function formatFreq(hz: number): string {
return `${Math.round(hz)}`;
}
/** Frequency with unit, for the graphic-band captions ("60 Hz", "12 kHz"). */
export function formatFreqHz(hz: number): string {
if (hz >= 1000) {
const k = hz / 1000;
return `${Number.isInteger(k) ? k : k.toFixed(1)} kHz`;
}
return `${Math.round(hz)} Hz`;
}
export function formatGain(db: number): string {
if (Math.abs(db) < 0.05) return '0';
return `${db > 0 ? '+' : ''}${db.toFixed(1)}`;
+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();
},
+9
View File
@@ -67,6 +67,10 @@ export interface EQBand {
enabled: boolean;
}
// EQ editor mode — graphic is a fixed 5-band front-end compiled onto the same
// parametric engine (see src/audio/graphicEq.ts).
export type EQMode = 'parametric' | 'graphic';
// EQ Preset
export interface EQPreset {
id: string;
@@ -74,6 +78,11 @@ export interface EQPreset {
bands: EQBand[];
preamp: number;
isCustom?: boolean;
// Which editor the preset targets; absent = 'parametric' (pre-mode presets).
mode?: EQMode;
// Slider gains (dB) when mode === 'graphic'; `bands` then holds the compiled
// snapshot so older builds / missing gains degrade to an identical parametric preset.
graphicGains?: number[];
}
// Visualizer config (scopes land at M3)