Spaces:
Running
Running
| import { useCallback, useEffect, useRef, useState } from 'react'; | |
| export const PERFORMANCE_SESSION_STORAGE_KEY = 'fragmenta.performance.session.v1'; | |
| const STORAGE_KEY = PERFORMANCE_SESSION_STORAGE_KEY; | |
| export function clearPerformanceSession() { | |
| try { localStorage.removeItem(STORAGE_KEY); } | |
| catch { /* non-fatal */ } | |
| } | |
| const PRESETS_STORAGE_KEY = 'fragmenta.performance.presets.v1'; | |
| function readPresetBag() { | |
| try { | |
| const raw = localStorage.getItem(PRESETS_STORAGE_KEY); | |
| if (!raw) return {}; | |
| const parsed = JSON.parse(raw); | |
| return parsed && typeof parsed === 'object' ? parsed : {}; | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| function writePresetBag(bag) { | |
| try { localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(bag)); } | |
| catch { /* quota — non-fatal */ } | |
| } | |
| export function listPresetNames() { | |
| return Object.keys(readPresetBag()).sort((a, b) => a.localeCompare(b)); | |
| } | |
| export function savePreset(name, sessionData) { | |
| const trimmed = (name || '').trim(); | |
| if (!trimmed) return false; | |
| const bag = readPresetBag(); | |
| bag[trimmed] = sessionData; | |
| writePresetBag(bag); | |
| return true; | |
| } | |
| export function deletePreset(name) { | |
| const bag = readPresetBag(); | |
| if (!(name in bag)) return false; | |
| delete bag[name]; | |
| writePresetBag(bag); | |
| return true; | |
| } | |
| // Replace the live session storage with a preset's snapshot. Caller is | |
| // expected to force-remount the panel afterward so its useState mirrors | |
| // pick up the new shape; localStorage alone won't reset mounted state. | |
| export function loadPresetIntoSession(name) { | |
| const bag = readPresetBag(); | |
| const preset = bag[name]; | |
| if (!preset) return false; | |
| try { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(preset)); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| const CHANNEL_DEFAULT = { | |
| prompt: '', | |
| duration: 8, | |
| durationMode: 'seconds', | |
| bars: 4, | |
| looping: true, | |
| muted: false, | |
| soloed: false, | |
| batchSize: 1, | |
| knobs: { gain: -6, pan: 0, filter: 18000, delay: 0, reverb: 0 }, | |
| }; | |
| function defaultSession(channelCount) { | |
| return { | |
| bpm: 120, | |
| launchQuantum: 4, | |
| masterDb: 0, | |
| injectBpm: true, | |
| linkEnabled: false, | |
| selectedModel: '', | |
| selectedUnwrappedModel: '', | |
| steps: 250, | |
| randomSeed: true, | |
| seedValue: '', | |
| cueDeviceId: '', | |
| channels: Array.from({ length: channelCount }, () => ({ | |
| ...CHANNEL_DEFAULT, | |
| knobs: { ...CHANNEL_DEFAULT.knobs }, | |
| })), | |
| }; | |
| } | |
| function loadSession(channelCount) { | |
| const fallback = defaultSession(channelCount); | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| if (!raw) return fallback; | |
| const parsed = JSON.parse(raw); | |
| // Merge against defaults so older saves don't crash on missing fields. | |
| // Length shifts (channel count change between releases) are absorbed | |
| // by always producing exactly `channelCount` channels. | |
| const channels = Array.from({ length: channelCount }, (_, i) => ({ | |
| ...CHANNEL_DEFAULT, | |
| ...(parsed.channels?.[i] || {}), | |
| knobs: { ...CHANNEL_DEFAULT.knobs, ...(parsed.channels?.[i]?.knobs || {}) }, | |
| })); | |
| return { ...fallback, ...parsed, channels }; | |
| } catch { | |
| return fallback; | |
| } | |
| } | |
| export function usePerformanceSession(channelCount = 4) { | |
| const [session, setSession] = useState(() => loadSession(channelCount)); | |
| const persistTimerRef = useRef(null); | |
| // Knobs and sliders fire many times per second; debounce writes so we don't | |
| // hammer localStorage. Last-write-wins is fine for session continuity. | |
| useEffect(() => { | |
| if (persistTimerRef.current) clearTimeout(persistTimerRef.current); | |
| persistTimerRef.current = setTimeout(() => { | |
| try { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); | |
| } catch { /* quota or serialization — non-fatal */ } | |
| }, 250); | |
| return () => { | |
| if (persistTimerRef.current) clearTimeout(persistTimerRef.current); | |
| }; | |
| }, [session]); | |
| const updateGlobal = useCallback((key, value) => { | |
| setSession(prev => (prev[key] === value ? prev : { ...prev, [key]: value })); | |
| }, []); | |
| const updateChannel = useCallback((index, partial) => { | |
| setSession(prev => { | |
| const channels = prev.channels.slice(); | |
| channels[index] = { ...channels[index], ...partial }; | |
| return { ...prev, channels }; | |
| }); | |
| }, []); | |
| return { session, updateGlobal, updateChannel }; | |
| } | |