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