File size: 4,676 Bytes
9571865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7986a9
9571865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7986a9
9571865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
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 };
}