This commit is contained in:
Boof2015
2026-06-19 13:19:37 -04:00
parent 978d001db9
commit bc687dfe87
145 changed files with 4685 additions and 197 deletions
+355 -50
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+68
View File
@@ -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
View File
@@ -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;
}
+90
View File
@@ -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 */
}
}
+111
View File
@@ -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';
+133
View File
@@ -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,
};
}
+100
View File
@@ -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 };
}
+142
View File
@@ -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();
};
}, []);
}
+6 -1
View File
@@ -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,
+137
View File
@@ -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;
+24 -5
View File
@@ -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);
};
+9 -1
View File
@@ -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,
]);
+125
View File
@@ -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;
+87
View File
@@ -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;
+330
View File
@@ -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;
+196
View File
@@ -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;
+156
View File
@@ -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;
+132
View File
@@ -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;
+102
View File
@@ -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;
+105
View File
@@ -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;
+83
View File
@@ -0,0 +1,83 @@
// Coordinate mapping + response-curve sampling for the EQ graph. Frequency is on a
// log axis (20 Hz20 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' },
];
+31
View File
@@ -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',
};
+8 -6
View File
@@ -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;
+68
View File
@@ -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
View File
@@ -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> {
+5 -1
View File
@@ -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;
}
+32
View File
@@ -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));
}
+10
View File
@@ -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);
+105
View File
@@ -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
View File
@@ -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,
};
});
+1 -1
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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 {