mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
m4
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import {
|
||||
DEFAULT_TARGET_LUFS,
|
||||
type NormalizationSettings,
|
||||
type ReplayGainMode,
|
||||
} from '@/audio/normalization';
|
||||
|
||||
/**
|
||||
* Loudness/normalization + ReplayGain preferences. SQLite (settings table) is the
|
||||
* source of truth, mirrored in memory (settingsStore pattern; no zustand-persist).
|
||||
* The EQ's own state lives in eqStore — this store is the normalization half.
|
||||
*/
|
||||
const NORMALIZATION_ENABLED_KEY = 'normalization_enabled';
|
||||
const NORMALIZATION_TARGET_KEY = 'normalization_target_lufs';
|
||||
const REPLAYGAIN_ENABLED_KEY = 'replaygain_enabled';
|
||||
const REPLAYGAIN_MODE_KEY = 'replaygain_mode';
|
||||
|
||||
function parseMode(value: string | null): ReplayGainMode {
|
||||
return value === 'track' || value === 'album' ? value : 'auto';
|
||||
}
|
||||
|
||||
interface AudioSettingsStore {
|
||||
normalizationEnabled: boolean;
|
||||
normalizationTargetLufs: number;
|
||||
replayGainEnabled: boolean;
|
||||
replayGainMode: ReplayGainMode;
|
||||
loaded: boolean;
|
||||
|
||||
load: () => Promise<void>;
|
||||
setNormalizationEnabled: (enabled: boolean) => Promise<void>;
|
||||
setNormalizationTargetLufs: (lufs: number) => Promise<void>;
|
||||
setReplayGainEnabled: (enabled: boolean) => Promise<void>;
|
||||
setReplayGainMode: (mode: ReplayGainMode) => Promise<void>;
|
||||
/** Current settings as the plain shape the gain resolver consumes. */
|
||||
asNormalizationSettings: () => NormalizationSettings;
|
||||
}
|
||||
|
||||
export const useAudioSettingsStore = create<AudioSettingsStore>((set, get) => ({
|
||||
normalizationEnabled: true,
|
||||
normalizationTargetLufs: DEFAULT_TARGET_LUFS,
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'auto',
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
if (get().loaded) return;
|
||||
const db = await openLibraryDb();
|
||||
const [enabled, target, rgEnabled, rgMode] = await Promise.all([
|
||||
getSetting(db, NORMALIZATION_ENABLED_KEY),
|
||||
getSetting(db, NORMALIZATION_TARGET_KEY),
|
||||
getSetting(db, REPLAYGAIN_ENABLED_KEY),
|
||||
getSetting(db, REPLAYGAIN_MODE_KEY),
|
||||
]);
|
||||
const targetNum = Number(target);
|
||||
set({
|
||||
// Defaults to ON when never set.
|
||||
normalizationEnabled: enabled === null ? true : enabled === 'true',
|
||||
normalizationTargetLufs: Number.isFinite(targetNum) && target !== null ? targetNum : DEFAULT_TARGET_LUFS,
|
||||
replayGainEnabled: rgEnabled === 'true',
|
||||
replayGainMode: parseMode(rgMode),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
setNormalizationEnabled: async (enabled) => {
|
||||
if (get().normalizationEnabled === enabled) return;
|
||||
set({ normalizationEnabled: enabled });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, NORMALIZATION_ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
setNormalizationTargetLufs: async (lufs) => {
|
||||
const clamped = Math.max(-30, Math.min(-5, lufs));
|
||||
if (get().normalizationTargetLufs === clamped) return;
|
||||
set({ normalizationTargetLufs: clamped });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, NORMALIZATION_TARGET_KEY, String(clamped));
|
||||
},
|
||||
|
||||
setReplayGainEnabled: async (enabled) => {
|
||||
if (get().replayGainEnabled === enabled) return;
|
||||
set({ replayGainEnabled: enabled });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, REPLAYGAIN_ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
setReplayGainMode: async (mode) => {
|
||||
if (get().replayGainMode === mode) return;
|
||||
set({ replayGainMode: mode });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, REPLAYGAIN_MODE_KEY, mode);
|
||||
},
|
||||
|
||||
asNormalizationSettings: () => {
|
||||
const s = get();
|
||||
return {
|
||||
enabled: s.normalizationEnabled,
|
||||
targetLufs: s.normalizationTargetLufs,
|
||||
replayGainEnabled: s.replayGainEnabled,
|
||||
replayGainMode: s.replayGainMode,
|
||||
};
|
||||
},
|
||||
}));
|
||||
+264
-28
@@ -1,44 +1,280 @@
|
||||
import { create } from 'zustand';
|
||||
import type { EQBand } from '@/types/audio';
|
||||
import type { EQBand, EQPreset } from '@/types/audio';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import {
|
||||
EQ_MAX_BANDS,
|
||||
clampEQFrequency,
|
||||
clampEQGain,
|
||||
clampEQQ,
|
||||
clampPreamp,
|
||||
createNormalizedEQBand,
|
||||
dbToLinear,
|
||||
flattenBandsForNative,
|
||||
} from '@/audio/eq';
|
||||
import { createBuiltInPresets, createDefaultBands, FLAT_PRESET_ID, genEqId } from '@/audio/eqPresets';
|
||||
import { setEqBandsNative, setEqEnabledNative, setEqPreampNative } from '@/audio/eqNative';
|
||||
|
||||
/**
|
||||
* EQ state — M0 stub. The biquad chain is implemented as a Media3 AudioProcessor
|
||||
* at M4; for now this just holds the band model (ported `EQBand`) so the EQ
|
||||
* screen and later DSP wiring share one shape.
|
||||
* Parametric EQ state — SQLite (settings table) is the source of truth, mirrored
|
||||
* in memory (mirrors the settingsStore pattern; no zustand-persist). Every band/
|
||||
* preamp/enable change pushes params to the native EqAudioProcessor via _syncToNative
|
||||
* (immediate, for live audio) and debounce-persists to SQLite.
|
||||
*/
|
||||
const DEFAULT_FREQUENCIES = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||
const ENABLED_KEY = 'eq_enabled';
|
||||
const PREAMP_KEY = 'eq_preamp';
|
||||
const BANDS_KEY = 'eq_bands';
|
||||
const ACTIVE_PRESET_KEY = 'eq_active_preset';
|
||||
const CUSTOM_PRESETS_KEY = 'eq_custom_presets';
|
||||
|
||||
function makeDefaultBands(): EQBand[] {
|
||||
return DEFAULT_FREQUENCIES.map((frequency) => ({
|
||||
id: `band-${frequency}`,
|
||||
type: 'peaking',
|
||||
frequency,
|
||||
gain: 0,
|
||||
Q: 1.0,
|
||||
}));
|
||||
const PERSIST_DEBOUNCE_MS = 250;
|
||||
|
||||
function parseBands(json: string | null): EQBand[] | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const arr = JSON.parse(json);
|
||||
if (!Array.isArray(arr) || arr.length === 0) return null;
|
||||
return arr.slice(0, EQ_MAX_BANDS).map((raw) => createNormalizedEQBand(raw, genEqId()));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCustomPresets(json: string | null): EQPreset[] {
|
||||
if (!json) return [];
|
||||
try {
|
||||
const arr = JSON.parse(json);
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr
|
||||
.filter((p): p is { id?: string; name: string; preamp?: number; bands?: unknown[] } => !!p && typeof p.name === 'string')
|
||||
.map((p) => ({
|
||||
// Keep the stored id so a persisted activePresetId still matches on reload.
|
||||
id: typeof p.id === 'string' && p.id.length > 0 ? p.id : genEqId(),
|
||||
name: p.name,
|
||||
preamp: clampPreamp(typeof p.preamp === 'number' ? p.preamp : 0),
|
||||
bands: Array.isArray(p.bands)
|
||||
? p.bands.slice(0, EQ_MAX_BANDS).map((b) => createNormalizedEQBand(b as object, genEqId()))
|
||||
: createDefaultBands(),
|
||||
isCustom: true,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface EQStore {
|
||||
enabled: boolean;
|
||||
preamp: number; // dB
|
||||
bands: EQBand[];
|
||||
presets: EQPreset[]; // built-in + custom
|
||||
activePresetId: string | null; // null = manually edited ("Custom")
|
||||
activeBandId: string | null; // UI selection shared by curve / strip / panel
|
||||
loaded: boolean;
|
||||
|
||||
load: () => Promise<void>;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
setPreamp: (preamp: number) => void;
|
||||
setBandGain: (id: string, gain: number) => void;
|
||||
reset: () => void;
|
||||
toggleEnabled: () => void;
|
||||
setPreamp: (db: number) => void;
|
||||
addBand: (band?: Partial<EQBand>) => void;
|
||||
removeBand: (id: string) => void;
|
||||
updateBand: (id: string, updates: Partial<EQBand>) => void;
|
||||
selectBand: (id: string | null) => void;
|
||||
applyPreset: (presetId: string) => void;
|
||||
resetToFlat: () => void;
|
||||
saveCustomPreset: (name: string) => void;
|
||||
deleteCustomPreset: (presetId: string) => void;
|
||||
importPreset: (preset: EQPreset) => void;
|
||||
|
||||
_syncToNative: () => void;
|
||||
}
|
||||
|
||||
export const useEQStore = create<EQStore>((set) => ({
|
||||
enabled: false,
|
||||
preamp: 0,
|
||||
bands: makeDefaultBands(),
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
setEnabled: (enabled) => set({ enabled }),
|
||||
setPreamp: (preamp) => set({ preamp }),
|
||||
setBandGain: (id, gain) =>
|
||||
set((state) => ({
|
||||
bands: state.bands.map((b) => (b.id === id ? { ...b, gain } : b)),
|
||||
})),
|
||||
reset: () => set({ enabled: false, preamp: 0, bands: makeDefaultBands() }),
|
||||
}));
|
||||
export const useEQStore = create<EQStore>((set, get) => {
|
||||
function syncToNative(): void {
|
||||
const { enabled, preamp, bands } = get();
|
||||
setEqEnabledNative(enabled);
|
||||
setEqPreampNative(enabled ? dbToLinear(preamp) : 1);
|
||||
setEqBandsNative(flattenBandsForNative(bands));
|
||||
}
|
||||
|
||||
function schedulePersist(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null;
|
||||
void persistNow();
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function persistNow(): Promise<void> {
|
||||
const { enabled, preamp, bands, activePresetId, presets } = get();
|
||||
const custom = presets.filter((p) => p.isCustom);
|
||||
try {
|
||||
const db = await openLibraryDb();
|
||||
await Promise.all([
|
||||
setSetting(db, ENABLED_KEY, enabled ? 'true' : 'false'),
|
||||
setSetting(db, PREAMP_KEY, String(preamp)),
|
||||
setSetting(db, BANDS_KEY, JSON.stringify(bands)),
|
||||
setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''),
|
||||
setSetting(
|
||||
db,
|
||||
CUSTOM_PRESETS_KEY,
|
||||
JSON.stringify(
|
||||
custom.map((p) => ({ id: p.id, name: p.name, preamp: p.preamp, bands: p.bands }))
|
||||
)
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
/* persistence failure is non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the current band set as a manual edit (no longer matches a preset). */
|
||||
function markEdited(patch: Partial<EQStore>): void {
|
||||
set({ ...patch, activePresetId: null });
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
preamp: 0,
|
||||
bands: createDefaultBands(),
|
||||
presets: createBuiltInPresets(),
|
||||
activePresetId: FLAT_PRESET_ID,
|
||||
activeBandId: null,
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
if (get().loaded) return;
|
||||
const db = await openLibraryDb();
|
||||
const [enabledRaw, preampRaw, bandsRaw, activeRaw, customRaw] = await Promise.all([
|
||||
getSetting(db, ENABLED_KEY),
|
||||
getSetting(db, PREAMP_KEY),
|
||||
getSetting(db, BANDS_KEY),
|
||||
getSetting(db, ACTIVE_PRESET_KEY),
|
||||
getSetting(db, CUSTOM_PRESETS_KEY),
|
||||
]);
|
||||
|
||||
const bands = parseBands(bandsRaw) ?? createDefaultBands();
|
||||
const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)];
|
||||
const storedActive = activeRaw && activeRaw.length > 0 ? activeRaw : null;
|
||||
|
||||
set({
|
||||
enabled: enabledRaw === 'true',
|
||||
preamp: clampPreamp(Number(preampRaw) || 0),
|
||||
bands,
|
||||
presets,
|
||||
// Stored active preset ids are regenerated on load (parseCustomPresets makes
|
||||
// new ids), so only built-in ids survive a reload; fall back to "Custom".
|
||||
activePresetId: presets.some((p) => p.id === storedActive) ? storedActive : null,
|
||||
activeBandId: bands[0]?.id ?? null,
|
||||
loaded: true,
|
||||
});
|
||||
syncToNative();
|
||||
},
|
||||
|
||||
setEnabled: (enabled) => {
|
||||
set({ enabled });
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
toggleEnabled: () => {
|
||||
set({ enabled: !get().enabled });
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
setPreamp: (db) => markEdited({ preamp: clampPreamp(db) }),
|
||||
|
||||
addBand: (partial) => {
|
||||
const { bands } = get();
|
||||
if (bands.length >= EQ_MAX_BANDS) return;
|
||||
const band = createNormalizedEQBand(
|
||||
{
|
||||
type: partial?.type ?? 'peaking',
|
||||
frequency: partial?.frequency ?? 1000,
|
||||
gain: partial?.gain ?? 0,
|
||||
Q: partial?.Q ?? 1.0,
|
||||
enabled: partial?.enabled ?? true,
|
||||
},
|
||||
genEqId()
|
||||
);
|
||||
const next = [...bands, band].sort((a, b) => a.frequency - b.frequency);
|
||||
markEdited({ bands: next, activeBandId: band.id });
|
||||
},
|
||||
|
||||
removeBand: (id) => {
|
||||
const { bands, activeBandId } = get();
|
||||
if (bands.length <= 1) return;
|
||||
const next = bands.filter((b) => b.id !== id);
|
||||
markEdited({
|
||||
bands: next,
|
||||
activeBandId: activeBandId === id ? (next[0]?.id ?? null) : activeBandId,
|
||||
});
|
||||
},
|
||||
|
||||
updateBand: (id, updates) => {
|
||||
const next = get().bands.map((b) => {
|
||||
if (b.id !== id) return b;
|
||||
const merged: EQBand = { ...b, ...updates };
|
||||
if (updates.frequency !== undefined) merged.frequency = clampEQFrequency(updates.frequency);
|
||||
if (updates.gain !== undefined) merged.gain = clampEQGain(updates.gain);
|
||||
if (updates.Q !== undefined) merged.Q = clampEQQ(updates.Q);
|
||||
return merged;
|
||||
});
|
||||
markEdited({
|
||||
bands: updates.frequency !== undefined ? next.sort((a, b) => a.frequency - b.frequency) : next,
|
||||
});
|
||||
},
|
||||
|
||||
selectBand: (id) => set({ activeBandId: id }),
|
||||
|
||||
applyPreset: (presetId) => {
|
||||
const preset = get().presets.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const bands = preset.bands.map((b) => createNormalizedEQBand(b, genEqId()));
|
||||
set({
|
||||
bands,
|
||||
preamp: clampPreamp(preset.preamp),
|
||||
activePresetId: presetId,
|
||||
activeBandId: bands[0]?.id ?? null,
|
||||
});
|
||||
syncToNative();
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
resetToFlat: () => get().applyPreset(FLAT_PRESET_ID),
|
||||
|
||||
saveCustomPreset: (name) => {
|
||||
const { bands, preamp, presets } = get();
|
||||
const preset: EQPreset = {
|
||||
id: genEqId(),
|
||||
name: name.trim() || 'Custom Preset',
|
||||
preamp,
|
||||
bands: bands.map((b) => ({ ...b, id: genEqId() })),
|
||||
isCustom: true,
|
||||
};
|
||||
set({ presets: [...presets, preset], activePresetId: preset.id });
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
deleteCustomPreset: (presetId) => {
|
||||
const { presets, activePresetId } = get();
|
||||
set({
|
||||
presets: presets.filter((p) => p.id !== presetId),
|
||||
activePresetId: activePresetId === presetId ? null : activePresetId,
|
||||
});
|
||||
schedulePersist();
|
||||
},
|
||||
|
||||
importPreset: (preset) => {
|
||||
const stored: EQPreset = { ...preset, id: genEqId(), isCustom: true };
|
||||
set({ presets: [...get().presets, stored] });
|
||||
get().applyPreset(stored.id);
|
||||
},
|
||||
|
||||
_syncToNative: syncToNative,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
|
||||
export type FolderWithCount = LibraryFolder & { track_count: number };
|
||||
|
||||
interface ScanProgressState {
|
||||
phase: 'idle' | 'discovering' | 'extracting';
|
||||
phase: 'idle' | 'discovering' | 'extracting' | 'analyzing';
|
||||
processed: number;
|
||||
total: number;
|
||||
folderName?: string;
|
||||
|
||||
Reference in New Issue
Block a user