mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
m4
This commit is contained in:
+355
-50
@@ -1,76 +1,381 @@
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { readAsStringAsync } from 'expo-file-system/legacy';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQGraph } from '@/components/eq/EQGraph';
|
||||
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 { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
|
||||
import { PresetSheet } from '@/components/eq/PresetSheet';
|
||||
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { setActivePostEqNative } from '@/audio/eqNative';
|
||||
import {
|
||||
EQ_MAX_BANDS,
|
||||
EQ_MAX_FREQUENCY,
|
||||
EQ_MAX_GAIN_DB,
|
||||
EQ_MAX_PREAMP_DB,
|
||||
EQ_MAX_Q,
|
||||
EQ_MIN_FREQUENCY,
|
||||
EQ_MIN_PREAMP_DB,
|
||||
EQ_MIN_Q,
|
||||
isPassEQBandType,
|
||||
} from '@/audio/eq';
|
||||
import { parseAutoEQ } from '@/audio/autoEQParser';
|
||||
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
|
||||
import type { EQBand, EQBandType } from '@/types/audio';
|
||||
|
||||
function formatFreq(hz: number): string {
|
||||
return hz >= 1000 ? `${hz / 1000}k` : `${hz}`;
|
||||
}
|
||||
type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type';
|
||||
|
||||
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
|
||||
|
||||
export default function EQScreen() {
|
||||
const bands = useEQStore((s) => s.bands);
|
||||
const eq = useEQStore();
|
||||
const scopeActive = useScopeActive();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [sheet, setSheet] = useState<SheetKind>('none');
|
||||
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
|
||||
const closeSheet = useCallback(() => setSheet('none'), []);
|
||||
|
||||
// Gate the post-EQ tap to while this screen is visible.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setFocused(true);
|
||||
setActivePostEqNative(true);
|
||||
return () => {
|
||||
setFocused(false);
|
||||
setActivePostEqNative(false);
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
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';
|
||||
const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`;
|
||||
const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null;
|
||||
|
||||
const handleImportAutoEQ = async () => {
|
||||
closeSheet();
|
||||
try {
|
||||
const res = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
||||
if (res.canceled || !res.assets?.[0]) return;
|
||||
const asset = res.assets[0];
|
||||
const content = await readAsStringAsync(asset.uri);
|
||||
const preset = parseAutoEQ(content, asset.name);
|
||||
if (preset.bands.length > 0) eq.importPreset(preset);
|
||||
} catch {
|
||||
/* invalid file — ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Equalizer
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.note}>
|
||||
The band model is in place. The Media3 biquad chain that makes these
|
||||
sliders live arrives in M4.
|
||||
</Text>
|
||||
|
||||
<View style={styles.bands}>
|
||||
{bands.map((band) => (
|
||||
<View key={band.id} style={styles.band}>
|
||||
<View style={styles.track}>
|
||||
<View style={styles.knob} />
|
||||
</View>
|
||||
<Text variant="caption" style={styles.freq}>
|
||||
{formatFreq(band.frequency)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
<Screen padded={false}>
|
||||
<View style={styles.header}>
|
||||
<Text variant="heading">Equalizer</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
|
||||
<Ionicons name="save-outline" size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconButton} onPress={() => setSheet('overflow')} hitSlop={8}>
|
||||
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.presetRow} onPress={() => setSheet('preset')}>
|
||||
<Text variant="body" color={colors.textPrimary}>
|
||||
{presetName}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.graphWrap}>
|
||||
<EQGraph
|
||||
bands={eq.bands}
|
||||
activeBandId={eq.activeBandId}
|
||||
enabled={eq.enabled}
|
||||
spectrumActive={scopeActive && focused}
|
||||
onSelectBand={eq.selectBand}
|
||||
onChangeBand={(id, updates) => eq.updateBand(id, updates)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<BandStrip
|
||||
bands={eq.bands}
|
||||
activeBandId={eq.activeBandId}
|
||||
canAdd={eq.bands.length < EQ_MAX_BANDS}
|
||||
onSelect={eq.selectBand}
|
||||
onAdd={() => eq.addBand()}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<BandDetailPanel
|
||||
band={activeBand}
|
||||
bandNumber={activeBandNumber > 0 ? activeBandNumber : 1}
|
||||
onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)}
|
||||
onEditType={() => setSheet('type')}
|
||||
onEditValue={setEditingValue}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomBar}>
|
||||
<View style={styles.preamp}>
|
||||
<EQSlider
|
||||
label="Preamp"
|
||||
value={eq.preamp}
|
||||
min={EQ_MIN_PREAMP_DB}
|
||||
max={EQ_MAX_PREAMP_DB}
|
||||
format={(v) => `${formatGain(v)} dB`}
|
||||
onChange={eq.setPreamp}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
|
||||
onPress={eq.toggleEnabled}
|
||||
>
|
||||
<Ionicons
|
||||
name="power"
|
||||
size={16}
|
||||
color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}
|
||||
/>
|
||||
<Text variant="label" color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}>
|
||||
{eq.enabled ? 'EQ on' : 'EQ off'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{sheet === 'preset' ? (
|
||||
<PresetSheet
|
||||
presets={eq.presets}
|
||||
activePresetId={eq.activePresetId}
|
||||
onApply={eq.applyPreset}
|
||||
onDelete={eq.deleteCustomPreset}
|
||||
onSaveNew={() => setSheet('save')}
|
||||
onClose={closeSheet}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sheet === 'save' ? (
|
||||
<SavePresetSheet
|
||||
defaultName={defaultPresetName}
|
||||
onSave={(name) => eq.saveCustomPreset(name)}
|
||||
onClose={closeSheet}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sheet === 'overflow' ? (
|
||||
<EqSheet onClose={closeSheet}>
|
||||
<EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} />
|
||||
{eq.bands.length > 1 && activeBand ? (
|
||||
<EqSheetItem
|
||||
label={`Remove band ${activeBandNumber}`}
|
||||
icon="remove-circle-outline"
|
||||
onPress={() => {
|
||||
eq.removeBand(activeBand.id);
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<EqSheetItem
|
||||
label="Reset to Flat"
|
||||
icon="refresh-outline"
|
||||
destructive
|
||||
onPress={() => {
|
||||
eq.resetToFlat();
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
</EqSheet>
|
||||
) : null}
|
||||
|
||||
{sheet === 'type' && activeBand ? (
|
||||
<EqSheet onClose={closeSheet}>
|
||||
<Text variant="heading" style={styles.sheetTitle}>
|
||||
Filter type
|
||||
</Text>
|
||||
{BAND_TYPES.map((type) => (
|
||||
<EqSheetItem
|
||||
key={type}
|
||||
label={BAND_TYPE_LABEL[type]}
|
||||
selected={type === activeBand.type}
|
||||
onPress={() => {
|
||||
eq.updateBand(activeBand.id, { type });
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</EqSheet>
|
||||
) : null}
|
||||
|
||||
{valueEditConfig && activeBand && editingValue ? (
|
||||
<EQValueEditSheet
|
||||
title={valueEditConfig.title}
|
||||
initialValue={valueEditConfig.initialValue}
|
||||
unit={valueEditConfig.unit}
|
||||
rangeLabel={valueEditConfig.rangeLabel}
|
||||
placeholder={valueEditConfig.placeholder}
|
||||
keyboardType={valueEditConfig.keyboardType}
|
||||
parseValue={valueEditConfig.parseValue}
|
||||
onApply={(value) => eq.updateBand(activeBand.id, createValueUpdate(editingValue, value))}
|
||||
onClose={() => setEditingValue(null)}
|
||||
/>
|
||||
) : null}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
|
||||
switch (kind) {
|
||||
case 'frequency':
|
||||
return {
|
||||
title: 'Edit frequency',
|
||||
initialValue: String(Math.round(band.frequency)),
|
||||
unit: 'Hz',
|
||||
rangeLabel: `${EQ_MIN_FREQUENCY}-${EQ_MAX_FREQUENCY} Hz`,
|
||||
placeholder: '1000 or 1k',
|
||||
keyboardType: 'default' as const,
|
||||
parseValue: parseFrequency,
|
||||
};
|
||||
case 'gain':
|
||||
if (isPassEQBandType(band.type)) return null;
|
||||
return {
|
||||
title: 'Edit gain',
|
||||
initialValue: band.gain.toFixed(1),
|
||||
unit: 'dB',
|
||||
rangeLabel: `${-EQ_MAX_GAIN_DB} to +${EQ_MAX_GAIN_DB} dB`,
|
||||
placeholder: '0.0',
|
||||
keyboardType: 'numbers-and-punctuation' as const,
|
||||
parseValue: parseDb,
|
||||
};
|
||||
case 'Q':
|
||||
return {
|
||||
title: 'Edit Q',
|
||||
initialValue: band.Q.toFixed(2),
|
||||
unit: 'Q',
|
||||
rangeLabel: `${EQ_MIN_Q}-${EQ_MAX_Q}`,
|
||||
placeholder: '1.00',
|
||||
keyboardType: 'numbers-and-punctuation' as const,
|
||||
parseValue: parsePlainNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createValueUpdate(kind: EQEditableValue, value: number): Partial<EQBand> {
|
||||
switch (kind) {
|
||||
case 'frequency':
|
||||
return { frequency: value };
|
||||
case 'gain':
|
||||
return { gain: value };
|
||||
case 'Q':
|
||||
return { Q: value };
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrequency(value: string): number | null {
|
||||
const match = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/,/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.match(/^([+-]?(?:\d+\.?\d*|\.\d+))(khz|hz|k)?$/);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return match[2] === 'k' || match[2] === 'khz' ? parsed * 1000 : parsed;
|
||||
}
|
||||
|
||||
function parseDb(value: string): number | null {
|
||||
const normalized = value.trim().toLowerCase().replace(/\s+/g, '');
|
||||
const raw = normalized.endsWith('db') ? normalized.slice(0, -2) : normalized;
|
||||
return parsePlainNumber(raw);
|
||||
}
|
||||
|
||||
function parsePlainNumber(value: string): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
note: {
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xxl,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bands: {
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
band: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
track: {
|
||||
width: 4,
|
||||
height: 140,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.glassBorder,
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
knob: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
presetRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
graphWrap: {
|
||||
flex: 1,
|
||||
minHeight: 180,
|
||||
marginHorizontal: spacing.lg,
|
||||
},
|
||||
section: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
preamp: {
|
||||
flex: 1,
|
||||
},
|
||||
eqToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accent,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
freq: {
|
||||
color: colors.textSecondary,
|
||||
eqToggleOn: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
sheetTitle: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
+185
-41
@@ -1,9 +1,12 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQSlider } from '@/components/eq/EQSlider';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import type { ReplayGainMode } from '@/audio/normalization';
|
||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
||||
|
||||
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
|
||||
@@ -19,59 +22,159 @@ const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; descri
|
||||
},
|
||||
];
|
||||
|
||||
const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [
|
||||
{ mode: 'auto', label: 'Auto' },
|
||||
{ mode: 'track', label: 'Track' },
|
||||
{ mode: 'album', label: 'Album' },
|
||||
];
|
||||
|
||||
function ToggleRow({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
value: boolean;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleText}>
|
||||
<Text variant="body">{title}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
|
||||
|
||||
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
|
||||
const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
|
||||
const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
|
||||
const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode);
|
||||
const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled);
|
||||
const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs);
|
||||
const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
|
||||
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Settings
|
||||
</Text>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Settings
|
||||
</Text>
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
||||
LIBRARY
|
||||
</Text>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
Artist grouping
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||
How tracks are organized into artists in the library.
|
||||
</Text>
|
||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
||||
AUDIO
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
<ToggleRow
|
||||
title="Loudness normalization"
|
||||
description="Level every track to a target loudness — easier on your ears and keeps the scopes consistent."
|
||||
value={normalizationEnabled}
|
||||
onValueChange={(v) => void setNormalizationEnabled(v)}
|
||||
/>
|
||||
{normalizationEnabled ? (
|
||||
<View style={styles.indent}>
|
||||
<EQSlider
|
||||
label="Target"
|
||||
value={normalizationTargetLufs}
|
||||
min={-30}
|
||||
max={-5}
|
||||
format={(v) => `${Math.round(v)} LUFS`}
|
||||
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.options}>
|
||||
{ARTIST_GROUPING_OPTIONS.map((option) => {
|
||||
const selected = option.mode === groupingMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.mode}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => void setArtistGroupingMode(option.mode)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View style={styles.optionText}>
|
||||
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||
{option.title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||
) : (
|
||||
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={[styles.card, styles.cardSpacing]}>
|
||||
<ToggleRow
|
||||
title="ReplayGain"
|
||||
description="Use ReplayGain tags when present; falls back to the measured loudness above."
|
||||
value={replayGainEnabled}
|
||||
onValueChange={(v) => void setReplayGainEnabled(v)}
|
||||
/>
|
||||
{replayGainEnabled ? (
|
||||
<View style={styles.modeRow}>
|
||||
{REPLAYGAIN_MODES.map((m) => {
|
||||
const selected = m.mode === replayGainMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={m.mode}
|
||||
style={[styles.modePill, selected && styles.modePillSelected]}
|
||||
onPress={() => void setReplayGainMode(m.mode)}
|
||||
>
|
||||
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
|
||||
{m.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
||||
LIBRARY
|
||||
</Text>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
Artist grouping
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||
How tracks are organized into artists in the library.
|
||||
</Text>
|
||||
|
||||
<View style={styles.options}>
|
||||
{ARTIST_GROUPING_OPTIONS.map((option) => {
|
||||
const selected = option.mode === groupingMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.mode}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => void setArtistGroupingMode(option.mode)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View style={styles.optionText}>
|
||||
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||
{option.title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||
) : (
|
||||
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
heading: {
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.xxl,
|
||||
@@ -80,6 +183,47 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionSpacing: {
|
||||
marginTop: spacing.xxl,
|
||||
},
|
||||
card: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
cardSpacing: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
toggleText: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
indent: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
modeRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
modePill: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
modePillSelected: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
settingTitle: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
|
||||
+20
-1
@@ -18,6 +18,9 @@ import {
|
||||
import { usePlaybackSync } from '@/audio/usePlaybackSync';
|
||||
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useNormalizationSync } from '@/audio/useNormalizationSync';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -34,6 +37,12 @@ function ScopeLifecycle() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pushes per-track normalization gain to native on track/settings change. */
|
||||
function NormalizationSync() {
|
||||
useNormalizationSync();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({
|
||||
Inter_400Regular,
|
||||
@@ -51,12 +60,21 @@ export default function RootLayout() {
|
||||
}, [fontsLoaded]);
|
||||
|
||||
// Eager library init: SQLite open + initial reads are tens of ms, and the
|
||||
// Library tab + playback adapters get data immediately.
|
||||
// Library tab + playback adapters get data immediately. EQ + audio settings load
|
||||
// alongside so the native EQ/gain reflect persisted prefs from the first play.
|
||||
useEffect(() => {
|
||||
useLibraryStore
|
||||
.getState()
|
||||
.initialize()
|
||||
.catch((err) => console.error('[library] init failed', err));
|
||||
useEQStore
|
||||
.getState()
|
||||
.load()
|
||||
.catch((err) => console.error('[eq] load failed', err));
|
||||
useAudioSettingsStore
|
||||
.getState()
|
||||
.load()
|
||||
.catch((err) => console.error('[audioSettings] load failed', err));
|
||||
}, []);
|
||||
|
||||
if (!fontsLoaded) return null;
|
||||
@@ -67,6 +85,7 @@ export default function RootLayout() {
|
||||
<StatusBar style="light" />
|
||||
<PlaybackSync />
|
||||
<ScopeLifecycle />
|
||||
<NormalizationSync />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
|
||||
+63
-40
@@ -16,12 +16,14 @@ import Animated, {
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { FormatBadges } from '@/components/FormatBadge';
|
||||
import { MarqueeText } from '@/components/MarqueeText';
|
||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||
import { Visualizer } from '@/components/Visualizer';
|
||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||
import { QueueTray } from '@/components/queue/QueueTray';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
@@ -177,6 +179,7 @@ export default function NowPlayingScreen() {
|
||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
||||
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
||||
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const libraryTracks = useLibraryStore((s) => s.tracks);
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
@@ -194,11 +197,15 @@ export default function NowPlayingScreen() {
|
||||
const source = track?.album?.trim() ? track.album : 'Library';
|
||||
const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2);
|
||||
const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
|
||||
const artistName = track?.artist.trim() ?? '';
|
||||
const libraryTrack = useMemo(
|
||||
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
|
||||
[libraryTracks, track]
|
||||
);
|
||||
const artistName = track
|
||||
? artistGroupingMode === 'fileTags'
|
||||
? resolveStrictBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
|
||||
: resolveCanonicalBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
|
||||
: '';
|
||||
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
|
||||
|
||||
const navigateToArtist = () => {
|
||||
@@ -454,9 +461,13 @@ export default function NowPlayingScreen() {
|
||||
<View style={styles.playerControls}>
|
||||
<View style={styles.trackInfo}>
|
||||
<View style={styles.trackTextStack}>
|
||||
<Text variant="heading" numberOfLines={1} style={styles.trackTitle}>
|
||||
<MarqueeText
|
||||
variant="heading"
|
||||
containerStyle={styles.trackTitle}
|
||||
style={styles.trackTitleText}
|
||||
>
|
||||
{track.title}
|
||||
</Text>
|
||||
</MarqueeText>
|
||||
<View style={styles.trackMetaRow}>
|
||||
<Pressable
|
||||
onPress={navigateToArtist}
|
||||
@@ -465,13 +476,10 @@ export default function NowPlayingScreen() {
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`View artist ${track.artist}`}
|
||||
>
|
||||
<Text variant="body" numberOfLines={1} style={styles.artist}>
|
||||
<MarqueeText variant="body" style={styles.artist}>
|
||||
{track.artist}
|
||||
</Text>
|
||||
</MarqueeText>
|
||||
</Pressable>
|
||||
<View style={styles.badges}>
|
||||
<FormatBadges track={track} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
@@ -566,31 +574,36 @@ export default function NowPlayingScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.subRow}>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
||||
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
||||
accessibilityState={{ selected: scopeStageVisible }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="sine-wave"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={scopeStageVisible ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => setQueueOpen(true)}
|
||||
accessibilityLabel="Queue"
|
||||
>
|
||||
<Ionicons
|
||||
name="list-outline"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={styles.subBadges}>
|
||||
<FormatBadges track={track} wrap={false} />
|
||||
</View>
|
||||
<View style={styles.subActions}>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
||||
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
||||
accessibilityState={{ selected: scopeStageVisible }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="sine-wave"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={scopeStageVisible ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => setQueueOpen(true)}
|
||||
accessibilityLabel="Queue"
|
||||
>
|
||||
<Ionicons
|
||||
name="list-outline"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -778,6 +791,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
trackTitle: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
trackTitleText: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
inlineActionBtn: {
|
||||
@@ -789,13 +804,13 @@ const styles = StyleSheet.create({
|
||||
trackMetaRow: {
|
||||
alignSelf: 'stretch',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
artistButton: {
|
||||
flexShrink: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
centered: {
|
||||
@@ -804,9 +819,6 @@ const styles = StyleSheet.create({
|
||||
artist: {
|
||||
color: colors.accentText,
|
||||
},
|
||||
badges: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minHeight: MIN_FLOATING_SPACE,
|
||||
@@ -843,11 +855,22 @@ const styles = StyleSheet.create({
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.lg,
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
marginTop: SUB_TOP_MARGIN,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
subBadges: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
subActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
subBtn: {
|
||||
width: SUB_BUTTON_SIZE,
|
||||
height: SUB_BUTTON_SIZE,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// AutoEQ ParametricEQ.txt parser — ported from desktop `src/renderer/utils/autoEQParser.ts`.
|
||||
|
||||
import type { EQBand, EQBandType, EQPreset } from '@/types/audio';
|
||||
import { EQ_MAX_BANDS, clampEQFrequency, clampEQGain, clampEQQ, clampPreamp } from './eq';
|
||||
import { genEqId } from './eqPresets';
|
||||
|
||||
const TYPE_MAP: Record<string, EQBandType> = {
|
||||
PK: 'peaking',
|
||||
LS: 'lowshelf',
|
||||
HS: 'highshelf',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an AutoEQ ParametricEQ.txt file into an EQPreset.
|
||||
*
|
||||
* Format:
|
||||
* Preamp: -6.2 dB
|
||||
* Filter 1: ON PK Fc 31 Hz Gain 4.5 dB Q 1.41
|
||||
* Filter 2: ON LS Fc 105 Hz Gain -2.1 dB Q 0.71
|
||||
*/
|
||||
export function parseAutoEQ(content: string, filename?: string): EQPreset {
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let preamp = 0;
|
||||
const bands: EQBand[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const preampMatch = line.match(/^Preamp:\s*([-\d.]+)\s*dB/i);
|
||||
if (preampMatch) {
|
||||
preamp = clampPreamp(parseFloat(preampMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const filterMatch = line.match(
|
||||
/^Filter\s+\d+:\s*(ON|OFF)\s+(PK|LS|HS)\s+Fc\s+([\d.]+)\s*Hz\s+Gain\s+([-\d.]+)\s*dB\s+Q\s+([\d.]+)/i
|
||||
);
|
||||
if (filterMatch) {
|
||||
const [, onOff, typeCode, fc, gain, q] = filterMatch;
|
||||
if (onOff.toUpperCase() === 'OFF') continue;
|
||||
|
||||
bands.push({
|
||||
id: genEqId(),
|
||||
type: TYPE_MAP[typeCode.toUpperCase()] || 'peaking',
|
||||
frequency: clampEQFrequency(parseFloat(fc)),
|
||||
gain: clampEQGain(parseFloat(gain)),
|
||||
Q: clampEQQ(parseFloat(q)),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bands.sort((a, b) => a.frequency - b.frequency);
|
||||
|
||||
const name = filename
|
||||
? filename.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '')
|
||||
: 'Imported AutoEQ';
|
||||
|
||||
return {
|
||||
id: genEqId(),
|
||||
name,
|
||||
preamp,
|
||||
bands: bands.slice(0, EQ_MAX_BANDS),
|
||||
isCustom: true,
|
||||
};
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
// Parametric EQ math + helpers — ported from desktop `src/renderer/utils/eq.ts`.
|
||||
// The biquad cookbook (Audio EQ Cookbook) magnitude math drives the response curve
|
||||
// in the EQ screen. Coefficients themselves are computed natively (Kotlin) at the
|
||||
// real stream sample rate — here we only flatten band params for the native bridge.
|
||||
|
||||
import type { EQBand, EQBandType, EQPreset } from '@/types/audio';
|
||||
|
||||
export const EQ_MIN_GAIN_DB = -12;
|
||||
export const EQ_MAX_GAIN_DB = 12;
|
||||
export const EQ_MIN_FREQUENCY = 20;
|
||||
export const EQ_MAX_FREQUENCY = 20000;
|
||||
export const EQ_MIN_Q = 0.1;
|
||||
export const EQ_MAX_Q = 18;
|
||||
export const EQ_PASS_FILTER_DEFAULT_Q = 0.707;
|
||||
export const EQ_MAX_BANDS = 10;
|
||||
export const EQ_MIN_PREAMP_DB = -12;
|
||||
export const EQ_MAX_PREAMP_DB = 12;
|
||||
export const EQ_PRESET_VERSION = 1;
|
||||
|
||||
// Ordinals MUST match the Kotlin `EqBandType` enum order in EqBridge.kt.
|
||||
export const EQ_BAND_TYPE_ORDINAL: Record<EQBandType, number> = {
|
||||
lowshelf: 0,
|
||||
peaking: 1,
|
||||
highshelf: 2,
|
||||
highpass: 3,
|
||||
lowpass: 4,
|
||||
};
|
||||
|
||||
interface RawEQBand {
|
||||
type?: unknown;
|
||||
frequency?: unknown;
|
||||
gain?: unknown;
|
||||
Q?: unknown;
|
||||
enabled?: unknown;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function coerceFiniteNumber(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function clampEQGain(value: number): number {
|
||||
return clamp(value, EQ_MIN_GAIN_DB, EQ_MAX_GAIN_DB);
|
||||
}
|
||||
|
||||
export function clampEQFrequency(value: number): number {
|
||||
return clamp(value, EQ_MIN_FREQUENCY, EQ_MAX_FREQUENCY);
|
||||
}
|
||||
|
||||
export function clampEQQ(value: number): number {
|
||||
return clamp(value, EQ_MIN_Q, EQ_MAX_Q);
|
||||
}
|
||||
|
||||
export function clampPreamp(value: number): number {
|
||||
return clamp(value, EQ_MIN_PREAMP_DB, EQ_MAX_PREAMP_DB);
|
||||
}
|
||||
|
||||
export function normalizeEQBandType(value: unknown): EQBandType {
|
||||
switch (value) {
|
||||
case 'lowshelf':
|
||||
case 'peaking':
|
||||
case 'highshelf':
|
||||
case 'highpass':
|
||||
case 'lowpass':
|
||||
return value;
|
||||
default:
|
||||
return 'peaking';
|
||||
}
|
||||
}
|
||||
|
||||
export function isPassEQBandType(type: EQBandType): boolean {
|
||||
return type === 'highpass' || type === 'lowpass';
|
||||
}
|
||||
|
||||
/** Pass filters carry no gain — force it to 0. */
|
||||
export function normalizeEQBand<T extends EQBand>(band: T): T {
|
||||
if (!isPassEQBandType(band.type) || band.gain === 0) {
|
||||
return band;
|
||||
}
|
||||
return { ...band, gain: 0 };
|
||||
}
|
||||
|
||||
export function createNormalizedEQBand(rawBand: RawEQBand, id: string): EQBand {
|
||||
const band: EQBand = {
|
||||
id,
|
||||
type: normalizeEQBandType(rawBand.type),
|
||||
frequency: clampEQFrequency(coerceFiniteNumber(rawBand.frequency, 1000)),
|
||||
gain: clampEQGain(coerceFiniteNumber(rawBand.gain, 0)),
|
||||
Q: clampEQQ(coerceFiniteNumber(rawBand.Q, 1.0)),
|
||||
enabled: rawBand.enabled === undefined ? true : rawBand.enabled !== false,
|
||||
};
|
||||
return normalizeEQBand(band);
|
||||
}
|
||||
|
||||
export function parseEQPresetData(value: unknown, createId: () => string): EQPreset {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid preset file');
|
||||
}
|
||||
const raw = value as { name?: unknown; preamp?: unknown; bands?: unknown };
|
||||
if (typeof raw.name !== 'string' || raw.name.trim().length === 0 || !Array.isArray(raw.bands)) {
|
||||
throw new Error('Invalid preset file');
|
||||
}
|
||||
return {
|
||||
id: createId(),
|
||||
name: raw.name.trim(),
|
||||
preamp: clampPreamp(coerceFiniteNumber(raw.preamp, 0)),
|
||||
bands: raw.bands
|
||||
.slice(0, EQ_MAX_BANDS)
|
||||
.map((band) =>
|
||||
createNormalizedEQBand(
|
||||
band && typeof band === 'object' && !Array.isArray(band) ? (band as RawEQBand) : {},
|
||||
createId()
|
||||
)
|
||||
),
|
||||
isCustom: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEQPresetData(preset: Pick<EQPreset, 'name' | 'preamp' | 'bands'>): {
|
||||
version: number;
|
||||
name: string;
|
||||
preamp: number;
|
||||
bands: Pick<EQBand, 'type' | 'frequency' | 'gain' | 'Q' | 'enabled'>[];
|
||||
} {
|
||||
return {
|
||||
version: EQ_PRESET_VERSION,
|
||||
name: preset.name,
|
||||
preamp: clampPreamp(coerceFiniteNumber(preset.preamp, 0)),
|
||||
bands: preset.bands.map((b) => {
|
||||
const n = normalizeEQBand(b);
|
||||
return { type: n.type, frequency: n.frequency, gain: n.gain, Q: n.Q, enabled: n.enabled };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response curve magnitude (Audio EQ Cookbook) — for the Skia response curve.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleRate: number): number {
|
||||
if (sampleRate <= 0) return 0;
|
||||
|
||||
const w0 = (2 * Math.PI * band.frequency) / sampleRate;
|
||||
const w = (2 * Math.PI * testFreq) / sampleRate;
|
||||
const A = Math.pow(10, band.gain / 40);
|
||||
const sinW0 = Math.sin(w0);
|
||||
const cosW0 = Math.cos(w0);
|
||||
const alpha = sinW0 / (2 * band.Q);
|
||||
|
||||
let b0 = 1;
|
||||
let b1 = 0;
|
||||
let b2 = 0;
|
||||
let a0 = 1;
|
||||
let a1 = 0;
|
||||
let a2 = 0;
|
||||
|
||||
switch (band.type) {
|
||||
case 'peaking':
|
||||
b0 = 1 + alpha * A;
|
||||
b1 = -2 * cosW0;
|
||||
b2 = 1 - alpha * A;
|
||||
a0 = 1 + alpha / A;
|
||||
a1 = -2 * cosW0;
|
||||
a2 = 1 - alpha / A;
|
||||
break;
|
||||
case 'lowshelf': {
|
||||
const sqrtA = Math.sqrt(A);
|
||||
b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha);
|
||||
b1 = 2 * A * (A - 1 - (A + 1) * cosW0);
|
||||
b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha);
|
||||
a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha;
|
||||
a1 = -2 * (A - 1 + (A + 1) * cosW0);
|
||||
a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha;
|
||||
break;
|
||||
}
|
||||
case 'highshelf': {
|
||||
const sqrtA = Math.sqrt(A);
|
||||
b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha);
|
||||
b1 = -2 * A * (A - 1 + (A + 1) * cosW0);
|
||||
b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha);
|
||||
a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha;
|
||||
a1 = 2 * (A - 1 - (A + 1) * cosW0);
|
||||
a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha;
|
||||
break;
|
||||
}
|
||||
case 'lowpass':
|
||||
b0 = (1 - cosW0) / 2;
|
||||
b1 = 1 - cosW0;
|
||||
b2 = (1 - cosW0) / 2;
|
||||
a0 = 1 + alpha;
|
||||
a1 = -2 * cosW0;
|
||||
a2 = 1 - alpha;
|
||||
break;
|
||||
case 'highpass':
|
||||
b0 = (1 + cosW0) / 2;
|
||||
b1 = -(1 + cosW0);
|
||||
b2 = (1 + cosW0) / 2;
|
||||
a0 = 1 + alpha;
|
||||
a1 = -2 * cosW0;
|
||||
a2 = 1 - alpha;
|
||||
break;
|
||||
}
|
||||
|
||||
const cosW = Math.cos(w);
|
||||
const sinW = Math.sin(w);
|
||||
const cos2W = Math.cos(2 * w);
|
||||
const sin2W = Math.sin(2 * w);
|
||||
|
||||
const numReal = b0 / a0 + (b1 / a0) * cosW + (b2 / a0) * cos2W;
|
||||
const numImag = -(b1 / a0) * sinW - (b2 / a0) * sin2W;
|
||||
const denReal = 1 + (a1 / a0) * cosW + (a2 / a0) * cos2W;
|
||||
const denImag = -(a1 / a0) * sinW - (a2 / a0) * sin2W;
|
||||
|
||||
const numMag = Math.sqrt(numReal * numReal + numImag * numImag);
|
||||
const denMag = Math.sqrt(denReal * denReal + denImag * denImag);
|
||||
|
||||
return 20 * Math.log10(numMag / (denMag + 1e-20));
|
||||
}
|
||||
|
||||
/** Sum of per-band magnitudes (dB) at a frequency, skipping disabled bands. */
|
||||
export function computeCombinedEQMagnitude(
|
||||
bands: readonly EQBand[],
|
||||
testFreq: number,
|
||||
sampleRate: number
|
||||
): number {
|
||||
let totalDb = 0;
|
||||
for (const band of bands) {
|
||||
if (band.enabled === false) continue;
|
||||
totalDb += computeEQFilterMagnitude(band, testFreq, sampleRate);
|
||||
}
|
||||
return totalDb;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native bridge encoding.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** dB → linear amplitude (for the preamp gain pushed to native). */
|
||||
export function dbToLinear(db: number): number {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten bands into the flat number[] the native EqBridge consumes:
|
||||
* 5 values per band — [typeOrdinal, frequency, gain, Q, enabled?1:0].
|
||||
* Disabled and pass-normalized bands are encoded as-is; Kotlin computes the
|
||||
* biquad coefficients at the actual stream sample rate.
|
||||
*/
|
||||
export function flattenBandsForNative(bands: readonly EQBand[]): number[] {
|
||||
const out: number[] = [];
|
||||
for (const band of bands) {
|
||||
const n = normalizeEQBand(band);
|
||||
out.push(
|
||||
EQ_BAND_TYPE_ORDINAL[n.type],
|
||||
n.frequency,
|
||||
n.gain,
|
||||
n.Q,
|
||||
n.enabled === false ? 0 : 1
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Thin, defensive wrapper over the native EQ/gain setters on the AstraScope module.
|
||||
// Guards every call so a JS bundle running against an older native binary (before the
|
||||
// M4 native rebuild) degrades to a no-op instead of crashing.
|
||||
|
||||
import { AstraScope } from '../../modules/astra-scope';
|
||||
|
||||
type NativeEq = {
|
||||
setEqEnabled?: (enabled: boolean) => void;
|
||||
setEqPreamp?: (linear: number) => void;
|
||||
setEqBands?: (params: number[]) => void;
|
||||
setNormalizationGain?: (linear: number) => void;
|
||||
setTrackGain?: (url: string, linear: number) => void;
|
||||
activateTrackGain?: (url: string) => void;
|
||||
clearTrackGains?: () => void;
|
||||
setActivePostEq?: (active: boolean) => void;
|
||||
};
|
||||
|
||||
const native = AstraScope as unknown as NativeEq;
|
||||
|
||||
export function setEqEnabledNative(enabled: boolean): void {
|
||||
try {
|
||||
native.setEqEnabled?.(enabled);
|
||||
} catch {
|
||||
/* older native binary — no-op */
|
||||
}
|
||||
}
|
||||
|
||||
export function setEqPreampNative(linear: number): void {
|
||||
try {
|
||||
native.setEqPreamp?.(linear);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
export function setEqBandsNative(params: number[]): void {
|
||||
try {
|
||||
native.setEqBands?.(params);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the active normalization/ReplayGain gain directly (linear). 1 = unity. */
|
||||
export function setNormalizationGainNative(linear: number): void {
|
||||
try {
|
||||
native.setNormalizationGain?.(linear);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a queued track's gain by URL so the player switches to it natively at the
|
||||
* media-item transition (no JS round-trip on track change).
|
||||
*/
|
||||
export function setTrackGainNative(url: string, linear: number): void {
|
||||
try {
|
||||
native.setTrackGain?.(url, linear);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
/** Activate the registered gain for this URL now (current track on mount/settings). */
|
||||
export function activateTrackGainNative(url: string): void {
|
||||
try {
|
||||
native.activateTrackGain?.(url);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop all registered per-track gains. */
|
||||
export function clearTrackGainsNative(): void {
|
||||
try {
|
||||
native.clearTrackGains?.();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
|
||||
/** Gate the post-EQ tap (true only while the EQ screen is visible). */
|
||||
export function setActivePostEqNative(active: boolean): void {
|
||||
try {
|
||||
native.setActivePostEq?.(active);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Default bands + built-in presets — ported from desktop `src/renderer/stores/eqStore.ts`.
|
||||
// Each band carries the mobile `enabled` flag (default on).
|
||||
|
||||
import type { EQBand, EQPreset } from '@/types/audio';
|
||||
|
||||
let idCounter = 0;
|
||||
/** Monotonic, collision-free id for bands/presets (RN-safe, no crypto needed). */
|
||||
export function genEqId(): string {
|
||||
idCounter += 1;
|
||||
return `eq-${Date.now().toString(36)}-${idCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
type BandSeed = Omit<EQBand, 'id' | 'enabled'> & { enabled?: boolean };
|
||||
|
||||
function mkBand(seed: BandSeed): EQBand {
|
||||
return {
|
||||
id: genEqId(),
|
||||
type: seed.type,
|
||||
frequency: seed.frequency,
|
||||
gain: seed.gain,
|
||||
Q: seed.Q,
|
||||
enabled: seed.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
// 5-band default (shelves at the extremes, 3 peaking in between). More bands can
|
||||
// be added from the EQ screen up to EQ_MAX_BANDS.
|
||||
export const DEFAULT_BAND_SEEDS: BandSeed[] = [
|
||||
{ type: 'lowshelf', frequency: 60, gain: 0, Q: 0.707 },
|
||||
{ type: 'peaking', frequency: 250, gain: 0, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 1000, gain: 0, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 4000, gain: 0, Q: 1.0 },
|
||||
{ type: 'highshelf', frequency: 12000, gain: 0, Q: 0.707 },
|
||||
];
|
||||
|
||||
/** Fresh default (flat) bands with new ids. */
|
||||
export function createDefaultBands(): EQBand[] {
|
||||
return DEFAULT_BAND_SEEDS.map(mkBand);
|
||||
}
|
||||
|
||||
interface PresetSeed {
|
||||
id: string;
|
||||
name: string;
|
||||
preamp: number;
|
||||
bands: BandSeed[];
|
||||
}
|
||||
|
||||
const BUILT_IN_SEEDS: PresetSeed[] = [
|
||||
{ id: 'flat', name: 'Flat', preamp: 0, bands: DEFAULT_BAND_SEEDS },
|
||||
{
|
||||
id: 'bass-boost',
|
||||
name: 'Bass Boost',
|
||||
preamp: -2,
|
||||
bands: [
|
||||
{ type: 'lowshelf', frequency: 60, gain: 6, Q: 0.707 },
|
||||
{ type: 'peaking', frequency: 150, gain: 4, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 400, gain: 1, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 1000, gain: 0, Q: 1.0 },
|
||||
{ type: 'highshelf', frequency: 12000, gain: 0, Q: 0.707 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'treble-boost',
|
||||
name: 'Treble Boost',
|
||||
preamp: -2,
|
||||
bands: [
|
||||
{ type: 'lowshelf', frequency: 60, gain: 0, Q: 0.707 },
|
||||
{ type: 'peaking', frequency: 1000, gain: 0, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 4000, gain: 3, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 8000, gain: 5, Q: 1.0 },
|
||||
{ type: 'highshelf', frequency: 12000, gain: 6, Q: 0.707 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vocal',
|
||||
name: 'Vocal',
|
||||
preamp: -1,
|
||||
bands: [
|
||||
{ type: 'lowshelf', frequency: 80, gain: -2, Q: 0.707 },
|
||||
{ type: 'peaking', frequency: 250, gain: 1, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 1500, gain: 4, Q: 1.2 },
|
||||
{ type: 'peaking', frequency: 4000, gain: 3, Q: 1.0 },
|
||||
{ type: 'highshelf', frequency: 12000, gain: 1, Q: 0.707 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'loudness',
|
||||
name: 'Loudness',
|
||||
preamp: -3,
|
||||
bands: [
|
||||
{ type: 'lowshelf', frequency: 60, gain: 5, Q: 0.707 },
|
||||
{ type: 'peaking', frequency: 400, gain: 2, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 1000, gain: -1, Q: 1.0 },
|
||||
{ type: 'peaking', frequency: 4000, gain: 2, Q: 1.0 },
|
||||
{ type: 'highshelf', frequency: 12000, gain: 5, Q: 0.707 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Built-in presets with fresh band ids (call once at store init). */
|
||||
export function createBuiltInPresets(): EQPreset[] {
|
||||
return BUILT_IN_SEEDS.map((seed) => ({
|
||||
id: seed.id,
|
||||
name: seed.name,
|
||||
preamp: seed.preamp,
|
||||
bands: seed.bands.map(mkBand),
|
||||
isCustom: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export const FLAT_PRESET_ID = 'flat';
|
||||
@@ -0,0 +1,133 @@
|
||||
// Per-track normalization gain — ported from desktop AudioEngine
|
||||
// `resolveStaticNormalizationGain` / `resolveGainStateForAnalysis`.
|
||||
//
|
||||
// Precedence (desktop-match, locked with the user):
|
||||
// normalization off -> unity
|
||||
// ReplayGain on + tag present -> use the tag (clamped)
|
||||
// else -> targetLufs - scannedLUFS (clamped)
|
||||
// then back off so peak * linearGain <= 0.98 (peak limiter).
|
||||
|
||||
export const NORM_MIN_GAIN_DB = -18;
|
||||
export const NORM_MAX_GAIN_DB = 6;
|
||||
export const NORM_PEAK_CEILING_LINEAR = 0.98;
|
||||
export const DEFAULT_TARGET_LUFS = -12;
|
||||
|
||||
export type ReplayGainMode = 'auto' | 'track' | 'album';
|
||||
export type NormalizationMode = 'off' | 'replaygain' | 'normalization';
|
||||
|
||||
export interface LoudnessFacts {
|
||||
loudnessLufs: number | null;
|
||||
samplePeak: number | null;
|
||||
replayGainTrackDb: number | null;
|
||||
replayGainAlbumDb: number | null;
|
||||
replayGainTrackPeak: number | null;
|
||||
replayGainAlbumPeak: number | null;
|
||||
}
|
||||
|
||||
export interface NormalizationSettings {
|
||||
enabled: boolean;
|
||||
targetLufs: number;
|
||||
replayGainEnabled: boolean;
|
||||
replayGainMode: ReplayGainMode;
|
||||
}
|
||||
|
||||
export interface ResolvedGain {
|
||||
gainDb: number;
|
||||
linearGain: number;
|
||||
mode: NormalizationMode;
|
||||
peakLimited: boolean;
|
||||
}
|
||||
|
||||
const UNITY: ResolvedGain = { gainDb: 0, linearGain: 1, mode: 'off', peakLimited: false };
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
function dbToLinear(db: number): number {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
|
||||
interface PickedReplayGain {
|
||||
gainDb: number;
|
||||
/** The peak matching the chosen gain (track gain -> track peak), for clip-limiting. */
|
||||
peak: number | null;
|
||||
}
|
||||
|
||||
/** Pick the ReplayGain gain+peak to use for the given mode, or null if unavailable. */
|
||||
function pickReplayGain(facts: LoudnessFacts, mode: ReplayGainMode): PickedReplayGain | null {
|
||||
const useTrack: PickedReplayGain | null =
|
||||
facts.replayGainTrackDb != null && Number.isFinite(facts.replayGainTrackDb)
|
||||
? { gainDb: facts.replayGainTrackDb, peak: facts.replayGainTrackPeak }
|
||||
: null;
|
||||
const useAlbum: PickedReplayGain | null =
|
||||
facts.replayGainAlbumDb != null && Number.isFinite(facts.replayGainAlbumDb)
|
||||
? { gainDb: facts.replayGainAlbumDb, peak: facts.replayGainAlbumPeak }
|
||||
: null;
|
||||
switch (mode) {
|
||||
case 'track':
|
||||
return useTrack ?? useAlbum;
|
||||
case 'album':
|
||||
return useAlbum ?? useTrack;
|
||||
case 'auto':
|
||||
default:
|
||||
// Album gain keeps relative loudness within an album; prefer it when present.
|
||||
return useAlbum ?? useTrack;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether ReplayGain alone can normalize this track (RG on + a usable tag present),
|
||||
* so callers can skip the expensive loudness decode for tagged libraries.
|
||||
*/
|
||||
export function hasUsableReplayGain(facts: LoudnessFacts, settings: NormalizationSettings): boolean {
|
||||
return settings.replayGainEnabled && pickReplayGain(facts, settings.replayGainMode) != null;
|
||||
}
|
||||
|
||||
/** Apply the peak ceiling to a candidate gain. */
|
||||
function applyPeakLimit(
|
||||
gainDb: number,
|
||||
samplePeak: number | null
|
||||
): { gainDb: number; peakLimited: boolean } {
|
||||
if (samplePeak == null || samplePeak <= 0) return { gainDb, peakLimited: false };
|
||||
const linear = dbToLinear(gainDb);
|
||||
if (samplePeak * linear <= NORM_PEAK_CEILING_LINEAR) return { gainDb, peakLimited: false };
|
||||
const maxLinear = NORM_PEAK_CEILING_LINEAR / samplePeak;
|
||||
const limitedDb = 20 * Math.log10(maxLinear);
|
||||
return { gainDb: limitedDb, peakLimited: true };
|
||||
}
|
||||
|
||||
export function resolveNormalizationGain(
|
||||
facts: LoudnessFacts,
|
||||
settings: NormalizationSettings
|
||||
): ResolvedGain {
|
||||
if (!settings.enabled) return UNITY;
|
||||
|
||||
let gainDb: number;
|
||||
let mode: NormalizationMode;
|
||||
// Peak used for clip-limiting: the RG tag's own peak in RG mode (falling back to the
|
||||
// measured sample peak), or the measured peak for loudness normalization.
|
||||
let peak: number | null;
|
||||
|
||||
const rg = settings.replayGainEnabled ? pickReplayGain(facts, settings.replayGainMode) : null;
|
||||
if (rg != null) {
|
||||
gainDb = clamp(rg.gainDb, NORM_MIN_GAIN_DB, NORM_MAX_GAIN_DB);
|
||||
mode = 'replaygain';
|
||||
peak = rg.peak ?? facts.samplePeak;
|
||||
} else if (facts.loudnessLufs != null && Number.isFinite(facts.loudnessLufs)) {
|
||||
gainDb = clamp(settings.targetLufs - facts.loudnessLufs, NORM_MIN_GAIN_DB, NORM_MAX_GAIN_DB);
|
||||
mode = 'normalization';
|
||||
peak = facts.samplePeak;
|
||||
} else {
|
||||
// Enabled but nothing measured yet — unity until analysis backfills.
|
||||
return { gainDb: 0, linearGain: 1, mode: 'normalization', peakLimited: false };
|
||||
}
|
||||
|
||||
const limited = applyPeakLimit(gainDb, peak);
|
||||
return {
|
||||
gainDb: limited.gainDb,
|
||||
linearGain: dbToLinear(limited.gainDb),
|
||||
mode,
|
||||
peakLimited: limited.peakLimited,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Per-track normalization facts: ReplayGain tags (cheap, container-only) + measured
|
||||
// integrated LUFS / sample peak (a decode, only when ReplayGain can't cover the track).
|
||||
//
|
||||
// ensureTrackLoudness is the single deduped entry point used by the normalization sync
|
||||
// (current track + queue prefetch). It reads ReplayGain tags once per track, and only
|
||||
// falls back to the expensive loudness decode when ReplayGain is off or absent — so a
|
||||
// fully tagged library normalizes with no decoding at all.
|
||||
|
||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||
import type { LibraryDatabase } from '@/db/database';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getTrackLoudness, setTrackLoudness, setTrackReplayGain, type TrackLoudness } from '@/db/queries';
|
||||
import { hasUsableReplayGain, type LoudnessFacts } from '@/audio/normalization';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
|
||||
function factsFromRow(row: TrackLoudness | null): LoudnessFacts {
|
||||
return {
|
||||
loudnessLufs: row?.loudness_lufs ?? null,
|
||||
samplePeak: row?.sample_peak ?? null,
|
||||
replayGainTrackDb: row?.replay_gain_track_db ?? null,
|
||||
replayGainAlbumDb: row?.replay_gain_album_db ?? null,
|
||||
replayGainTrackPeak: row?.replay_gain_track_peak ?? null,
|
||||
replayGainAlbumPeak: row?.replay_gain_album_peak ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure + store integrated loudness + sample peak for one track (always
|
||||
* re-measures). The decode is the expensive part; failures leave loudness NULL.
|
||||
*/
|
||||
export async function measureAndStoreLoudness(
|
||||
db: LibraryDatabase,
|
||||
path: string
|
||||
): Promise<{ lufs: number | null; peak: number | null }> {
|
||||
try {
|
||||
const res = await AstraLibraryScanner.measureLoudness(path);
|
||||
const lufs = res?.lufs ?? null;
|
||||
const peak = res?.peak ?? null;
|
||||
await setTrackLoudness(db, path, lufs, peak).catch(() => {});
|
||||
return { lufs, peak };
|
||||
} catch {
|
||||
return { lufs: null, peak: null };
|
||||
}
|
||||
}
|
||||
|
||||
const inflight = new Map<string, Promise<LoudnessFacts>>();
|
||||
|
||||
/**
|
||||
* Loudness facts for a track, reading ReplayGain tags and decoding only as needed
|
||||
* (deduped by path). Cheap when already analyzed (single DB read). The normalization
|
||||
* sync uses this so tracks from a pre-M4 library still normalize before a full rescan.
|
||||
*/
|
||||
export function ensureTrackLoudness(path: string): Promise<LoudnessFacts> {
|
||||
const existing = inflight.get(path);
|
||||
if (existing) return existing;
|
||||
const task = run(path).finally(() => inflight.delete(path));
|
||||
inflight.set(path, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function run(path: string): Promise<LoudnessFacts> {
|
||||
const db = await openLibraryDb();
|
||||
const row = await getTrackLoudness(db, path);
|
||||
let facts = factsFromRow(row);
|
||||
|
||||
// 1. Read ReplayGain tags once per track (container-only, no decode). Decoupled
|
||||
// from loudness so a track measured before ReplayGain was enabled still picks
|
||||
// up its tags; rg_scanned stays unset on failure so it retries next touch.
|
||||
if (!row || row.rg_scanned !== 1) {
|
||||
try {
|
||||
const rg = await AstraLibraryScanner.readReplayGain(path);
|
||||
await setTrackReplayGain(db, path, {
|
||||
trackGainDb: rg.trackGainDb,
|
||||
albumGainDb: rg.albumGainDb,
|
||||
trackPeak: rg.trackPeak,
|
||||
albumPeak: rg.albumPeak,
|
||||
}).catch(() => {});
|
||||
facts = {
|
||||
...facts,
|
||||
replayGainTrackDb: rg.trackGainDb,
|
||||
replayGainAlbumDb: rg.albumGainDb,
|
||||
replayGainTrackPeak: rg.trackPeak,
|
||||
replayGainAlbumPeak: rg.albumPeak,
|
||||
};
|
||||
} catch {
|
||||
/* tag read failed — fall through to a loudness measure */
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Loudness already measured — nothing more to do.
|
||||
if (facts.loudnessLufs != null) return facts;
|
||||
|
||||
// 3. ReplayGain alone can normalize this track — skip the expensive decode.
|
||||
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
|
||||
if (hasUsableReplayGain(facts, settings)) return facts;
|
||||
|
||||
// 4. Otherwise measure loudness now (decode) and merge it in.
|
||||
const measured = await measureAndStoreLoudness(db, path);
|
||||
return { ...facts, loudnessLufs: measured.lufs, samplePeak: measured.peak };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Owns per-track normalization gain. It reads each track's loudness facts from
|
||||
// SQLite, resolves the gain, and registers it natively keyed by URL — for the current
|
||||
// track AND the next few queued tracks. The player then swaps to the matching gain
|
||||
// natively at the real media-item transition (no JS round-trip on track change). The
|
||||
// current track is also activated directly here, since on mount / settings change no
|
||||
// transition fires. Renders nothing — mount once near the root.
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { resolveNormalizationGain, type LoudnessFacts } from '@/audio/normalization';
|
||||
import { ensureTrackLoudness } from '@/audio/trackAnalysis';
|
||||
import {
|
||||
setNormalizationGainNative,
|
||||
setTrackGainNative,
|
||||
activateTrackGainNative,
|
||||
} from '@/audio/eqNative';
|
||||
import { useScopeStore } from '@/scope/scopeStore';
|
||||
import { computeOscilloscopeGain, DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
|
||||
|
||||
const EMPTY_FACTS: LoudnessFacts = {
|
||||
loudnessLufs: null,
|
||||
samplePeak: null,
|
||||
replayGainTrackDb: null,
|
||||
replayGainAlbumDb: null,
|
||||
replayGainTrackPeak: null,
|
||||
replayGainAlbumPeak: null,
|
||||
};
|
||||
|
||||
// How many upcoming queue tracks to pre-measure. Bounded work (native decode
|
||||
// concurrency is capped at 2); covers songs queued a few positions ahead.
|
||||
const PREFETCH_AHEAD = 5;
|
||||
|
||||
export function useNormalizationSync(): void {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function recompute(): Promise<void> {
|
||||
const path = usePlayerStore.getState().currentTrack?.path ?? null;
|
||||
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
|
||||
if (!path) {
|
||||
setNormalizationGainNative(1);
|
||||
useScopeStore.getState().setOscGain(DEFAULT_OSC_GAIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// ensureTrackLoudness is cheap when already analyzed (single DB read) and
|
||||
// decodes+stores on a miss (lazy backfill for pre-scan tracks).
|
||||
let facts = EMPTY_FACTS;
|
||||
try {
|
||||
facts = await ensureTrackLoudness(path);
|
||||
if (cancelled) return;
|
||||
// Track changed during the await — let the newer recompute win.
|
||||
if (usePlayerStore.getState().currentTrack?.path !== path) return;
|
||||
} catch {
|
||||
/* fall back to unity via EMPTY_FACTS */
|
||||
}
|
||||
|
||||
const resolved = resolveNormalizationGain(facts, settings);
|
||||
// Seed the native map (so transitioning back to this track picks it up) and make
|
||||
// it active now (mount / settings change fire no media-item transition).
|
||||
setTrackGainNative(path, resolved.linearGain);
|
||||
activateTrackGainNative(path);
|
||||
|
||||
// Pick the oscilloscope's per-track display gain from the track's peak and the
|
||||
// gain we just applied (the scope tap is post-normalization). Held constant for
|
||||
// the whole track, so dynamics within the song are preserved.
|
||||
const basePeak =
|
||||
facts.samplePeak ?? facts.replayGainTrackPeak ?? facts.replayGainAlbumPeak ?? null;
|
||||
useScopeStore.getState().setOscGain(computeOscilloscopeGain(basePeak, resolved.linearGain));
|
||||
}
|
||||
|
||||
// Warm the next several upcoming tracks' loudness while the current one plays, and
|
||||
// register each one's resolved gain natively by URL — so when the player advances,
|
||||
// the gain is already in the map and gets applied at the transition with no JS in
|
||||
// the loop. Looking a few ahead (not just the immediate next) means a song added
|
||||
// several positions back is still measured + registered with plenty of lead time.
|
||||
// Derived from the queue mirror, so it re-runs on reorder / add-next / advance.
|
||||
// Deduped + DB-cached + native-semaphore-capped, so it stays cheap and gentle.
|
||||
function prefetchUpcoming(): void {
|
||||
const { tracks, activeIndex } = useQueueStore.getState();
|
||||
if (activeIndex < 0) return;
|
||||
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
|
||||
for (let i = 1; i <= PREFETCH_AHEAD; i++) {
|
||||
const url = tracks[activeIndex + i]?.url;
|
||||
if (typeof url !== 'string' || url.length === 0) continue;
|
||||
void ensureTrackLoudness(url)
|
||||
.then((facts) => {
|
||||
if (cancelled) return;
|
||||
const resolved = resolveNormalizationGain(facts, settings);
|
||||
setTrackGainNative(url, resolved.linearGain);
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave unregistered — defaults to unity at the transition */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The queue can change rapidly (drag-reorder); coalesce re-warms.
|
||||
let prefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function schedulePrefetch(): void {
|
||||
if (prefetchTimer) clearTimeout(prefetchTimer);
|
||||
prefetchTimer = setTimeout(() => {
|
||||
prefetchTimer = null;
|
||||
prefetchUpcoming();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
const unsubTrack = usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.currentTrack?.path !== prev.currentTrack?.path) void recompute();
|
||||
});
|
||||
const unsubQueue = useQueueStore.subscribe((state, prev) => {
|
||||
// Re-warm when the upcoming order changes (reorder, add-next, remove, advance).
|
||||
if (state.tracks !== prev.tracks || state.activeIndex !== prev.activeIndex) {
|
||||
schedulePrefetch();
|
||||
}
|
||||
});
|
||||
const unsubSettings = useAudioSettingsStore.subscribe((state, prev) => {
|
||||
if (
|
||||
state.normalizationEnabled !== prev.normalizationEnabled ||
|
||||
state.normalizationTargetLufs !== prev.normalizationTargetLufs ||
|
||||
state.replayGainEnabled !== prev.replayGainEnabled ||
|
||||
state.replayGainMode !== prev.replayGainMode
|
||||
) {
|
||||
void recompute();
|
||||
// Upcoming tracks' gains depend on the same settings — re-register them.
|
||||
schedulePrefetch();
|
||||
}
|
||||
});
|
||||
void recompute();
|
||||
prefetchUpcoming();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (prefetchTimer) clearTimeout(prefetchTimer);
|
||||
unsubTrack();
|
||||
unsubQueue();
|
||||
unsubSettings();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -20,8 +20,10 @@ export function Badge({ label }: { label: string }) {
|
||||
*/
|
||||
export function FormatBadges({
|
||||
track,
|
||||
wrap = true,
|
||||
}: {
|
||||
track: Pick<Track, 'format' | 'bitDepth' | 'sampleRate'>;
|
||||
wrap?: boolean;
|
||||
}) {
|
||||
const labels: string[] = [];
|
||||
if (track.format) labels.push(track.format.toUpperCase());
|
||||
@@ -31,7 +33,7 @@ export function FormatBadges({
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.row, !wrap && styles.rowNoWrap]}>
|
||||
{labels.map((label) => (
|
||||
<Badge key={label} label={label} />
|
||||
))}
|
||||
@@ -45,6 +47,9 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rowNoWrap: {
|
||||
flexWrap: 'nowrap',
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.glassBg,
|
||||
borderColor: colors.glassBorder,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import type { TextLayoutEvent } from 'react-native/Libraries/Types/CoreEventTypes';
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { Text } from './Text';
|
||||
|
||||
type TextVariant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono';
|
||||
|
||||
const DEFAULT_DELAY_MS = 900;
|
||||
const DEFAULT_HOLD_MS = 900;
|
||||
const DEFAULT_SPEED_PX_PER_SECOND = 28;
|
||||
const MIN_DURATION_MS = 1600;
|
||||
const MEASURE_WIDTH = 10000;
|
||||
|
||||
interface MarqueeTextProps {
|
||||
children: string;
|
||||
variant?: TextVariant;
|
||||
color?: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
delayMs?: number;
|
||||
holdMs?: number;
|
||||
speedPxPerSecond?: number;
|
||||
}
|
||||
|
||||
export function MarqueeText({
|
||||
children,
|
||||
variant = 'body',
|
||||
color,
|
||||
style,
|
||||
containerStyle,
|
||||
delayMs = DEFAULT_DELAY_MS,
|
||||
holdMs = DEFAULT_HOLD_MS,
|
||||
speedPxPerSecond = DEFAULT_SPEED_PX_PER_SECOND,
|
||||
}: MarqueeTextProps) {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [textWidth, setTextWidth] = useState(0);
|
||||
const offset = useSharedValue(0);
|
||||
const overflowDistance = Math.max(0, Math.ceil(textWidth - containerWidth));
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimation(offset);
|
||||
offset.value = 0;
|
||||
|
||||
if (overflowDistance <= 1) return;
|
||||
|
||||
const duration = Math.max(
|
||||
MIN_DURATION_MS,
|
||||
Math.round((overflowDistance / speedPxPerSecond) * 1000)
|
||||
);
|
||||
offset.value = withDelay(
|
||||
delayMs,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(-overflowDistance, { duration, easing: Easing.linear }),
|
||||
withDelay(holdMs, withTiming(-overflowDistance, { duration: 0 })),
|
||||
withTiming(0, { duration, easing: Easing.linear }),
|
||||
withDelay(holdMs, withTiming(0, { duration: 0 }))
|
||||
),
|
||||
-1,
|
||||
false
|
||||
)
|
||||
);
|
||||
}, [delayMs, holdMs, offset, overflowDistance, speedPxPerSecond]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: offset.value }],
|
||||
}));
|
||||
|
||||
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
||||
setContainerWidth(event.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
const handleTextLayout = (event: TextLayoutEvent) => {
|
||||
const measuredWidth = Math.ceil(event.nativeEvent.lines[0]?.width ?? 0);
|
||||
setTextWidth((current) => (Math.abs(current - measuredWidth) > 1 ? measuredWidth : current));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]} onLayout={handleContainerLayout}>
|
||||
<Animated.View
|
||||
style={[styles.content, textWidth > 0 ? { width: textWidth } : null, animatedStyle]}
|
||||
>
|
||||
<Text
|
||||
variant={variant}
|
||||
color={color}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="clip"
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
<Text
|
||||
variant={variant}
|
||||
color={color}
|
||||
numberOfLines={1}
|
||||
onTextLayout={handleTextLayout}
|
||||
style={[styles.measure, style]}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
measure: {
|
||||
position: 'absolute',
|
||||
width: MEASURE_WIDTH,
|
||||
opacity: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default MarqueeText;
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type SkPicture,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
|
||||
import { useScopeStore } from '@/scope/scopeStore';
|
||||
import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
interface OscilloscopeWaveProps {
|
||||
@@ -25,7 +27,6 @@ type SkiaViewApiShape = {
|
||||
requestRedraw: (nativeId: number) => void;
|
||||
};
|
||||
|
||||
const VISUAL_GAIN = 1.8;
|
||||
const values = new Float32Array(OSCILLOSCOPE_POINTS);
|
||||
|
||||
function skiaViewApi(): SkiaViewApiShape | null {
|
||||
@@ -58,7 +59,8 @@ function buildPicture(
|
||||
height: number,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
glow: boolean
|
||||
glow: boolean,
|
||||
gain: number
|
||||
): SkPicture {
|
||||
const recorder = Skia.PictureRecorder();
|
||||
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
|
||||
@@ -70,7 +72,9 @@ function buildPicture(
|
||||
const amp = mid - lineWidth;
|
||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
||||
const yAt = (i: number) => {
|
||||
let v = samples[i] * VISUAL_GAIN;
|
||||
let v = samples[i] * gain;
|
||||
// Per-track gain targets ~85% of full scale, so this only catches the rare
|
||||
// intra-track peak that runs a touch hotter than the analyzed sample peak.
|
||||
if (v < -1) v = -1;
|
||||
else if (v > 1) v = 1;
|
||||
return mid - v * amp;
|
||||
@@ -94,6 +98,10 @@ function buildPicture(
|
||||
* Imperative oscilloscope renderer. This mirrors desktop/prism's hot path:
|
||||
* a frame loop pulls native scope data and draws directly into a canvas-like
|
||||
* surface instead of routing each frame through React reconciliation.
|
||||
*
|
||||
* Amplitude uses a per-track display gain (scopeStore.oscGain, set once per track by
|
||||
* useNormalizationSync) — read fresh each frame so it tracks song changes, but held
|
||||
* constant within a track so the music's own dynamics are preserved.
|
||||
*/
|
||||
export function OscilloscopeWave({
|
||||
active,
|
||||
@@ -106,7 +114,17 @@ export function OscilloscopeWave({
|
||||
}: OscilloscopeWaveProps) {
|
||||
const viewRef = useRef<SkiaPictureView | null>(null);
|
||||
const initialPicture = useMemo(
|
||||
() => buildPicture(values, values.length, Math.max(1, width), Math.max(1, height), color, lineWidth, glow),
|
||||
() =>
|
||||
buildPicture(
|
||||
values,
|
||||
values.length,
|
||||
Math.max(1, width),
|
||||
Math.max(1, height),
|
||||
color,
|
||||
lineWidth,
|
||||
glow,
|
||||
DEFAULT_OSC_GAIN
|
||||
),
|
||||
[color, glow, height, lineWidth, width]
|
||||
);
|
||||
|
||||
@@ -119,7 +137,8 @@ export function OscilloscopeWave({
|
||||
let raf = 0;
|
||||
|
||||
const draw = (sampleCount: number) => {
|
||||
const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow);
|
||||
const gain = useScopeStore.getState().oscGain;
|
||||
const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow, gain);
|
||||
api.setJsiProperty(view.nativeId, 'picture', picture);
|
||||
api.requestRedraw(view.nativeId);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ interface SpectrumCurveProps {
|
||||
height: number;
|
||||
/** Pull native spectrum frames while active, bypassing React per-frame state. */
|
||||
active?: boolean;
|
||||
/** Which native tap to pull from. 'post' is the post-EQ ring (EQ screen). */
|
||||
source?: 'pre' | 'post';
|
||||
/** Number of render points when active. Defaults to one point per rendered pixel. */
|
||||
pointCount?: number;
|
||||
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
|
||||
@@ -292,6 +294,7 @@ export function SpectrumCurve({
|
||||
width,
|
||||
height,
|
||||
active = false,
|
||||
source = 'pre',
|
||||
pointCount,
|
||||
frameMs = MINI_FRAME_MS,
|
||||
analysisFrameMs,
|
||||
@@ -391,7 +394,11 @@ export function SpectrumCurve({
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) {
|
||||
lastAnalysis = t;
|
||||
if (AstraScope.getSpectrumFrame(spectrumBins) > 0) {
|
||||
const got =
|
||||
source === 'post'
|
||||
? AstraScope.getSpectrumFramePostEq(spectrumBins)
|
||||
: AstraScope.getSpectrumFrame(spectrumBins);
|
||||
if (got > 0) {
|
||||
writeSpectrumPoints(spectrumBins, renderValues, pointOptions);
|
||||
hasNewFrame = true;
|
||||
}
|
||||
@@ -425,6 +432,7 @@ export function SpectrumCurve({
|
||||
lineOpacity,
|
||||
lineWidth,
|
||||
resolvedPointCount,
|
||||
source,
|
||||
tiltDbPerOctave,
|
||||
width,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Pressable, StyleSheet, Switch, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import type { EQBand } from '@/types/audio';
|
||||
import { EQ_MAX_FREQUENCY, EQ_MAX_GAIN_DB, EQ_MAX_Q, EQ_MIN_FREQUENCY, EQ_MIN_Q, isPassEQBandType } from '@/audio/eq';
|
||||
import { EQSlider } from './EQSlider';
|
||||
import { BAND_TYPE_LABEL, formatFreq, formatGain } from './format';
|
||||
|
||||
interface BandDetailPanelProps {
|
||||
band: EQBand | null;
|
||||
bandNumber: number;
|
||||
onUpdate: (updates: Partial<EQBand>) => void;
|
||||
/** Open the filter-type picker (the sheet lives at the screen root). */
|
||||
onEditType: () => void;
|
||||
/** Open the exact value editor (the sheet lives at the screen root). */
|
||||
onEditValue: (value: EQEditableValue) => void;
|
||||
}
|
||||
|
||||
export type EQEditableValue = 'frequency' | 'gain' | 'Q';
|
||||
|
||||
/** "Band N" + type dropdown + On toggle + Frequency / Gain / Q sliders. */
|
||||
export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) {
|
||||
if (!band) {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Select a band to edit.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isPass = isPassEQBandType(band.type);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Text variant="heading">Band {bandNumber}</Text>
|
||||
<Pressable style={styles.typeButton} onPress={onEditType}>
|
||||
<Text variant="label" color={colors.textPrimary}>
|
||||
{BAND_TYPE_LABEL[band.type]}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={14} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.toggle}>
|
||||
<Text variant="label">{band.enabled ? 'On' : 'Off'}</Text>
|
||||
<Switch
|
||||
value={band.enabled}
|
||||
onValueChange={(enabled) => onUpdate({ enabled })}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<EQSlider
|
||||
label="Frequency"
|
||||
value={band.frequency}
|
||||
min={EQ_MIN_FREQUENCY}
|
||||
max={EQ_MAX_FREQUENCY}
|
||||
log
|
||||
format={(v) => `${formatFreq(v)} Hz`}
|
||||
onChange={(v) => onUpdate({ frequency: v })}
|
||||
onValuePress={() => onEditValue('frequency')}
|
||||
/>
|
||||
<EQSlider
|
||||
label="Gain"
|
||||
value={isPass ? 0 : band.gain}
|
||||
min={-EQ_MAX_GAIN_DB}
|
||||
max={EQ_MAX_GAIN_DB}
|
||||
format={(v) => `${formatGain(v)} dB`}
|
||||
onChange={(v) => onUpdate({ gain: v })}
|
||||
onValuePress={() => onEditValue('gain')}
|
||||
disabled={isPass}
|
||||
/>
|
||||
<EQSlider
|
||||
label="Q"
|
||||
value={band.Q}
|
||||
min={EQ_MIN_Q}
|
||||
max={EQ_MAX_Q}
|
||||
log
|
||||
format={(v) => v.toFixed(2)}
|
||||
onChange={(v) => onUpdate({ Q: v })}
|
||||
onValuePress={() => onEditValue('Q')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
typeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
toggle: {
|
||||
marginLeft: 'auto',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default BandDetailPanel;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Pressable, ScrollView, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import type { EQBand } from '@/types/audio';
|
||||
import { formatFreq, formatGain, gainColor } from './format';
|
||||
|
||||
interface BandStripProps {
|
||||
bands: EQBand[];
|
||||
activeBandId: string | null;
|
||||
canAdd: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
/** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */
|
||||
export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) {
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
>
|
||||
{bands.map((band) => {
|
||||
const isActive = band.id === activeBandId;
|
||||
return (
|
||||
<Pressable
|
||||
key={band.id}
|
||||
onPress={() => onSelect(band.id)}
|
||||
style={[styles.cell, isActive && styles.cellActive]}
|
||||
>
|
||||
<Text variant="caption" style={styles.freq}>
|
||||
{formatFreq(band.frequency)}
|
||||
</Text>
|
||||
<Text
|
||||
variant="label"
|
||||
style={[styles.gain, { color: band.enabled ? gainColor(band.gain) : colors.textTertiary }]}
|
||||
>
|
||||
{formatGain(band.gain)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{canAdd ? (
|
||||
<Pressable onPress={onAdd} style={[styles.cell, styles.addCell]} accessibilityLabel="Add band">
|
||||
<Ionicons name="add" size={22} color={colors.accentText} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
cell: {
|
||||
minWidth: 66,
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.glassBg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
cellActive: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.glassHighlight,
|
||||
},
|
||||
addCell: {
|
||||
justifyContent: 'center',
|
||||
borderColor: colors.glassBorder,
|
||||
borderStyle: 'dashed',
|
||||
minWidth: 52,
|
||||
},
|
||||
freq: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
gain: {
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
||||
export default BandStrip;
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Canvas,
|
||||
Circle,
|
||||
DashPathEffect,
|
||||
Group,
|
||||
Path,
|
||||
Skia,
|
||||
type SkPath,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Text } from '@/components/Text';
|
||||
import { SpectrumCurve } from '@/components/SpectrumCurve';
|
||||
import { colors } from '@/theme';
|
||||
import type { EQBand } from '@/types/audio';
|
||||
import {
|
||||
FREQ_TICKS,
|
||||
buildResponseFill,
|
||||
buildResponsePath,
|
||||
freqToX,
|
||||
gainToY,
|
||||
xToFreq,
|
||||
yToGain,
|
||||
} from './eqGraphMath';
|
||||
|
||||
const HIT_RADIUS = 34;
|
||||
const NODE_R = 13;
|
||||
|
||||
interface EQGraphProps {
|
||||
bands: EQBand[];
|
||||
activeBandId: string | null;
|
||||
enabled: boolean;
|
||||
/** Pull the live post-EQ spectrum behind the curve. */
|
||||
spectrumActive: boolean;
|
||||
onSelectBand: (id: string) => void;
|
||||
onChangeBand: (id: string, updates: { frequency: number; gain: number }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The EQ response graph: a live post-EQ spectrum behind a draggable response curve
|
||||
* with one numbered node per band. Skia draws the curve/grid/nodes; a transparent
|
||||
* RN responder maps touches to the nearest node and drags it (x → frequency, y →
|
||||
* gain). Q is edited from the detail panel, not the curve.
|
||||
*/
|
||||
export function EQGraph({
|
||||
bands,
|
||||
activeBandId,
|
||||
enabled,
|
||||
spectrumActive,
|
||||
onSelectBand,
|
||||
onChangeBand,
|
||||
}: EQGraphProps) {
|
||||
const [size, setSize] = useStableSize();
|
||||
const width = size.width;
|
||||
const height = size.height;
|
||||
|
||||
// Anchor the grabbed node + grant page coords; move by absolute page deltas
|
||||
// (clamped to the graph) so veering off-bounds can't snap to a corner.
|
||||
const dragRef = useRef<{
|
||||
id: string;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
} | null>(null);
|
||||
|
||||
const linePath = useMemo(
|
||||
() => buildResponsePath(bands, width, height),
|
||||
[bands, width, height]
|
||||
);
|
||||
const fillPath = useMemo(
|
||||
() => buildResponseFill(linePath, width, height),
|
||||
[linePath, width, height]
|
||||
);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
setSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height });
|
||||
};
|
||||
|
||||
const nearestBandId = (x: number, y: number): string | null => {
|
||||
let best: string | null = null;
|
||||
let bestDist = HIT_RADIUS * HIT_RADIUS;
|
||||
for (const band of bands) {
|
||||
const bx = freqToX(band.frequency, width);
|
||||
const by = gainToY(band.gain, height);
|
||||
const d = (bx - x) ** 2 + (by - y) ** 2;
|
||||
if (d <= bestDist) {
|
||||
bestDist = d;
|
||||
best = band.id;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const handleGrant = (e: GestureResponderEvent) => {
|
||||
const { locationX, locationY, pageX, pageY } = e.nativeEvent;
|
||||
const id = nearestBandId(locationX, locationY);
|
||||
if (!id) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
const band = bands.find((b) => b.id === id);
|
||||
if (!band) return;
|
||||
dragRef.current = {
|
||||
id,
|
||||
pageX,
|
||||
pageY,
|
||||
startX: freqToX(band.frequency, width),
|
||||
startY: gainToY(band.gain, height),
|
||||
};
|
||||
onSelectBand(id);
|
||||
};
|
||||
|
||||
const handleMove = (e: GestureResponderEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const { pageX, pageY } = e.nativeEvent;
|
||||
const nx = Math.max(0, Math.min(width, d.startX + (pageX - d.pageX)));
|
||||
const ny = Math.max(0, Math.min(height, d.startY + (pageY - d.pageY)));
|
||||
onChangeBand(d.id, { frequency: xToFreq(nx, width), gain: yToGain(ny, height) });
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
const curveColor = enabled ? colors.accent : colors.textTertiary;
|
||||
const centerY = height / 2;
|
||||
const yPlus6 = gainToY(6, height);
|
||||
const yMinus6 = gainToY(-6, height);
|
||||
|
||||
return (
|
||||
<View style={styles.container} onLayout={onLayout}>
|
||||
{width > 0 && height > 0 ? (
|
||||
<>
|
||||
{/* Live post-EQ spectrum behind the curve. */}
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<SpectrumCurve
|
||||
source="post"
|
||||
active={spectrumActive}
|
||||
width={width}
|
||||
height={height}
|
||||
frameMs={0}
|
||||
color={colors.accent}
|
||||
lineOpacity={0.22}
|
||||
fillOpacity={0.5}
|
||||
glow={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{/* Grid: ±6 dB lines + dashed 0 dB centerline. */}
|
||||
<Group>
|
||||
<Path
|
||||
path={hLine(0, yPlus6, width)}
|
||||
color={colors.glassBorder}
|
||||
style="stroke"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Path
|
||||
path={hLine(0, yMinus6, width)}
|
||||
color={colors.glassBorder}
|
||||
style="stroke"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Path path={hLine(0, centerY, width)} color={colors.glassBorder} style="stroke" strokeWidth={1}>
|
||||
<DashPathEffect intervals={[3, 5]} />
|
||||
</Path>
|
||||
</Group>
|
||||
|
||||
{/* Response curve + soft fill. */}
|
||||
<Path path={fillPath} color={withAlpha(curveColor, 0.1)} style="fill" />
|
||||
<Path
|
||||
path={linePath}
|
||||
color={curveColor}
|
||||
style="stroke"
|
||||
strokeWidth={2}
|
||||
strokeJoin="round"
|
||||
strokeCap="round"
|
||||
/>
|
||||
|
||||
{/* Band nodes. */}
|
||||
{bands.map((band) => {
|
||||
const cx = freqToX(band.frequency, width);
|
||||
const cy = gainToY(band.gain, height);
|
||||
const isActive = band.id === activeBandId;
|
||||
const dim = !band.enabled || !enabled;
|
||||
return (
|
||||
<Group key={band.id}>
|
||||
<Circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={NODE_R}
|
||||
color={isActive ? colors.accent : colors.bgTertiary}
|
||||
opacity={dim ? 0.4 : 1}
|
||||
/>
|
||||
<Circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={NODE_R}
|
||||
color={isActive ? colors.accent : colors.glassBorder}
|
||||
style="stroke"
|
||||
strokeWidth={isActive ? 0 : 1.5}
|
||||
opacity={dim ? 0.5 : 1}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
|
||||
{/* Node numbers (RN text over the canvas). */}
|
||||
{bands.map((band, i) => {
|
||||
const cx = freqToX(band.frequency, width);
|
||||
const cy = gainToY(band.gain, height);
|
||||
const isActive = band.id === activeBandId;
|
||||
return (
|
||||
<Text
|
||||
key={band.id}
|
||||
variant="caption"
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.nodeLabel,
|
||||
{ left: cx - NODE_R, top: cy - 8 },
|
||||
{ color: isActive ? colors.accentTextStrong : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{i + 1}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* dB labels (right edge). */}
|
||||
<Text variant="caption" pointerEvents="none" style={[styles.dbLabel, { top: yPlus6 - 6 }]}>
|
||||
+6
|
||||
</Text>
|
||||
<Text variant="caption" pointerEvents="none" style={[styles.dbLabel, { top: yMinus6 - 6 }]}>
|
||||
-6
|
||||
</Text>
|
||||
|
||||
{/* Frequency labels (bottom axis). */}
|
||||
{FREQ_TICKS.map((tick) => (
|
||||
<Text
|
||||
key={tick.label}
|
||||
variant="caption"
|
||||
pointerEvents="none"
|
||||
style={[styles.freqLabel, { left: freqToX(tick.freq, width) - 10 }]}
|
||||
>
|
||||
{tick.label}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{/* Gesture overlay. */}
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderTerminationRequest={() => false}
|
||||
onResponderGrant={handleGrant}
|
||||
onResponderMove={handleMove}
|
||||
onResponderRelease={endDrag}
|
||||
onResponderTerminate={endDrag}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function hLine(x0: number, y: number, width: number): SkPath {
|
||||
const p = Skia.Path.Make();
|
||||
p.moveTo(x0, 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})`;
|
||||
}
|
||||
|
||||
function useStableSize(): [
|
||||
{ width: number; height: number },
|
||||
(s: { width: number; height: number }) => void,
|
||||
] {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
const set = (s: { width: number; height: number }) => {
|
||||
setSize((prev) => (prev.width === s.width && prev.height === s.height ? prev : s));
|
||||
};
|
||||
return [size, set];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgSecondary,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
nodeLabel: {
|
||||
position: 'absolute',
|
||||
width: NODE_R * 2,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
},
|
||||
dbLabel: {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
freqLabel: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
textAlign: 'center',
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
});
|
||||
|
||||
export default EQGraph;
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
View,
|
||||
StyleSheet,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
|
||||
const THUMB = 16;
|
||||
|
||||
interface EQSliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
/** Logarithmic mapping (for frequency). */
|
||||
log?: boolean;
|
||||
format: (v: number) => string;
|
||||
onChange: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
onValuePress?: () => void;
|
||||
}
|
||||
|
||||
const clamp01 = (f: number) => Math.min(1, Math.max(0, f));
|
||||
|
||||
/** Labeled horizontal slider following the SeekBar gesture/derivation pattern. */
|
||||
export function EQSlider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
log,
|
||||
format,
|
||||
onChange,
|
||||
disabled,
|
||||
onValuePress,
|
||||
}: EQSliderProps) {
|
||||
const [width, setWidth] = useState(0);
|
||||
const [active, setActive] = useState(false);
|
||||
const widthRef = useRef(0);
|
||||
// Anchor on grant, then track absolute pageX deltas so veering off the row
|
||||
// vertically can't corrupt the value (the SeekBar pattern).
|
||||
const grantRef = useRef({ fraction: 0, pageX: 0 });
|
||||
|
||||
const valueToFraction = (v: number): number => {
|
||||
if (log) {
|
||||
const lo = Math.log10(min);
|
||||
const hi = Math.log10(max);
|
||||
return clamp01((Math.log10(Math.max(min, v)) - lo) / (hi - lo));
|
||||
}
|
||||
return clamp01((v - min) / (max - min));
|
||||
};
|
||||
|
||||
const fractionToValue = (f: number): number => {
|
||||
if (log) {
|
||||
const lo = Math.log10(min);
|
||||
const hi = Math.log10(max);
|
||||
return 10 ** (lo + clamp01(f) * (hi - lo));
|
||||
}
|
||||
return min + clamp01(f) * (max - min);
|
||||
};
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
widthRef.current = e.nativeEvent.layout.width;
|
||||
setWidth(e.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
const handleGrant = (e: GestureResponderEvent) => {
|
||||
setActive(true);
|
||||
const f = clamp01(e.nativeEvent.locationX / Math.max(1, widthRef.current));
|
||||
grantRef.current = { fraction: f, pageX: e.nativeEvent.pageX };
|
||||
onChange(fractionToValue(f));
|
||||
};
|
||||
|
||||
const handleMove = (e: GestureResponderEvent) => {
|
||||
const delta = (e.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current);
|
||||
onChange(fractionToValue(clamp01(grantRef.current.fraction + delta)));
|
||||
};
|
||||
|
||||
const fraction = valueToFraction(value);
|
||||
|
||||
return (
|
||||
<View style={[styles.row, disabled && styles.disabled]}>
|
||||
<Text variant="label" style={styles.label}>
|
||||
{label}
|
||||
</Text>
|
||||
<View
|
||||
style={styles.touch}
|
||||
onLayout={onLayout}
|
||||
onStartShouldSetResponder={() => !disabled}
|
||||
onMoveShouldSetResponder={() => !disabled}
|
||||
onResponderTerminationRequest={() => false}
|
||||
onResponderGrant={handleGrant}
|
||||
onResponderMove={handleMove}
|
||||
onResponderRelease={() => setActive(false)}
|
||||
onResponderTerminate={() => setActive(false)}
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.fill, { width: `${fraction * 100}%` }]} />
|
||||
</View>
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.thumb,
|
||||
active && styles.thumbActive,
|
||||
{ left: Math.max(0, fraction * width - THUMB / 2) },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{onValuePress && !disabled ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.valueButton, pressed && styles.valueButtonPressed]}
|
||||
onPress={onValuePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit ${label}`}
|
||||
>
|
||||
<Text variant="mono" style={styles.value}>
|
||||
{format(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text variant="mono" style={styles.value}>
|
||||
{format(value)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
label: {
|
||||
width: 78,
|
||||
},
|
||||
touch: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.glassBorder,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: 4,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
thumb: {
|
||||
position: 'absolute',
|
||||
width: THUMB,
|
||||
height: THUMB,
|
||||
borderRadius: THUMB / 2,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
thumbActive: {
|
||||
transform: [{ scale: 1.3 }],
|
||||
backgroundColor: colors.accentHover,
|
||||
},
|
||||
valueButton: {
|
||||
minWidth: 68,
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
valueButtonPressed: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.glassHighlight,
|
||||
},
|
||||
value: {
|
||||
width: 64,
|
||||
textAlign: 'right',
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
|
||||
export default EQSlider;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
type KeyboardTypeOptions,
|
||||
} from 'react-native';
|
||||
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, fonts, radius, spacing } from '@/theme';
|
||||
import { EqSheet } from './EqSheet';
|
||||
|
||||
interface EQValueEditSheetProps {
|
||||
title: string;
|
||||
initialValue: string;
|
||||
unit: string;
|
||||
rangeLabel: string;
|
||||
placeholder?: string;
|
||||
keyboardType?: KeyboardTypeOptions;
|
||||
parseValue: (value: string) => number | null;
|
||||
onApply: (value: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Focused numeric editor for exact EQ band values. */
|
||||
export function EQValueEditSheet({
|
||||
title,
|
||||
initialValue,
|
||||
unit,
|
||||
rangeLabel,
|
||||
placeholder,
|
||||
keyboardType = 'numbers-and-punctuation',
|
||||
parseValue,
|
||||
onApply,
|
||||
onClose,
|
||||
}: EQValueEditSheetProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const trimmed = value.trim();
|
||||
const parsed = trimmed.length > 0 ? parseValue(trimmed) : null;
|
||||
const valid = parsed !== null;
|
||||
|
||||
const apply = () => {
|
||||
if (parsed === null) return;
|
||||
onApply(parsed);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<EqSheet onClose={onClose}>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.inputRow}>
|
||||
<BottomSheetTextInput
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
keyboardType={keyboardType}
|
||||
style={[styles.input, trimmed.length > 0 && !valid && styles.inputInvalid]}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
maxLength={16}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={apply}
|
||||
selectionColor={colors.accent}
|
||||
/>
|
||||
<Text variant="label" style={styles.unit}>
|
||||
{unit}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" style={[styles.range, trimmed.length > 0 && !valid && styles.invalidText]}>
|
||||
{valid || trimmed.length === 0 ? rangeLabel : 'Enter a valid number'}
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.apply, !valid && styles.applyDisabled]}
|
||||
disabled={!valid}
|
||||
onPress={apply}
|
||||
>
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
Apply
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</EqSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: fonts.mono.regular,
|
||||
fontSize: 18,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
inputInvalid: {
|
||||
borderColor: colors.warning,
|
||||
},
|
||||
unit: {
|
||||
minWidth: 34,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
range: {
|
||||
marginTop: spacing.sm,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
invalidText: {
|
||||
color: colors.warning,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
cancel: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
apply: {
|
||||
backgroundColor: colors.accentGlow,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
applyDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
export default EQValueEditSheet;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, type ReactNode } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetView,
|
||||
type BottomSheetBackdropProps,
|
||||
} from '@gorhom/bottom-sheet';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
|
||||
/**
|
||||
* Bottom sheet for the EQ screen's menus — same chrome/behaviour as the now-playing
|
||||
* QueueTray (inline gorhom BottomSheet, dimmed backdrop, grab handle, pan-to-close)
|
||||
* so trays stay consistent across the app. Dynamically sized to its content; render
|
||||
* it conditionally ({open && <EqSheet onClose=...>}).
|
||||
*/
|
||||
export function EqSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
pressBehavior="close"
|
||||
opacity={0.58}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
index={0}
|
||||
enableDynamicSizing
|
||||
enablePanDownToClose
|
||||
onClose={onClose}
|
||||
backdropComponent={renderBackdrop}
|
||||
backgroundStyle={styles.sheetBg}
|
||||
handleIndicatorStyle={styles.handle}
|
||||
>
|
||||
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
|
||||
{children}
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section label inside a sheet. */
|
||||
export function EqSheetSection({ label }: { label: string }) {
|
||||
return (
|
||||
<Text variant="caption" style={styles.section}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface EqSheetItemProps {
|
||||
label: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
selected?: boolean;
|
||||
destructive?: boolean;
|
||||
onPress: () => void;
|
||||
/** Optional trailing control (e.g. a delete button). */
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
/** One tappable row, styled like the ActionSheet items it replaces. */
|
||||
export function EqSheetItem({ label, icon, selected, destructive, onPress, trailing }: EqSheetItemProps) {
|
||||
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
|
||||
return (
|
||||
<View style={styles.itemRow}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
|
||||
) : null}
|
||||
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
|
||||
{label}
|
||||
</Text>
|
||||
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
|
||||
</Pressable>
|
||||
{trailing}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sheetBg: {
|
||||
backgroundColor: colors.bgSecondary,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
},
|
||||
handle: {
|
||||
backgroundColor: colors.glassBorder,
|
||||
width: 38,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xs,
|
||||
},
|
||||
section: {
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 1,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
itemRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
item: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
itemPressed: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
itemLabel: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default EqSheet;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import type { EQPreset } from '@/types/audio';
|
||||
import { EqSheet, EqSheetItem, EqSheetSection } from './EqSheet';
|
||||
|
||||
interface PresetSheetProps {
|
||||
presets: EQPreset[];
|
||||
activePresetId: string | null;
|
||||
onApply: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSaveNew: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Preset hub: pick a built-in or custom preset, delete custom ones, or save a new one. */
|
||||
export function PresetSheet({
|
||||
presets,
|
||||
activePresetId,
|
||||
onApply,
|
||||
onDelete,
|
||||
onSaveNew,
|
||||
onClose,
|
||||
}: PresetSheetProps) {
|
||||
const builtIn = presets.filter((p) => !p.isCustom);
|
||||
const custom = presets.filter((p) => p.isCustom);
|
||||
|
||||
return (
|
||||
<EqSheet onClose={onClose}>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
Presets
|
||||
</Text>
|
||||
|
||||
<EqSheetSection label="BUILT-IN" />
|
||||
{builtIn.map((p) => (
|
||||
<EqSheetItem
|
||||
key={p.id}
|
||||
label={p.name}
|
||||
selected={p.id === activePresetId}
|
||||
onPress={() => {
|
||||
onApply(p.id);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<EqSheetSection label="CUSTOM" />
|
||||
{custom.length === 0 ? (
|
||||
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
|
||||
No saved presets yet.
|
||||
</Text>
|
||||
) : (
|
||||
custom.map((p) => (
|
||||
<EqSheetItem
|
||||
key={p.id}
|
||||
label={p.name}
|
||||
selected={p.id === activePresetId}
|
||||
onPress={() => {
|
||||
onApply(p.id);
|
||||
onClose();
|
||||
}}
|
||||
trailing={
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
onPress={() => onDelete(p.id)}
|
||||
style={styles.delete}
|
||||
accessibilityLabel={`Delete preset ${p.name}`}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<EqSheetItem
|
||||
label="Save current as preset…"
|
||||
icon="bookmark-outline"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onSaveNew();
|
||||
}}
|
||||
/>
|
||||
</EqSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
empty: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
delete: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default PresetSheet;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, fonts, radius, spacing } from '@/theme';
|
||||
import { EqSheet } from './EqSheet';
|
||||
|
||||
interface SavePresetSheetProps {
|
||||
defaultName: string;
|
||||
onSave: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Name + save a custom preset from the current bands/preamp. */
|
||||
export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) {
|
||||
const [name, setName] = useState(defaultName);
|
||||
const trimmed = name.trim();
|
||||
|
||||
return (
|
||||
<EqSheet onClose={onClose}>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
Save preset
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Preset name"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
maxLength={40}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
if (trimmed) {
|
||||
onSave(trimmed);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]}
|
||||
disabled={!trimmed}
|
||||
onPress={() => {
|
||||
onSave(trimmed);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
Save
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</EqSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
input: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: fonts.sans.regular,
|
||||
fontSize: 16,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
cancel: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
save: {
|
||||
backgroundColor: colors.accentGlow,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
saveDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
export default SavePresetSheet;
|
||||
@@ -0,0 +1,83 @@
|
||||
// Coordinate mapping + response-curve sampling for the EQ graph. Frequency is on a
|
||||
// log axis (20 Hz–20 kHz); gain is linear (±12 dB) centered vertically.
|
||||
|
||||
import { Skia, type SkPath } from '@shopify/react-native-skia';
|
||||
import type { EQBand } from '@/types/audio';
|
||||
import {
|
||||
EQ_MAX_FREQUENCY,
|
||||
EQ_MAX_GAIN_DB,
|
||||
EQ_MIN_FREQUENCY,
|
||||
computeCombinedEQMagnitude,
|
||||
} from '@/audio/eq';
|
||||
|
||||
export const GRAPH_SAMPLE_RATE = 48000;
|
||||
export const GRAPH_PAD_Y = 14; // px headroom so ±12 dB nodes aren't clipped
|
||||
const LOG_MIN = Math.log10(EQ_MIN_FREQUENCY);
|
||||
const LOG_MAX = Math.log10(EQ_MAX_FREQUENCY);
|
||||
const LOG_SPAN = LOG_MAX - LOG_MIN;
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export function freqToX(freq: number, width: number): number {
|
||||
const f = clamp(freq, EQ_MIN_FREQUENCY, EQ_MAX_FREQUENCY);
|
||||
return ((Math.log10(f) - LOG_MIN) / LOG_SPAN) * width;
|
||||
}
|
||||
|
||||
export function xToFreq(x: number, width: number): number {
|
||||
const t = clamp(width > 0 ? x / width : 0, 0, 1);
|
||||
return 10 ** (LOG_MIN + t * LOG_SPAN);
|
||||
}
|
||||
|
||||
export function gainToY(gainDb: number, height: number): number {
|
||||
const center = height / 2;
|
||||
const usable = center - GRAPH_PAD_Y;
|
||||
return center - (clamp(gainDb, -EQ_MAX_GAIN_DB, EQ_MAX_GAIN_DB) / EQ_MAX_GAIN_DB) * usable;
|
||||
}
|
||||
|
||||
export function yToGain(y: number, height: number): number {
|
||||
const center = height / 2;
|
||||
const usable = center - GRAPH_PAD_Y;
|
||||
if (usable <= 0) return 0;
|
||||
return clamp(((center - y) / usable) * EQ_MAX_GAIN_DB, -EQ_MAX_GAIN_DB, EQ_MAX_GAIN_DB);
|
||||
}
|
||||
|
||||
/** Combined response curve as a stroked SkPath sampled across the width. */
|
||||
export function buildResponsePath(
|
||||
bands: readonly EQBand[],
|
||||
width: number,
|
||||
height: number,
|
||||
samples = 96
|
||||
): SkPath {
|
||||
const path = Skia.Path.Make();
|
||||
if (width <= 0 || height <= 0) return path;
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const x = (i / samples) * width;
|
||||
const freq = xToFreq(x, width);
|
||||
const db = computeCombinedEQMagnitude(bands, freq, GRAPH_SAMPLE_RATE);
|
||||
const y = gainToY(db, height);
|
||||
if (i === 0) path.moveTo(x, y);
|
||||
else path.lineTo(x, y);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Closes a copy of the response path down to the baseline for a soft fill. */
|
||||
export function buildResponseFill(line: SkPath, width: number, height: number): SkPath {
|
||||
const fill = line.copy();
|
||||
fill.lineTo(width, height / 2);
|
||||
fill.lineTo(0, height / 2);
|
||||
fill.close();
|
||||
return fill;
|
||||
}
|
||||
|
||||
/** Frequency gridline positions + labels shown along the bottom axis. */
|
||||
export const FREQ_TICKS: { freq: number; label: string }[] = [
|
||||
{ freq: 30, label: '30' },
|
||||
{ freq: 100, label: '100' },
|
||||
{ freq: 500, label: '500' },
|
||||
{ freq: 1000, label: '1k' },
|
||||
{ freq: 5000, label: '5k' },
|
||||
{ freq: 15000, label: '15k' },
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
// Shared EQ value formatting for the band strip + detail panel.
|
||||
|
||||
import { colors } from '@/theme';
|
||||
import type { EQBandType } from '@/types/audio';
|
||||
|
||||
export function formatFreq(hz: number): string {
|
||||
if (hz >= 1000) {
|
||||
const k = hz / 1000;
|
||||
return `${Number.isInteger(k) ? k : k.toFixed(1)}k`;
|
||||
}
|
||||
return `${Math.round(hz)}`;
|
||||
}
|
||||
|
||||
export function formatGain(db: number): string {
|
||||
if (Math.abs(db) < 0.05) return '0';
|
||||
return `${db > 0 ? '+' : ''}${db.toFixed(1)}`;
|
||||
}
|
||||
|
||||
export function gainColor(db: number): string {
|
||||
if (db > 0.05) return colors.accentText;
|
||||
if (db < -0.05) return colors.warning;
|
||||
return colors.textTertiary;
|
||||
}
|
||||
|
||||
export const BAND_TYPE_LABEL: Record<EQBandType, string> = {
|
||||
lowshelf: 'Low Shelf',
|
||||
peaking: 'Peaking',
|
||||
highshelf: 'High Shelf',
|
||||
highpass: 'High Pass',
|
||||
lowpass: 'Low Pass',
|
||||
};
|
||||
@@ -11,14 +11,16 @@ export function ScanProgress() {
|
||||
if (!isScanning) return null;
|
||||
|
||||
const label =
|
||||
progress.phase === 'extracting'
|
||||
? `Scanning ${progress.folderName ?? ''}… ${progress.processed}/${progress.total}`
|
||||
: progress.total > 0
|
||||
? `Found ${progress.total} files in ${progress.folderName ?? ''}…`
|
||||
: `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}…`;
|
||||
progress.phase === 'analyzing'
|
||||
? `Analyzing audio… ${progress.processed}/${progress.total}`
|
||||
: progress.phase === 'extracting'
|
||||
? `Scanning ${progress.folderName ?? ''}… ${progress.processed}/${progress.total}`
|
||||
: progress.total > 0
|
||||
? `Found ${progress.total} files in ${progress.folderName ?? ''}…`
|
||||
: `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}…`;
|
||||
|
||||
const fraction =
|
||||
progress.phase === 'extracting' && progress.total > 0
|
||||
(progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0
|
||||
? progress.processed / progress.total
|
||||
: 0;
|
||||
|
||||
|
||||
@@ -141,6 +141,74 @@ export async function getTrackCount(db: LibraryDatabase): Promise<number> {
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
// --- Loudness (M4 normalization facts) ---------------------------------------
|
||||
|
||||
export interface TrackLoudness {
|
||||
loudness_lufs: number | null;
|
||||
sample_peak: number | null;
|
||||
replay_gain_track_db: number | null;
|
||||
replay_gain_album_db: number | null;
|
||||
replay_gain_track_peak: number | null;
|
||||
replay_gain_album_peak: number | null;
|
||||
/** 1 once ReplayGain tags have been read (whether or not any were present). */
|
||||
rg_scanned: number | null;
|
||||
}
|
||||
|
||||
/** Loudness facts for one track path (NULL fields = not yet analyzed). */
|
||||
export async function getTrackLoudness(
|
||||
db: LibraryDatabase,
|
||||
path: string
|
||||
): Promise<TrackLoudness | null> {
|
||||
return (
|
||||
(await db.get<TrackLoudness>(
|
||||
`SELECT loudness_lufs, sample_peak,
|
||||
replay_gain_track_db, replay_gain_album_db,
|
||||
replay_gain_track_peak, replay_gain_album_peak, rg_scanned
|
||||
FROM tracks WHERE path = ?`,
|
||||
[path]
|
||||
)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist measured loudness + sample peak for a track (scan analyze pass). */
|
||||
export async function setTrackLoudness(
|
||||
db: LibraryDatabase,
|
||||
path: string,
|
||||
lufs: number | null,
|
||||
samplePeak: number | null
|
||||
): Promise<void> {
|
||||
await db.run('UPDATE tracks SET loudness_lufs = ?, sample_peak = ? WHERE path = ?', [
|
||||
lufs,
|
||||
samplePeak,
|
||||
path,
|
||||
]);
|
||||
}
|
||||
|
||||
export interface ReplayGainColumns {
|
||||
trackGainDb: number | null;
|
||||
albumGainDb: number | null;
|
||||
trackPeak: number | null;
|
||||
albumPeak: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist ReplayGain tags read from the container + mark the track as scanned, so
|
||||
* we read tags once per track (independent of loudness, which may re-measure).
|
||||
*/
|
||||
export async function setTrackReplayGain(
|
||||
db: LibraryDatabase,
|
||||
path: string,
|
||||
rg: ReplayGainColumns
|
||||
): Promise<void> {
|
||||
await db.run(
|
||||
`UPDATE tracks SET
|
||||
replay_gain_track_db = ?, replay_gain_album_db = ?,
|
||||
replay_gain_track_peak = ?, replay_gain_album_peak = ?, rg_scanned = 1
|
||||
WHERE path = ?`,
|
||||
[rg.trackGainDb, rg.albumGainDb, rg.trackPeak, rg.albumPeak, path]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Settings (key-value preferences) ----------------------------------------
|
||||
|
||||
export async function getSetting(db: LibraryDatabase, key: string): Promise<string | null> {
|
||||
|
||||
+28
-2
@@ -4,11 +4,16 @@
|
||||
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts);
|
||||
// v4 adds a key-value settings table (artist grouping mode, future prefs);
|
||||
// v5 caches offline waveform peaks for the M3 waveform seek bar; v6 repairs DBs
|
||||
// that an abandoned earlier M3 spike left at v5 with a stale `waveform_cache`.
|
||||
// that an abandoned earlier M3 spike left at v5 with a stale `waveform_cache`;
|
||||
// v7 (M4) adds per-track loudness facts (integrated LUFS + sample peak + ReplayGain
|
||||
// tags) measured for normalization; v8 clears any loudness measured by the earlier
|
||||
// ungated whole-file method so it re-measures with the fast gated subset method;
|
||||
// v9 adds ReplayGain peak columns + an `rg_scanned` sentinel so tag reading runs
|
||||
// once per track (and is retried if it ever failed), independent of loudness.
|
||||
|
||||
import type { LibraryDatabase } from './database';
|
||||
|
||||
export const SCHEMA_VERSION = 6;
|
||||
export const SCHEMA_VERSION = 9;
|
||||
|
||||
// One statement per entry — op-sqlite executes single statements.
|
||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
@@ -115,6 +120,27 @@ const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
)`,
|
||||
`DROP TABLE IF EXISTS waveform_cache`,
|
||||
],
|
||||
// v6 -> v7 — per-track loudness facts for normalization (M4). NULL = not yet
|
||||
// analyzed (the scan analyze pass / lazy fallback backfills them). loudness_lufs
|
||||
// is integrated LUFS (negative dB); sample_peak is linear [0,1]; replay_gain_*
|
||||
// are tag dB values when present.
|
||||
[
|
||||
`ALTER TABLE tracks ADD COLUMN loudness_lufs REAL`,
|
||||
`ALTER TABLE tracks ADD COLUMN sample_peak REAL`,
|
||||
`ALTER TABLE tracks ADD COLUMN replay_gain_track_db REAL`,
|
||||
`ALTER TABLE tracks ADD COLUMN replay_gain_album_db REAL`,
|
||||
],
|
||||
// v7 -> v8 — re-measure loudness with the gated subset method (the earlier values
|
||||
// were ungated whole-file). NULL forces the background pass to recompute them.
|
||||
[`UPDATE tracks SET loudness_lufs = NULL, sample_peak = NULL`],
|
||||
// v8 -> v9 — ReplayGain peaks (linear, for clip-limiting in RG mode) + an
|
||||
// `rg_scanned` flag (0 = tags not yet read). Tag reading is decoupled from the
|
||||
// loudness decode so it runs once per track and survives loudness re-measures.
|
||||
[
|
||||
`ALTER TABLE tracks ADD COLUMN replay_gain_track_peak REAL`,
|
||||
`ALTER TABLE tracks ADD COLUMN replay_gain_album_peak REAL`,
|
||||
`ALTER TABLE tracks ADD COLUMN rg_scanned INTEGER NOT NULL DEFAULT 0`,
|
||||
],
|
||||
];
|
||||
|
||||
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { metadataToUpsertRow } from './trackAdapter';
|
||||
const EXTRACT_BATCH_SIZE = 24;
|
||||
|
||||
export interface ScanProgress {
|
||||
phase: 'discovering' | 'extracting';
|
||||
phase: 'discovering' | 'extracting' | 'analyzing';
|
||||
processed: number;
|
||||
total: number;
|
||||
folderName: string;
|
||||
@@ -157,6 +157,10 @@ export async function scanFolder(
|
||||
}
|
||||
|
||||
await markFolderScanned(db, folder.id);
|
||||
|
||||
// Loudness + waveform are measured on the fly: the first time a track is played,
|
||||
// useNormalizationSync (loudness) and the seek bar (waveform) decode + cache it.
|
||||
// No bulk background decoding — gentle on low-end devices.
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Per-track oscilloscope display gain.
|
||||
//
|
||||
// The oscilloscope tap sits right after the normalization processor, so each track
|
||||
// arrives at a different level (loudness normalization scales quiet and loud masters
|
||||
// by different amounts). A single fixed display gain therefore buries quiet tracks
|
||||
// and clips loud ones. Instead we pick ONE gain per track that maps the track's peak
|
||||
// to a consistent display height, and hold it for the whole track — so the song's own
|
||||
// quiet/loud dynamics are preserved (a constant multiplier doesn't change them) while
|
||||
// every track lines up to the same reference. It only changes when the track changes.
|
||||
|
||||
/** Fallback when the track's peak is unknown (not yet analyzed / untagged). */
|
||||
export const DEFAULT_OSC_GAIN = 1.8;
|
||||
|
||||
// Map the track's (post-normalization) peak to this fraction of the half-height,
|
||||
// leaving a little headroom so the line doesn't kiss the edges.
|
||||
const TARGET_LEVEL = 0.85;
|
||||
const MIN_OSC_GAIN = 0.5;
|
||||
const MAX_OSC_GAIN = 8; // cap so a very quiet master doesn't blow up to noise
|
||||
|
||||
/**
|
||||
* Display gain for the oscilloscope given the track's pre-normalization linear peak
|
||||
* and the normalization gain currently applied (the tap is post-normalization, so the
|
||||
* level it sees is `basePeak * normGain`). Returns {@link DEFAULT_OSC_GAIN} when the
|
||||
* peak is unknown.
|
||||
*/
|
||||
export function computeOscilloscopeGain(basePeak: number | null, normGain: number): number {
|
||||
if (basePeak == null || !(basePeak > 0)) return DEFAULT_OSC_GAIN;
|
||||
const postNormPeak = basePeak * (normGain > 0 ? normGain : 1);
|
||||
if (!(postNormPeak > 0)) return DEFAULT_OSC_GAIN;
|
||||
const gain = TARGET_LEVEL / postNormPeak;
|
||||
return Math.max(MIN_OSC_GAIN, Math.min(MAX_OSC_GAIN, gain));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { DEFAULT_OSC_GAIN } from './oscilloscopeGain';
|
||||
|
||||
/**
|
||||
* Whether the visualizers should run. Set by useScopeLifecycle (foreground +
|
||||
@@ -8,11 +9,20 @@ import { create } from 'zustand';
|
||||
interface ScopeStore {
|
||||
active: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
/**
|
||||
* Per-track oscilloscope display gain. Set once per track by useNormalizationSync
|
||||
* (from the track's peak + the normalization gain) and read each frame by the
|
||||
* oscilloscope so the level is consistent across tracks but constant within one.
|
||||
*/
|
||||
oscGain: number;
|
||||
setOscGain: (gain: number) => void;
|
||||
}
|
||||
|
||||
export const useScopeStore = create<ScopeStore>((set) => ({
|
||||
active: false,
|
||||
setActive: (active) => set({ active }),
|
||||
oscGain: DEFAULT_OSC_GAIN,
|
||||
setOscGain: (oscGain) => set({ oscGain }),
|
||||
}));
|
||||
|
||||
export const useScopeActive = (): boolean => useScopeStore((s) => s.active);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import {
|
||||
DEFAULT_TARGET_LUFS,
|
||||
type NormalizationSettings,
|
||||
type ReplayGainMode,
|
||||
} from '@/audio/normalization';
|
||||
|
||||
/**
|
||||
* Loudness/normalization + ReplayGain preferences. SQLite (settings table) is the
|
||||
* source of truth, mirrored in memory (settingsStore pattern; no zustand-persist).
|
||||
* The EQ's own state lives in eqStore — this store is the normalization half.
|
||||
*/
|
||||
const NORMALIZATION_ENABLED_KEY = 'normalization_enabled';
|
||||
const NORMALIZATION_TARGET_KEY = 'normalization_target_lufs';
|
||||
const REPLAYGAIN_ENABLED_KEY = 'replaygain_enabled';
|
||||
const REPLAYGAIN_MODE_KEY = 'replaygain_mode';
|
||||
|
||||
function parseMode(value: string | null): ReplayGainMode {
|
||||
return value === 'track' || value === 'album' ? value : 'auto';
|
||||
}
|
||||
|
||||
interface AudioSettingsStore {
|
||||
normalizationEnabled: boolean;
|
||||
normalizationTargetLufs: number;
|
||||
replayGainEnabled: boolean;
|
||||
replayGainMode: ReplayGainMode;
|
||||
loaded: boolean;
|
||||
|
||||
load: () => Promise<void>;
|
||||
setNormalizationEnabled: (enabled: boolean) => Promise<void>;
|
||||
setNormalizationTargetLufs: (lufs: number) => Promise<void>;
|
||||
setReplayGainEnabled: (enabled: boolean) => Promise<void>;
|
||||
setReplayGainMode: (mode: ReplayGainMode) => Promise<void>;
|
||||
/** Current settings as the plain shape the gain resolver consumes. */
|
||||
asNormalizationSettings: () => NormalizationSettings;
|
||||
}
|
||||
|
||||
export const useAudioSettingsStore = create<AudioSettingsStore>((set, get) => ({
|
||||
normalizationEnabled: true,
|
||||
normalizationTargetLufs: DEFAULT_TARGET_LUFS,
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'auto',
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
if (get().loaded) return;
|
||||
const db = await openLibraryDb();
|
||||
const [enabled, target, rgEnabled, rgMode] = await Promise.all([
|
||||
getSetting(db, NORMALIZATION_ENABLED_KEY),
|
||||
getSetting(db, NORMALIZATION_TARGET_KEY),
|
||||
getSetting(db, REPLAYGAIN_ENABLED_KEY),
|
||||
getSetting(db, REPLAYGAIN_MODE_KEY),
|
||||
]);
|
||||
const targetNum = Number(target);
|
||||
set({
|
||||
// Defaults to ON when never set.
|
||||
normalizationEnabled: enabled === null ? true : enabled === 'true',
|
||||
normalizationTargetLufs: Number.isFinite(targetNum) && target !== null ? targetNum : DEFAULT_TARGET_LUFS,
|
||||
replayGainEnabled: rgEnabled === 'true',
|
||||
replayGainMode: parseMode(rgMode),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
setNormalizationEnabled: async (enabled) => {
|
||||
if (get().normalizationEnabled === enabled) return;
|
||||
set({ normalizationEnabled: enabled });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, NORMALIZATION_ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
setNormalizationTargetLufs: async (lufs) => {
|
||||
const clamped = Math.max(-30, Math.min(-5, lufs));
|
||||
if (get().normalizationTargetLufs === clamped) return;
|
||||
set({ normalizationTargetLufs: clamped });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, NORMALIZATION_TARGET_KEY, String(clamped));
|
||||
},
|
||||
|
||||
setReplayGainEnabled: async (enabled) => {
|
||||
if (get().replayGainEnabled === enabled) return;
|
||||
set({ replayGainEnabled: enabled });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, REPLAYGAIN_ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
setReplayGainMode: async (mode) => {
|
||||
if (get().replayGainMode === mode) return;
|
||||
set({ replayGainMode: mode });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, REPLAYGAIN_MODE_KEY, mode);
|
||||
},
|
||||
|
||||
asNormalizationSettings: () => {
|
||||
const s = get();
|
||||
return {
|
||||
enabled: s.normalizationEnabled,
|
||||
targetLufs: s.normalizationTargetLufs,
|
||||
replayGainEnabled: s.replayGainEnabled,
|
||||
replayGainMode: s.replayGainMode,
|
||||
};
|
||||
},
|
||||
}));
|
||||
+264
-28
@@ -1,44 +1,280 @@
|
||||
import { create } from 'zustand';
|
||||
import type { EQBand } from '@/types/audio';
|
||||
import type { EQBand, EQPreset } from '@/types/audio';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import {
|
||||
EQ_MAX_BANDS,
|
||||
clampEQFrequency,
|
||||
clampEQGain,
|
||||
clampEQQ,
|
||||
clampPreamp,
|
||||
createNormalizedEQBand,
|
||||
dbToLinear,
|
||||
flattenBandsForNative,
|
||||
} from '@/audio/eq';
|
||||
import { createBuiltInPresets, createDefaultBands, FLAT_PRESET_ID, genEqId } from '@/audio/eqPresets';
|
||||
import { setEqBandsNative, setEqEnabledNative, setEqPreampNative } from '@/audio/eqNative';
|
||||
|
||||
/**
|
||||
* EQ state — M0 stub. The biquad chain is implemented as a Media3 AudioProcessor
|
||||
* at M4; for now this just holds the band model (ported `EQBand`) so the EQ
|
||||
* screen and later DSP wiring share one shape.
|
||||
* Parametric EQ state — SQLite (settings table) is the source of truth, mirrored
|
||||
* in memory (mirrors the settingsStore pattern; no zustand-persist). Every band/
|
||||
* preamp/enable change pushes params to the native EqAudioProcessor via _syncToNative
|
||||
* (immediate, for live audio) and debounce-persists to SQLite.
|
||||
*/
|
||||
const DEFAULT_FREQUENCIES = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||
const ENABLED_KEY = 'eq_enabled';
|
||||
const PREAMP_KEY = 'eq_preamp';
|
||||
const BANDS_KEY = 'eq_bands';
|
||||
const ACTIVE_PRESET_KEY = 'eq_active_preset';
|
||||
const CUSTOM_PRESETS_KEY = 'eq_custom_presets';
|
||||
|
||||
function makeDefaultBands(): EQBand[] {
|
||||
return DEFAULT_FREQUENCIES.map((frequency) => ({
|
||||
id: `band-${frequency}`,
|
||||
type: 'peaking',
|
||||
frequency,
|
||||
gain: 0,
|
||||
Q: 1.0,
|
||||
}));
|
||||
const PERSIST_DEBOUNCE_MS = 250;
|
||||
|
||||
function parseBands(json: string | null): EQBand[] | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const arr = JSON.parse(json);
|
||||
if (!Array.isArray(arr) || arr.length === 0) return null;
|
||||
return arr.slice(0, EQ_MAX_BANDS).map((raw) => createNormalizedEQBand(raw, genEqId()));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCustomPresets(json: string | null): EQPreset[] {
|
||||
if (!json) return [];
|
||||
try {
|
||||
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,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface EQStore {
|
||||
enabled: boolean;
|
||||
preamp: number; // dB
|
||||
bands: EQBand[];
|
||||
presets: EQPreset[]; // built-in + custom
|
||||
activePresetId: string | null; // null = manually edited ("Custom")
|
||||
activeBandId: string | null; // UI selection shared by curve / strip / panel
|
||||
loaded: boolean;
|
||||
|
||||
load: () => Promise<void>;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
setPreamp: (preamp: number) => void;
|
||||
setBandGain: (id: string, gain: number) => void;
|
||||
reset: () => void;
|
||||
toggleEnabled: () => void;
|
||||
setPreamp: (db: number) => void;
|
||||
addBand: (band?: Partial<EQBand>) => void;
|
||||
removeBand: (id: string) => void;
|
||||
updateBand: (id: string, updates: Partial<EQBand>) => void;
|
||||
selectBand: (id: string | null) => void;
|
||||
applyPreset: (presetId: string) => void;
|
||||
resetToFlat: () => void;
|
||||
saveCustomPreset: (name: string) => void;
|
||||
deleteCustomPreset: (presetId: string) => void;
|
||||
importPreset: (preset: EQPreset) => void;
|
||||
|
||||
_syncToNative: () => void;
|
||||
}
|
||||
|
||||
export const useEQStore = create<EQStore>((set) => ({
|
||||
enabled: false,
|
||||
preamp: 0,
|
||||
bands: makeDefaultBands(),
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
setEnabled: (enabled) => set({ enabled }),
|
||||
setPreamp: (preamp) => set({ preamp }),
|
||||
setBandGain: (id, gain) =>
|
||||
set((state) => ({
|
||||
bands: state.bands.map((b) => (b.id === id ? { ...b, gain } : b)),
|
||||
})),
|
||||
reset: () => set({ enabled: false, preamp: 0, bands: makeDefaultBands() }),
|
||||
}));
|
||||
export const useEQStore = create<EQStore>((set, get) => {
|
||||
function syncToNative(): void {
|
||||
const { enabled, preamp, bands } = get();
|
||||
setEqEnabledNative(enabled);
|
||||
setEqPreampNative(enabled ? dbToLinear(preamp) : 1);
|
||||
setEqBandsNative(flattenBandsForNative(bands));
|
||||
}
|
||||
|
||||
function schedulePersist(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null;
|
||||
void persistNow();
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function persistNow(): Promise<void> {
|
||||
const { enabled, preamp, bands, activePresetId, presets } = get();
|
||||
const custom = presets.filter((p) => p.isCustom);
|
||||
try {
|
||||
const db = await openLibraryDb();
|
||||
await Promise.all([
|
||||
setSetting(db, ENABLED_KEY, enabled ? 'true' : 'false'),
|
||||
setSetting(db, PREAMP_KEY, String(preamp)),
|
||||
setSetting(db, BANDS_KEY, JSON.stringify(bands)),
|
||||
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 }))
|
||||
)
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
/* persistence failure is non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the current band set as a manual edit (no longer matches a preset). */
|
||||
function markEdited(patch: Partial<EQStore>): void {
|
||||
set({ ...patch, activePresetId: null });
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
preamp: 0,
|
||||
bands: createDefaultBands(),
|
||||
presets: createBuiltInPresets(),
|
||||
activePresetId: FLAT_PRESET_ID,
|
||||
activeBandId: null,
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
if (get().loaded) return;
|
||||
const db = await openLibraryDb();
|
||||
const [enabledRaw, preampRaw, bandsRaw, activeRaw, customRaw] = await Promise.all([
|
||||
getSetting(db, ENABLED_KEY),
|
||||
getSetting(db, PREAMP_KEY),
|
||||
getSetting(db, BANDS_KEY),
|
||||
getSetting(db, ACTIVE_PRESET_KEY),
|
||||
getSetting(db, CUSTOM_PRESETS_KEY),
|
||||
]);
|
||||
|
||||
const bands = parseBands(bandsRaw) ?? createDefaultBands();
|
||||
const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)];
|
||||
const storedActive = activeRaw && activeRaw.length > 0 ? activeRaw : null;
|
||||
|
||||
set({
|
||||
enabled: enabledRaw === 'true',
|
||||
preamp: clampPreamp(Number(preampRaw) || 0),
|
||||
bands,
|
||||
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".
|
||||
activePresetId: presets.some((p) => p.id === storedActive) ? storedActive : null,
|
||||
activeBandId: bands[0]?.id ?? null,
|
||||
loaded: true,
|
||||
});
|
||||
syncToNative();
|
||||
},
|
||||
|
||||
setEnabled: (enabled) => {
|
||||
set({ enabled });
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
toggleEnabled: () => {
|
||||
set({ enabled: !get().enabled });
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
setPreamp: (db) => markEdited({ preamp: clampPreamp(db) }),
|
||||
|
||||
addBand: (partial) => {
|
||||
const { bands } = get();
|
||||
if (bands.length >= EQ_MAX_BANDS) return;
|
||||
const band = createNormalizedEQBand(
|
||||
{
|
||||
type: partial?.type ?? 'peaking',
|
||||
frequency: partial?.frequency ?? 1000,
|
||||
gain: partial?.gain ?? 0,
|
||||
Q: partial?.Q ?? 1.0,
|
||||
enabled: partial?.enabled ?? true,
|
||||
},
|
||||
genEqId()
|
||||
);
|
||||
const next = [...bands, band].sort((a, b) => a.frequency - b.frequency);
|
||||
markEdited({ bands: next, activeBandId: band.id });
|
||||
},
|
||||
|
||||
removeBand: (id) => {
|
||||
const { bands, activeBandId } = get();
|
||||
if (bands.length <= 1) return;
|
||||
const next = bands.filter((b) => b.id !== id);
|
||||
markEdited({
|
||||
bands: next,
|
||||
activeBandId: activeBandId === id ? (next[0]?.id ?? null) : activeBandId,
|
||||
});
|
||||
},
|
||||
|
||||
updateBand: (id, updates) => {
|
||||
const next = get().bands.map((b) => {
|
||||
if (b.id !== id) return b;
|
||||
const merged: EQBand = { ...b, ...updates };
|
||||
if (updates.frequency !== undefined) merged.frequency = clampEQFrequency(updates.frequency);
|
||||
if (updates.gain !== undefined) merged.gain = clampEQGain(updates.gain);
|
||||
if (updates.Q !== undefined) merged.Q = clampEQQ(updates.Q);
|
||||
return merged;
|
||||
});
|
||||
markEdited({
|
||||
bands: updates.frequency !== undefined ? next.sort((a, b) => a.frequency - b.frequency) : next,
|
||||
});
|
||||
},
|
||||
|
||||
selectBand: (id) => set({ activeBandId: id }),
|
||||
|
||||
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,
|
||||
});
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
resetToFlat: () => 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,
|
||||
};
|
||||
set({ presets: [...presets, preset], activePresetId: preset.id });
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
deleteCustomPreset: (presetId) => {
|
||||
const { presets, activePresetId } = get();
|
||||
set({
|
||||
presets: presets.filter((p) => p.id !== presetId),
|
||||
activePresetId: activePresetId === presetId ? null : activePresetId,
|
||||
});
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
importPreset: (preset) => {
|
||||
const stored: EQPreset = { ...preset, id: genEqId(), isCustom: true };
|
||||
set({ presets: [...get().presets, stored] });
|
||||
get().applyPreset(stored.id);
|
||||
},
|
||||
|
||||
_syncToNative: syncToNative,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
|
||||
export type FolderWithCount = LibraryFolder & { track_count: number };
|
||||
|
||||
interface ScanProgressState {
|
||||
phase: 'idle' | 'discovering' | 'extracting';
|
||||
phase: 'idle' | 'discovering' | 'extracting' | 'analyzing';
|
||||
processed: number;
|
||||
total: number;
|
||||
folderName?: string;
|
||||
|
||||
+6
-1
@@ -52,12 +52,17 @@ export interface PlayerState {
|
||||
}
|
||||
|
||||
// EQ Band
|
||||
export type EQBandType = 'lowshelf' | 'peaking' | 'highshelf' | 'highpass' | 'lowpass';
|
||||
|
||||
export interface EQBand {
|
||||
id: string;
|
||||
type: 'lowshelf' | 'peaking' | 'highshelf' | 'highpass' | 'lowpass';
|
||||
type: EQBandType;
|
||||
frequency: number;
|
||||
gain: number;
|
||||
Q: number;
|
||||
// Per-band bypass (mobile addition vs desktop — the EQ screen's per-band On toggle).
|
||||
// A disabled band is passthrough and is skipped in the response curve.
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// EQ Preset
|
||||
|
||||
@@ -30,6 +30,12 @@ export interface DbTrack {
|
||||
mtime: number;
|
||||
added_at: number;
|
||||
modified_at: number;
|
||||
// M4 loudness facts (NULL until analyzed). loudness_lufs: integrated LUFS (dB,
|
||||
// negative); sample_peak: linear [0,1]; replay_gain_*: tag dB when present.
|
||||
loudness_lufs: number | null;
|
||||
sample_peak: number | null;
|
||||
replay_gain_track_db: number | null;
|
||||
replay_gain_album_db: number | null;
|
||||
}
|
||||
|
||||
export interface LibraryFolder {
|
||||
|
||||
Reference in New Issue
Block a user