/** * Persistence. Voice metadata, training-clip descriptions, and cached * feature vectors live in localStorage; the clips' raw audio lives in * IndexedDB keyed by clip id, so a training can be reopened, re-detected, * and re-labelled later. The two stores can drift only in one harmless * direction: a clip whose audio is missing simply cannot be re-edited, * while its cached features keep classifying. */ 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 } /** 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'; export function loadVoices(): Voice[] { try { const raw = localStorage.getItem(KEY); if (raw) return (JSON.parse(raw) as { voices: Voice[] }).voices; const old = localStorage.getItem(KEY_V1); if (old) { // v1 stored a flat training list and a synth preset per voice. const v1 = JSON.parse(old) as { voices: { id: string; name: string; color: string; preset: string; train: TrainHit[] }[] }; 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; } } catch { /* a corrupt store starts fresh */ } return []; } export function saveVoices(voices: Voice[]): void { localStorage.setItem(KEY, JSON.stringify({ voices })); } export function clearStore(): void { localStorage.removeItem(KEY); } // ---- clip audio in IndexedDB ------------------------------------------------ let dbPromise: Promise | null = null; function db(): Promise { return (dbPromise ??= new Promise((resolve, reject) => { const req = indexedDB.open('instrawave-compose', 1); req.onupgradeneeded = () => req.result.createObjectStore('clips'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); })); } function op(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest): Promise { return db().then((d) => new Promise((resolve, reject) => { const req = fn(d.transaction('clips', mode).objectStore('clips')); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); })); } export function putClipAudio(id: string, samples: Float32Array, sr: number): Promise { return op('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>('readonly', (s) => s.get(id)) .then((r) => r ?? null); } export function deleteClipAudio(id: string): Promise { return op('readwrite', (s) => s.delete(id)); } export function clearClipAudio(): Promise { return op('readwrite', (s) => s.clear()); }