openvisengine/src/lib/store.ts
2026-10-26 20:45:47 +00:00

321 lines
9.5 KiB
TypeScript
Executable file

// 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<T>(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<typeof setTimeout> | null = null
let beatPersistTimer: ReturnType<typeof setTimeout> | null = null
function putSettings(payload: Record<string, unknown>): 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<string, unknown>
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<string, unknown>
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<unknown>(LS_BAND)
return isBandConfig(v) ? v : null
}
export function readStoredBeatConfig(): BeatConfig | null {
const v = lsGetJSON<unknown>(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<AvaState>()((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<PresetItem[]> {
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 []
}
}