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
+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)}`;