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