// IndexedDB-backed soundboard. // // Two object stores: // `sounds` — one record per user-added sound, keyed by uuid. Contains both // metadata (name, category, hotkey, gain, order, timestamps) and // the raw blob so decoding can pull everything in one get(). // `prefs` — single "prefs" record with global soundboard state (master // volume, local monitor volume). // // CRUD helpers surface a SoundboardEntry shape without the blob so call sites // that only need metadata (list views, hotkey registration) don't pull the // audio payload into memory. `getSoundBlob(id)` fetches the blob on demand. export interface SoundboardEntry { id: string; name: string; mime: string; size: number; category: string | null; hotkey: string | null; gain: number; // 0..1 order: number; // ascending within (category, uncategorized) bucket createdAt: number; updatedAt: number; } export interface SoundboardPrefs { masterGain: number; // 0..1 monitorGain: number; // 0..1 } interface StoredSound extends SoundboardEntry { blob: Blob; } export const DEFAULT_PREFS: SoundboardPrefs = { masterGain: 0.8, monitorGain: 0.5, }; export const MAX_SOUND_BYTES = 5 * 1024 * 1024; // 5 MB per clip // --- Change observer ------------------------------------------------------ // Synchronous tiny pub-sub so interested modules (hotkey registry, in-call // panel, settings dialog) refresh when the manifest mutates in any tab. type Listener = () => void; const listeners = new Set(); export function subscribeSoundboardChanges(l: Listener): () => void { listeners.add(l); return () => { listeners.delete(l); }; } function notifyChange(): void { for (const l of listeners) { try { l(); } catch (err: unknown) { console.warn('soundboard change listener threw', err); } } } const DB_NAME = 'netralax-soundboard'; const DB_VERSION = 1; const SOUNDS_STORE = 'sounds'; const PREFS_STORE = 'prefs'; const PREFS_KEY = 'prefs'; let dbPromise: Promise | null = null; function openDb(): Promise { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(SOUNDS_STORE)) { db.createObjectStore(SOUNDS_STORE, { keyPath: 'id' }); } if (!db.objectStoreNames.contains(PREFS_STORE)) { db.createObjectStore(PREFS_STORE); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error ?? new Error('indexedDB open failed')); }); return dbPromise; } function tx( store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest | void, ): Promise { return openDb().then( (db) => new Promise((resolve, reject) => { const t = db.transaction(store, mode); const s = t.objectStore(store); let result: T | undefined = undefined; const req = fn(s); if (req) { req.onsuccess = () => { result = req.result; }; req.onerror = () => reject(req.error); } t.oncomplete = () => resolve(result); t.onerror = () => reject(t.error); t.onabort = () => reject(t.error); }), ); } function stripBlob(stored: StoredSound): SoundboardEntry { const { blob: _blob, ...rest } = stored; return rest; } function clamp01(v: number): number { if (!Number.isFinite(v)) return 0; if (v < 0) return 0; if (v > 1) return 1; return v; } function genId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return 'sb-' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); } export async function listSounds(): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const t = db.transaction(SOUNDS_STORE, 'readonly'); const s = t.objectStore(SOUNDS_STORE); const req = s.getAll(); req.onsuccess = () => { const all = (req.result as StoredSound[]).map(stripBlob); // Stable sort: category name asc (null last), then order asc, then // createdAt as a tie-breaker so freshly added sounds don't leapfrog. all.sort((a, b) => { const catCmp = compareCategory(a.category, b.category); if (catCmp !== 0) return catCmp; if (a.order !== b.order) return a.order - b.order; return a.createdAt - b.createdAt; }); resolve(all); }; req.onerror = () => reject(req.error); }); } function compareCategory(a: string | null, b: string | null): number { if (a === b) return 0; if (a === null) return 1; // uncategorized last if (b === null) return -1; return a.localeCompare(b); } export async function listCategories(): Promise { const all = await listSounds(); const set = new Set(); for (const s of all) { if (s.category) set.add(s.category); } return Array.from(set).sort((a, b) => a.localeCompare(b)); } async function nextOrderFor(category: string | null): Promise { const all = await listSounds(); let max = -1; for (const s of all) { if (s.category === category && s.order > max) max = s.order; } return max + 1; } export interface AddSoundInput { file: File; name?: string; category?: string | null; } export async function addSound(input: AddSoundInput): Promise { const { file, name, category = null } = input; if (file.size === 0) throw new Error('empty file'); if (file.size > MAX_SOUND_BYTES) throw new Error('sound_too_large'); const mime = file.type || 'application/octet-stream'; if (!mime.startsWith('audio/')) throw new Error('sound_not_audio'); const now = Date.now(); const trimmedName = (name ?? file.name.replace(/\.[^.]+$/, '')).trim() || 'Untitled'; const entry: StoredSound = { id: genId(), name: trimmedName, mime, size: file.size, category: category ?? null, hotkey: null, gain: 1, order: await nextOrderFor(category ?? null), createdAt: now, updatedAt: now, blob: file, }; await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(entry)); notifyChange(); return stripBlob(entry); } export interface UpdateSoundPatch { name?: string; category?: string | null; hotkey?: string | null; gain?: number; } export async function updateSound( id: string, patch: UpdateSoundPatch, ): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const t = db.transaction(SOUNDS_STORE, 'readwrite'); const s = t.objectStore(SOUNDS_STORE); const getReq = s.get(id); getReq.onsuccess = () => { const current = getReq.result as StoredSound | undefined; if (!current) { reject(new Error('sound_not_found')); return; } const nextCategory = patch.category !== undefined ? patch.category : current.category; const categoryChanged = nextCategory !== current.category; const next: StoredSound = { ...current, ...(patch.name !== undefined ? { name: patch.name.trim() || current.name } : {}), ...(patch.category !== undefined ? { category: nextCategory } : {}), ...(patch.hotkey !== undefined ? { hotkey: patch.hotkey } : {}), ...(patch.gain !== undefined ? { gain: clamp01(patch.gain) } : {}), updatedAt: Date.now(), }; // When moving categories, append to the destination's end so the move // doesn't collide with existing order values. if (categoryChanged) { const getAll = s.getAll(); getAll.onsuccess = () => { const all = getAll.result as StoredSound[]; let max = -1; for (const e of all) { if (e.category === nextCategory && e.order > max) max = e.order; } next.order = max + 1; const putReq = s.put(next); putReq.onsuccess = () => { notifyChange(); resolve(stripBlob(next)); }; putReq.onerror = () => reject(putReq.error); }; getAll.onerror = () => reject(getAll.error); return; } const putReq = s.put(next); putReq.onsuccess = () => { notifyChange(); resolve(stripBlob(next)); }; putReq.onerror = () => reject(putReq.error); }; getReq.onerror = () => reject(getReq.error); t.onerror = () => reject(t.error); }); } export async function deleteSound(id: string): Promise { await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id)); notifyChange(); } export async function reorderCategory( category: string | null, orderedIds: string[], ): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const t = db.transaction(SOUNDS_STORE, 'readwrite'); const s = t.objectStore(SOUNDS_STORE); let remaining = orderedIds.length; if (remaining === 0) { resolve(); return; } orderedIds.forEach((id, idx) => { const getReq = s.get(id); getReq.onsuccess = () => { const current = getReq.result as StoredSound | undefined; if (!current || current.category !== category) { remaining--; if (remaining === 0) resolve(); return; } const next: StoredSound = { ...current, order: idx, updatedAt: Date.now() }; const putReq = s.put(next); putReq.onsuccess = () => { remaining--; if (remaining === 0) resolve(); }; putReq.onerror = () => reject(putReq.error); }; getReq.onerror = () => reject(getReq.error); }); t.oncomplete = () => notifyChange(); t.onerror = () => reject(t.error); }); } export async function getSoundBlob(id: string): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const t = db.transaction(SOUNDS_STORE, 'readonly'); const s = t.objectStore(SOUNDS_STORE); const req = s.get(id); req.onsuccess = () => { const cur = req.result as StoredSound | undefined; resolve(cur ? cur.blob : null); }; req.onerror = () => reject(req.error); }); } export async function getPrefs(): Promise { const raw = await tx('prefs', 'readonly', (s) => s.get(PREFS_KEY) as IDBRequest, ); if (!raw) return { ...DEFAULT_PREFS }; return { masterGain: clamp01(raw.masterGain ?? DEFAULT_PREFS.masterGain), monitorGain: clamp01(raw.monitorGain ?? DEFAULT_PREFS.monitorGain), }; } export async function updatePrefs(patch: Partial): Promise { const cur = await getPrefs(); const next: SoundboardPrefs = { masterGain: patch.masterGain !== undefined ? clamp01(patch.masterGain) : cur.masterGain, monitorGain: patch.monitorGain !== undefined ? clamp01(patch.monitorGain) : cur.monitorGain, }; await tx('prefs', 'readwrite', (s) => s.put(next, PREFS_KEY)); return next; } // Convenience: checks whether `accelerator` is already bound to a sound other // than `excludeId`. Pair with PTT check at the CallContext layer. export async function isHotkeyTaken( accelerator: string, excludeId: string | null = null, ): Promise { const all = await listSounds(); for (const s of all) { if (s.hotkey === accelerator && s.id !== excludeId) return true; } return false; } // --- Sync-engine bypass helpers ------------------------------------------ // Used by useSoundboardSync to write/delete IndexedDB rows without firing // notifyChange — pulls and remote-driven deletes are not "edits". If they // triggered notifyChange the engine would loop: // push debounce → upsert → realtime → pull → notifyChange → push debounce → ... export async function putRawStoredSound(stored: { id: string; name: string; mime: string; size: number; category: string | null; hotkey: string | null; gain: number; order: number; createdAt: number; updatedAt: number; blob: Blob; }): Promise { await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored)); } export async function deleteRawStoredSound(id: string): Promise { await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id)); } export async function getRawStoredSound(id: string): Promise<{ id: string; name: string; mime: string; size: number; category: string | null; hotkey: string | null; gain: number; order: number; createdAt: number; updatedAt: number; blob: Blob; } | null> { const db = await openDb(); return new Promise((resolve, reject) => { const t = db.transaction(SOUNDS_STORE, 'readonly'); const s = t.objectStore(SOUNDS_STORE); const req = s.get(id); req.onsuccess = () => resolve((req.result as any) ?? null); req.onerror = () => reject(req.error); }); }