/** * Persistence. Small structured state lives in localStorage: voices with * their training-clip descriptions and cached features, per-instrument * geometry overrides, the take's events, and UI knobs. Bulk audio and * binary data live in IndexedDB: training-clip audio, the take's audio, * and the bakes (the drum's is a couple of hundred MB, which is exactly * why it is worth keeping). The stores can drift only in harmless * directions: a clip whose audio is missing cannot be re-edited but keeps * classifying, and a bake whose key no longer matches is ignored. */ import type { Binding } from './render/types.ts'; export interface TrainHit { vec: number[]; peakDb: number; } export interface TrainClip { id: string; sr: number; sens: number; // the sensitivity the onsets were detected with onsets: number[]; // seconds excluded: number[]; // indices into onsets hits: TrainHit[]; // features of the included onsets, cached } export interface Voice { id: string; name: string; color: string; binding: Binding; clips: TrainClip[]; loose: TrainHit[]; // hits learned from takes; they belong to no clip } export interface TakeHit { id: string; t: number; voiceId: string; gain: number; peakDb: number; vec: number[] | null; // null for hand-added hits s0: number; s1: number; // sample bounds in the take clip; -1 if hand-added } /** The pooled training set a voice contributes to classification. */ export function trainPool(v: Voice): TrainHit[] { return [...v.clips.flatMap((c) => c.hits), ...v.loose]; } const KEY = 'instrawave-compose/v2'; const KEY_V1 = 'instrawave-compose/v1'; const KEY_IW = 'instrawave-compose/iw'; const KEY_TAKE = 'instrawave-compose/take'; const KEY_UI = 'instrawave-compose/ui'; function read(key: string): T | null { try { const raw = localStorage.getItem(key); return raw ? (JSON.parse(raw) as T) : null; } catch { return null; } } export function loadVoices(): Voice[] { const doc = read<{ voices: Voice[] }>(KEY); if (doc) return doc.voices; // v1 stored a flat training list and a synth preset per voice. const v1 = read<{ voices: { id: string; name: string; color: string; preset: string; train: TrainHit[] }[] }>(KEY_V1); if (v1) { const voices: Voice[] = v1.voices.map((v) => ({ id: v.id, name: v.name, color: v.color, binding: { kind: 'synth', preset: v.preset as ('kick' | 'snare' | 'hat' | 'tom') }, clips: [], loose: v.train, })); localStorage.setItem(KEY, JSON.stringify({ voices })); localStorage.removeItem(KEY_V1); return voices; } return []; } export function saveVoices(voices: Voice[]): void { localStorage.setItem(KEY, JSON.stringify({ voices })); } /** Per-instrument geometry and accuracy overrides. */ export function loadIwOverrides(): Record> { return read(KEY_IW) ?? {}; } export function saveIwOverrides(o: Record>): void { localStorage.setItem(KEY_IW, JSON.stringify(o)); } export function loadTakeHits(): TakeHit[] | null { return read(KEY_TAKE); } export function saveTakeHits(hits: TakeHit[]): void { localStorage.setItem(KEY_TAKE, JSON.stringify(hits)); } export interface UiState { takeSens: number; captureSens: number; } export function loadUi(): UiState { return read(KEY_UI) ?? { takeSens: 0.6, captureSens: 0.6 }; } export function saveUi(ui: UiState): void { localStorage.setItem(KEY_UI, JSON.stringify(ui)); } export function clearStore(): void { for (const k of [KEY, KEY_IW, KEY_TAKE, KEY_UI]) localStorage.removeItem(k); } // ---- bulk data in IndexedDB --------------------------------------------------- const STORES = ['clips', 'takes', 'bakes', 'bakemeta'] as const; let dbPromise: Promise | null = null; function db(): Promise { return (dbPromise ??= new Promise((resolve, reject) => { const req = indexedDB.open('instrawave-compose', 2); req.onupgradeneeded = () => { for (const s of STORES) { if (!req.result.objectStoreNames.contains(s)) req.result.createObjectStore(s); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); })); } function op(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise { return db().then((d) => new Promise((resolve, reject) => { const req = fn(d.transaction(store, mode).objectStore(store)); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); })); } export function putClipAudio(id: string, samples: Float32Array, sr: number): Promise { return op('clips', 'readwrite', (s) => s.put({ samples, sr }, id)); } export function getClipAudio(id: string): Promise<{ samples: Float32Array; sr: number } | null> { return op<{ samples: Float32Array; sr: number } | undefined>('clips', 'readonly', (s) => s.get(id)) .then((r) => r ?? null); } export function deleteClipAudio(id: string): Promise { return op('clips', 'readwrite', (s) => s.delete(id)); } export function putTakeAudio(samples: Float32Array, sr: number): Promise { return op('takes', 'readwrite', (s) => s.put({ samples, sr }, 'take')); } export function getTakeAudio(): Promise<{ samples: Float32Array; sr: number } | null> { return op<{ samples: Float32Array; sr: number } | undefined>('takes', 'readonly', (s) => s.get('take')) .then((r) => r ?? null); } /** Bake bytes and their key are separate stores so staleness can be * checked without deserializing hundreds of MB. */ export async function putBake(name: string, key: string, bytes: Uint8Array): Promise { await op('bakes', 'readwrite', (s) => s.put(bytes, name)); await op('bakemeta', 'readwrite', (s) => s.put(key, name)); } export function getBakeKey(name: string): Promise { return op('bakemeta', 'readonly', (s) => s.get(name)).then((r) => r ?? null); } export function getBakeBytes(name: string): Promise { return op('bakes', 'readonly', (s) => s.get(name)).then((r) => r ?? null); } export async function clearBulk(): Promise { for (const s of STORES) await op(s, 'readwrite', (st) => st.clear()); }