321 lines
9.5 KiB
TypeScript
Executable file
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 []
|
|
}
|
|
}
|