// AVA — client state store (zustand) // // Single source of truth for the UI. The engine is the render authority; this // store mirrors the pieces the UI needs (throttled stats, configs, script // text, presets, source state) and applies config changes to the engine live. // Config changes persist to localStorage immediately and to /api/settings // debounced (~600 ms). import { create } from 'zustand' import type { EngineStats } from '@/lib/ava/engine' import { getEngine } from '@/lib/ava/engine' import type { TapResult } from '@/lib/ava/beat' import type { BandConfig, BeatConfig, BeatSource } from '@/lib/ava/types' import { DEFAULT_BAND_CONFIG, DEFAULT_BEAT_CONFIG } from '@/lib/ava/types' export interface PresetItem { id: string name: string author: string description: string content: string isBuiltIn: boolean updatedAt: string } export type AudioSourceState = 'idle' | 'demo' | 'mic' | 'file' | 'link' /** EngineStats augmented with the beat counter (read alongside the callback). */ export interface AvaStats extends EngineStats { beatCount: number } export interface BeatFlash { seq: number at: number source: BeatSource } // --------------------------------------------------------------------------- // localStorage helpers (all guarded — SSR / private mode safe) // --------------------------------------------------------------------------- const LS_BAND = 'ava.bandConfig' const LS_BEAT = 'ava.beatConfig' const LS_SCRIPT = 'ava.defaultScript' function lsGetJSON(key: string): T | null { try { const raw = localStorage.getItem(key) return raw ? (JSON.parse(raw) as T) : null } catch { return null } } function lsSetJSON(key: string, value: unknown): void { try { localStorage.setItem(key, JSON.stringify(value)) } catch { /* ignore */ } } export function lsGetString(key: string): string | null { try { return localStorage.getItem(key) } catch { return null } } export function lsSetString(key: string, value: string): void { try { localStorage.setItem(key, value) } catch { /* ignore */ } } // --------------------------------------------------------------------------- // debounced /api/settings persistence // --------------------------------------------------------------------------- let bandPersistTimer: ReturnType | null = null let beatPersistTimer: ReturnType | null = null function putSettings(payload: Record): void { fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).catch(() => { /* offline — localStorage copy already saved */ }) } function persistBandConfig(cfg: BandConfig): void { lsSetJSON(LS_BAND, cfg) if (bandPersistTimer) clearTimeout(bandPersistTimer) bandPersistTimer = setTimeout(() => putSettings({ bandConfig: cfg }), 600) } function persistBeatConfig(cfg: BeatConfig): void { lsSetJSON(LS_BEAT, cfg) if (beatPersistTimer) clearTimeout(beatPersistTimer) beatPersistTimer = setTimeout(() => putSettings({ beatConfig: cfg }), 600) } export function persistDefaultScript(text: string): void { lsSetString(LS_SCRIPT, text) putSettings({ defaultScript: text }) } // --------------------------------------------------------------------------- // shape guards for data coming from the API / localStorage // --------------------------------------------------------------------------- export function isBandConfig(v: unknown): v is BandConfig { if (!v || typeof v !== 'object') return false const c = v as Record return ( ['bassLow', 'bassHigh', 'midLow', 'midHigh', 'highLow', 'highHigh', 'bassGain', 'midGain', 'highGain'].every( (k) => typeof c[k] === 'number' && Number.isFinite(c[k]), ) ) } export function isBeatConfig(v: unknown): v is BeatConfig { if (!v || typeof v !== 'object') return false const c = v as Record return ( typeof c.threshold === 'number' && typeof c.minInterval === 'number' && typeof c.floorLevel === 'number' && typeof c.prediction === 'boolean' ) } export function readStoredBandConfig(): BandConfig | null { const v = lsGetJSON(LS_BAND) return isBandConfig(v) ? v : null } export function readStoredBeatConfig(): BeatConfig | null { const v = lsGetJSON(LS_BEAT) return isBeatConfig(v) ? v : null } // --------------------------------------------------------------------------- // the store // --------------------------------------------------------------------------- interface AvaState { /** Throttled engine stats (~8 Hz). */ stats: AvaStats | null /** Every detected / predicted / tapped beat pulse (drives the green dot). */ beatFlash: BeatFlash | null /** Result of the last "Set Beat" tap. */ lastTap: TapResult | null /** Mirrors engine.audio.kind — kept in sync by the audio-source helpers. */ source: AudioSourceState /** File name / stream title for the active source (footer display). */ sourceTitle: string | null /** "Stream a link" dialog visibility (opened from header + idle overlay). */ linkDialogOpen: boolean demoBpm: number /** Editor content (the live script text). */ scriptText: string bandConfig: BandConfig beatConfig: BeatConfig presets: PresetItem[] tab: string setStats: (s: AvaStats) => void flashBeat: (source: BeatSource) => void setLastTap: (t: TapResult) => void setSource: (s: AudioSourceState) => void setSourceTitle: (t: string | null) => void setLinkDialogOpen: (b: boolean) => void setDemoBpm: (bpm: number) => void setScriptText: (t: string) => void /** Apply a full config (initial load) — engine + store, no persist. */ applyBandConfig: (cfg: BandConfig) => void applyBeatConfig: (cfg: BeatConfig) => void /** Set a single value — engine + store + debounced persist, with clamping. */ setBandValue: (key: keyof BandConfig, value: number) => void setBeatValue: (key: keyof BeatConfig, value: number | boolean) => void resetBandConfig: () => void resetBeatConfig: () => void setPresets: (p: PresetItem[]) => void setTab: (t: string) => void } let flashSeq = 0 export const useAvaStore = create()((set, get) => ({ stats: null, beatFlash: null, lastTap: null, source: 'idle', sourceTitle: null, linkDialogOpen: false, demoBpm: 124, scriptText: '', bandConfig: { ...DEFAULT_BAND_CONFIG }, beatConfig: { ...DEFAULT_BEAT_CONFIG }, presets: [], tab: 'presets', setStats: (s) => set({ stats: s }), flashBeat: (source) => set({ beatFlash: { seq: ++flashSeq, at: performance.now(), source } }), setLastTap: (t) => set({ lastTap: t }), setSource: (s) => set({ source: s }), setSourceTitle: (t) => set({ sourceTitle: t }), setLinkDialogOpen: (b) => set({ linkDialogOpen: b }), setDemoBpm: (bpm) => { const v = Math.min(200, Math.max(60, Math.round(bpm))) getEngine().audio.setDemoBpm(v) set({ demoBpm: v }) }, setScriptText: (t) => set({ scriptText: t }), applyBandConfig: (cfg) => { getEngine().bandConfig = cfg set({ bandConfig: cfg }) }, applyBeatConfig: (cfg) => { getEngine().beatConfig = cfg set({ beatConfig: cfg }) }, setBandValue: (key, value) => { const cur = get().bandConfig let v = value // enforce low < high with a sensible minimum gap per band switch (key) { case 'bassLow': v = Math.min(v, cur.bassHigh - 10) break case 'bassHigh': v = Math.max(v, cur.bassLow + 10) break case 'midLow': v = Math.min(v, cur.midHigh - 50) break case 'midHigh': v = Math.max(v, cur.midLow + 50) break case 'highLow': v = Math.min(v, cur.highHigh - 200) break case 'highHigh': v = Math.max(v, cur.highLow + 200) break case 'bassGain': case 'midGain': case 'highGain': v = Math.min(4, Math.max(0.1, v)) break } const cfg = { ...cur, [key]: v } getEngine().bandConfig = cfg set({ bandConfig: cfg }) persistBandConfig(cfg) }, setBeatValue: (key, value) => { const cur = get().beatConfig let v: number | boolean = value if (key === 'threshold') v = Math.min(2.5, Math.max(1.1, value as number)) if (key === 'minInterval') v = Math.min(600, Math.max(150, Math.round(value as number))) if (key === 'floorLevel') v = Math.min(0.5, Math.max(0, value as number)) const cfg = { ...cur, [key]: v } as BeatConfig getEngine().beatConfig = cfg set({ beatConfig: cfg }) persistBeatConfig(cfg) }, resetBandConfig: () => { const cfg = { ...DEFAULT_BAND_CONFIG } getEngine().bandConfig = cfg set({ bandConfig: cfg }) persistBandConfig(cfg) }, resetBeatConfig: () => { const cfg = { ...DEFAULT_BEAT_CONFIG } getEngine().beatConfig = cfg set({ beatConfig: cfg }) persistBeatConfig(cfg) }, setPresets: (p) => set({ presets: p }), setTab: (t) => set({ tab: t }), })) // --------------------------------------------------------------------------- // preset list fetching (shared by Presets tab + initial load) // --------------------------------------------------------------------------- export async function refreshPresets(): Promise { try { const res = await fetch('/api/presets') if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = (await res.json()) as { presets?: PresetItem[] } const presets = data.presets ?? [] useAvaStore.getState().setPresets(presets) return presets } catch { return [] } }