debugging web app

This commit is contained in:
Kyle Henderson
2026-03-12 22:16:28 -04:00
parent bf68c1fea0
commit 16ecc34b67
11 changed files with 2181 additions and 68 deletions
+5
View File
@@ -6,3 +6,8 @@ __pycache__/
*.log
tasks.txt
nul
# Web
web/node_modules/
web/dist/
web/.vite/
+3 -3
View File
@@ -280,13 +280,13 @@ iterations_per_frame: 50
moire_blend: 0
moire_type_1: 0
spatial_freq_1: 10.0
angle_1: 321.06482
angle_1: 4.755799999999994
zoom_1: 1.0
moire_center_x_1: 320
moire_center_y_1: 240
moire_type_2: 0
spatial_freq_2: 1.0
angle_2: 0.0003900000000101045
angle_2: 179.24602
zoom_2: 1.0
moire_center_x_2: 320
moire_center_y_2: 240
@@ -476,7 +476,7 @@ src_2_animations_moire_rot2_noise_repeat: 100
src_2_animations_moire_rot2_noise_base: 456
src_2_animations_moire_rot2_cutoff_min: 0
src_2_animations_moire_rot2_cutoff_max: 360
source_1: DEVICE_0
source_1: METABALLS
source_2: METABALLS
video_file_src1: 13241754_3840_2160_60fps.mp4
video_file_src2: 13241754_3840_2160_60fps.mp4
+1
View File
@@ -0,0 +1 @@
1773365480.5726748
+11 -2
View File
@@ -417,10 +417,14 @@ def main(settings):
# post_effects.params,
# mixer.params,)
# Collect all params (used by API server and MIDI mapper)
# Collect all params (used by API server and MIDI mapper).
# Effect managers share param names (e.g. "alpha" in SRC_1, SRC_2, POST),
# so prefix each with its group name to avoid collisions.
all_params = ParamTable()
for effect_mgr in effects:
all_params.params.update(effect_mgr.params.params)
group_key = effect_mgr.group.name if hasattr(effect_mgr.group, 'name') else str(effect_mgr.group)
for k, v in effect_mgr.params.params.items():
all_params.params[f"{group_key}.{k}"] = v
all_params.params.update(mixer.params.params)
all_params.params.update(audio_params.params)
@@ -436,7 +440,12 @@ def main(settings):
# Initialize API server if requested (midi_mapper set below after it's created)
api_server = None
if settings.api:
osc_banks = {
(effect_mgr.group.name if hasattr(effect_mgr.group, 'name') else str(effect_mgr.group)): effect_mgr.oscs
for effect_mgr in effects
}
api_server = APIServer(all_params, mixer=mixer, save_controller=save_controller,
osc_banks=osc_banks,
host=settings.api_host, port=settings.api_port)
api_server.start()
+161 -37
View File
@@ -14,35 +14,56 @@ import threading
import uvicorn
import io
import cv2
from lfo import LFO, LFOShape
log = logging.getLogger(__name__)
class ParamValue(BaseModel):
"""Request model for setting a parameter value."""
value: float | int
value: Any
class ParamInfo(BaseModel):
"""Response model for parameter information."""
name: str
value: float | int
min: float | int
max: float | int
default: float | int
value: Any
min: Any
max: Any
default: Any
group: str
subgroup: str
type: str
options: Optional[List[str]] = None
class LfoInfo(BaseModel):
param_name: str
osc_name: str
shape: str
frequency: float
amplitude: float
phase: float
seed: float
class LfoRequest(BaseModel):
shape: str = "SINE"
frequency: float = 0.5
amplitude: Optional[float] = None
phase: float = 0.0
seed: float = 0.0
class APIServer:
"""FastAPI server for controlling video synthesizer parameters."""
def __init__(self, params, mixer=None, save_controller=None, midi_mapper=None, host="127.0.0.1", port=8000):
def __init__(self, params, mixer=None, save_controller=None, midi_mapper=None, osc_banks=None, host="127.0.0.1", port=8000):
self.params = params
self.mixer = mixer
self.save_controller = save_controller
self.midi_mapper = midi_mapper
self.osc_banks = osc_banks or {} # group_prefix → OscBank
self._lfo_map = {} # param_name → LFO
self.host = host
self.port = port
self.app = FastAPI(
@@ -79,40 +100,49 @@ class APIServer:
}
}
def _serialize_options(param) -> Optional[List[str]]:
"""Convert a Param's options to a flat list of strings for the API."""
opts = param.options
if opts is None:
return None
if isinstance(opts, dict):
return list(opts.keys())
if isinstance(opts, list):
return [str(o) for o in opts]
# Enum class
try:
return [e.name for e in opts]
except TypeError:
return [str(opts)]
def _enum_name(v) -> str:
"""Return the enum member name if v is an enum, else str(v)."""
return v.name if hasattr(v, 'name') else str(v)
def _make_param_info(name, param) -> ParamInfo:
return ParamInfo(
name=name,
value=param.value,
min=param.min,
max=param.max,
default=param.default,
group=_enum_name(param.group),
subgroup=_enum_name(param.subgroup),
type=_enum_name(param.type),
options=_serialize_options(param),
)
@self.app.get("/params", response_model=List[ParamInfo])
async def list_params():
"""Get list of all parameters with their current values."""
result = []
for name, param in self.params.params.items():
result.append(ParamInfo(
name=name,
value=param.value,
min=param.min,
max=param.max,
default=param.default,
group=str(param.group),
subgroup=str(param.subgroup),
type=str(param.type)
))
return result
return [_make_param_info(n, p) for n, p in self.params.params.items()]
@self.app.get("/params/{param_name}", response_model=ParamInfo)
async def get_param(param_name: str):
"""Get specific parameter info and current value."""
if param_name not in self.params.params:
raise HTTPException(status_code=404, detail=f"Parameter '{param_name}' not found")
param = self.params.params[param_name]
return ParamInfo(
name=param_name,
value=param.value,
min=param.min,
max=param.max,
default=param.default,
group=str(param.group),
subgroup=str(param.subgroup),
type=str(param.type)
)
return _make_param_info(param_name, self.params.params[param_name])
@self.app.put("/params/{param_name}")
async def set_param(param_name: str, param_value: ParamValue):
@@ -122,12 +152,13 @@ class APIServer:
param = self.params.params[param_name]
# Validate value is within bounds
if param_value.value < param.min or param_value.value > param.max:
raise HTTPException(
status_code=400,
detail=f"Value {param_value.value} out of range [{param.min}, {param.max}]"
)
# Validate numeric bounds (skip for dropdown string values)
if isinstance(param_value.value, (int, float)):
if param_value.value < param.min or param_value.value > param.max:
raise HTTPException(
status_code=400,
detail=f"Value {param_value.value} out of range [{param.min}, {param.max}]"
)
# Set the value
param.value = param_value.value
@@ -246,6 +277,99 @@ class APIServer:
raise HTTPException(status_code=503, detail="MidiMapper not available")
return self.midi_mapper.get_learn_state()
# --- LFO endpoints ---
def _get_osc_bank(param_name: str):
prefix = param_name.split('.')[0] if '.' in param_name else None
return self.osc_banks.get(prefix)
@self.app.get("/lfo", response_model=List[LfoInfo])
async def list_lfos():
result = []
for pname, osc in self._lfo_map.items():
try:
shape_name = LFOShape(int(osc.shape.value)).name
except (ValueError, KeyError):
shape_name = "SINE"
result.append(LfoInfo(
param_name=pname,
osc_name=osc.name,
shape=shape_name,
frequency=float(osc.frequency.value),
amplitude=float(osc.amplitude.value),
phase=float(osc.phase.value),
seed=float(osc.seed.value),
))
return result
@self.app.get("/lfo/{param_name:path}", response_model=LfoInfo)
async def get_lfo(param_name: str):
if param_name not in self._lfo_map:
raise HTTPException(status_code=404, detail=f"No LFO for param '{param_name}'")
osc = self._lfo_map[param_name]
try:
shape_name = LFOShape(int(osc.shape.value)).name
except (ValueError, KeyError):
shape_name = "SINE"
return LfoInfo(
param_name=param_name,
osc_name=osc.name,
shape=shape_name,
frequency=float(osc.frequency.value),
amplitude=float(osc.amplitude.value),
phase=float(osc.phase.value),
seed=float(osc.seed.value),
)
@self.app.post("/lfo/{param_name:path}")
async def create_lfo(param_name: str, req: LfoRequest):
if param_name not in self.params.params:
raise HTTPException(status_code=404, detail=f"Param '{param_name}' not found")
osc_bank = _get_osc_bank(param_name)
if osc_bank is None:
raise HTTPException(status_code=400, detail=f"No oscillator bank for param '{param_name}'")
param = self.params.params[param_name]
# Remove existing LFO for this param if any
if param_name in self._lfo_map:
old_osc = self._lfo_map.pop(param_name)
old_osc.unlink_param()
osc_bank.remove_oscillator(old_osc)
shape_val = LFOShape[req.shape].value if req.shape in LFOShape.__members__ else LFOShape.SINE.value
amplitude = req.amplitude if req.amplitude is not None else (param.max - param.min) / 2
osc_name = f"web_lfo_{id(param)}"
osc = osc_bank.add_oscillator(osc_name, frequency=req.frequency, amplitude=amplitude, phase=req.phase, shape=shape_val)
osc.link_param(param)
osc.seed.value = req.seed
self._lfo_map[param_name] = osc
log.info(f"LFO created for '{param_name}' shape={req.shape} freq={req.frequency}")
return {"success": True, "osc_name": osc_name}
@self.app.put("/lfo/{param_name:path}")
async def update_lfo(param_name: str, req: LfoRequest):
if param_name not in self._lfo_map:
raise HTTPException(status_code=404, detail=f"No LFO for param '{param_name}'")
osc = self._lfo_map[param_name]
if req.shape in LFOShape.__members__:
osc.shape.value = LFOShape[req.shape].value
osc.frequency.value = req.frequency
if req.amplitude is not None:
osc.amplitude.value = req.amplitude
osc.phase.value = req.phase
osc.seed.value = req.seed
return {"success": True}
@self.app.delete("/lfo/{param_name:path}")
async def delete_lfo(param_name: str):
if param_name not in self._lfo_map:
raise HTTPException(status_code=404, detail=f"No LFO for param '{param_name}'")
osc = self._lfo_map.pop(param_name)
osc.unlink_param()
osc_bank = _get_osc_bank(param_name)
if osc_bank:
osc_bank.remove_oscillator(osc)
log.info(f"LFO removed for '{param_name}'")
return {"success": True}
def start(self):
"""Start the API server in a background thread."""
if self._server_thread is not None:
+1677
View File
File diff suppressed because it is too large Load Diff
+101 -1
View File
@@ -128,7 +128,7 @@ html, body, #root {
/* ── Param row ────────────────────────────────────────────────────── */
.param-row {
display: grid;
grid-template-columns: 110px 1fr 52px 24px;
grid-template-columns: 110px 1fr 52px 24px 18px;
align-items: center;
gap: 4px;
padding: 2px 0;
@@ -175,6 +175,24 @@ input[type='range']::-moz-range-thumb {
border: none;
}
.param-row.dropdown {
grid-template-columns: 110px 1fr;
}
.param-select {
background: var(--bg3);
color: var(--text);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 4px;
font-family: var(--font);
font-size: 11px;
outline: none;
width: 100%;
cursor: pointer;
}
.param-select:focus { border-color: var(--accent); }
.param-value-input {
width: 52px;
background: var(--bg3);
@@ -343,3 +361,85 @@ button.btn-active:hover { background: #c0392b; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
select, input { color-scheme: dark; }
/* ── LFO panel ────────────────────────────────────────────────────── */
.param-col {
display: flex;
flex-direction: column;
}
.lfo-btn {
width: 18px;
height: 18px;
padding: 0;
font-size: 12px;
background: transparent;
color: var(--text2);
border: 1px solid transparent;
border-radius: 2px;
cursor: pointer;
flex-shrink: 0;
}
.lfo-btn:hover { color: var(--accent); border-color: var(--accent); }
.lfo-btn.active { color: var(--accent); border-color: var(--accent); }
.lfo-panel {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 4px 6px;
margin-bottom: 2px;
display: flex;
flex-direction: column;
gap: 2px;
}
.lfo-row {
display: grid;
grid-template-columns: 48px 1fr 48px auto;
align-items: center;
gap: 4px;
}
.lfo-label {
font-size: 10px;
color: var(--text2);
text-align: right;
padding-right: 4px;
}
.lfo-range {
width: 100%;
min-width: 0;
}
.lfo-val {
font-size: 10px;
color: var(--text2);
text-align: right;
width: 44px;
}
.lfo-remove-btn {
width: 18px;
height: 18px;
padding: 0;
font-size: 10px;
background: transparent;
color: var(--text2);
border: 1px solid transparent;
border-radius: 2px;
cursor: pointer;
}
.lfo-remove-btn:hover { color: #e55; border-color: #e55; }
.lfo-apply-btn {
height: 18px;
padding: 0 6px;
font-size: 10px;
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 2px;
cursor: pointer;
}
+40
View File
@@ -59,3 +59,43 @@ export function streamUrl() {
// Cache-bust on each call to force reconnect
return `${API_BASE}/stream?t=${Date.now()}`
}
export async function fetchLfos() {
const r = await fetch(`${API_BASE}/lfo`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
}
export async function fetchLfo(paramName) {
const r = await fetch(`${API_BASE}/lfo/${encodeURIComponent(paramName)}`)
if (!r.ok) return null
return r.json()
}
export async function createLfo(paramName, config) {
const r = await fetch(`${API_BASE}/lfo/${encodeURIComponent(paramName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
}
export async function updateLfo(paramName, config) {
const r = await fetch(`${API_BASE}/lfo/${encodeURIComponent(paramName)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
}
export async function deleteLfo(paramName) {
const r = await fetch(`${API_BASE}/lfo/${encodeURIComponent(paramName)}`, {
method: 'DELETE',
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
}
+110
View File
@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react'
import { fetchLfo, createLfo, updateLfo, deleteLfo } from '../api.js'
const SHAPES = ['NONE', 'SINE', 'SQUARE', 'TRIANGLE', 'SAWTOOTH', 'PERLIN']
function LfoSlider({ label, value, min, max, step, onChange }) {
return (
<div className="lfo-row">
<span className="lfo-label">{label}</span>
<input
type="range" min={min} max={max} step={step}
value={value}
onChange={e => onChange(parseFloat(e.target.value))}
className="lfo-range"
/>
<span className="lfo-val">{typeof value === 'number' ? value.toFixed(3) : value}</span>
</div>
)
}
export default function LfoPanel({ param, onClose }) {
const [shape, setShape] = useState('SINE')
const [frequency, setFrequency] = useState(0.5)
const [amplitude, setAmplitude] = useState((param.max - param.min) / 2)
const [phase, setPhase] = useState(0)
const [seed, setSeed] = useState(0)
const [active, setActive] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchLfo(param.name).then(lfo => {
if (lfo) {
setShape(lfo.shape)
setFrequency(lfo.frequency)
setAmplitude(lfo.amplitude)
setPhase(lfo.phase)
setSeed(lfo.seed)
setActive(true)
}
setLoading(false)
})
}, [param.name])
async function handleApply(updates = {}) {
const config = {
shape: updates.shape ?? shape,
frequency: updates.frequency ?? frequency,
amplitude: updates.amplitude ?? amplitude,
phase: updates.phase ?? phase,
seed: updates.seed ?? seed,
}
try {
if (active) {
await updateLfo(param.name, config)
} else {
await createLfo(param.name, config)
setActive(true)
}
} catch (e) { /* ignore */ }
}
async function handleRemove() {
try {
await deleteLfo(param.name)
setActive(false)
setShape('SINE')
onClose()
} catch (e) { /* ignore */ }
}
function update(field, value, setter) {
setter(value)
handleApply({ [field]: value })
}
if (loading) return <div className="lfo-panel">loading…</div>
return (
<div className="lfo-panel">
<div className="lfo-row">
<span className="lfo-label">shape</span>
<select
className="param-select"
value={shape}
onChange={e => update('shape', e.target.value, setShape)}
>
{SHAPES.map(s => <option key={s} value={s}>{s.toLowerCase()}</option>)}
</select>
{active && (
<button className="lfo-remove-btn" onClick={handleRemove} title="Remove LFO">✕</button>
)}
{!active && (
<button className="lfo-apply-btn" onClick={() => handleApply()}>add</button>
)}
</div>
{(active || true) && shape !== 'NONE' && (
<>
<LfoSlider label="freq" value={frequency} min={0} max={2} step={0.001}
onChange={v => update('frequency', v, setFrequency)} />
<LfoSlider label="amp" value={amplitude} min={0} max={param.max - param.min} step={(param.max - param.min) / 200}
onChange={v => update('amplitude', v, setAmplitude)} />
<LfoSlider label="phase" value={phase} min={0} max={360} step={1}
onChange={v => update('phase', v, setPhase)} />
<LfoSlider label="seed" value={seed} min={param.min} max={param.max} step={(param.max - param.min) / 200}
onChange={v => update('seed', v, setSeed)} />
</>
)}
</div>
)
}
+70 -25
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { setParam, resetParam } from '../api.js'
import LfoPanel from './LfoPanel.jsx'
function debounce(fn, ms) {
let t
@@ -12,7 +13,47 @@ function fmt(v) {
return Number.isInteger(n) ? String(n) : n.toFixed(3)
}
function ParamDropdown({ param }) {
const [localVal, setLocalVal] = useState(param.value)
useEffect(() => { setLocalVal(param.value) }, [param.value])
function handleChange(e) {
const v = e.target.value
setLocalVal(v)
setParam(param.name, v).catch(() => {})
}
const label = param.name.replace(/^[A-Z0-9_]+\./, '').replace(/_/g, ' ')
const options = param.options || []
return (
<div className="param-row dropdown" title={param.name}>
<span className="param-label">{label}</span>
<select
className="param-select"
value={localVal}
onChange={handleChange}
>
{options.map(opt => (
<option key={opt} value={opt}>
{opt.replace(/_/g, ' ')}
</option>
))}
</select>
</div>
)
}
export default function ParamSlider({ param, onExternalSync }) {
const isDropdown = param.options && param.options.length > 0 &&
(param.type.includes('DROPDOWN') || param.type.includes('RADIO') || typeof param.value === 'string')
// showLfo must be declared before any early return to satisfy React's hook rules
const [showLfo, setShowLfo] = useState(false)
if (isDropdown) return <ParamDropdown param={param} />
const range = param.max - param.min
const step = range > 0 ? range / 200 : 0.01
@@ -61,33 +102,37 @@ export default function ParamSlider({ param, onExternalSync }) {
} catch (e) { /* ignore */ }
}
const label = param.name.replace(/_/g, ' ')
const label = param.name.replace(/^[A-Z0-9_]+\./, '').replace(/_/g, ' ')
return (
<div className="param-row" title={param.name}>
<span className="param-label">{label}</span>
<input
type="range"
min={param.min}
max={param.max}
step={step}
value={localVal}
onMouseDown={() => { dragging.current = true }}
onMouseUp={() => { dragging.current = false }}
onTouchStart={() => { dragging.current = true }}
onTouchEnd={() => { dragging.current = false }}
onChange={handleSlider}
/>
<input
className="param-value-input"
type="text"
value={inputVal}
onFocus={() => { inputFocused.current = true }}
onBlur={handleInputCommit}
onChange={handleInputChange}
onKeyDown={e => { if (e.key === 'Enter') handleInputCommit() }}
/>
<button className="reset-btn" onClick={handleReset} title={`Reset to ${fmt(param.default)}`}>R</button>
<div className="param-col">
<div className="param-row" title={param.name}>
<span className="param-label">{label}</span>
<input
type="range"
min={param.min}
max={param.max}
step={step}
value={localVal}
onMouseDown={() => { dragging.current = true }}
onMouseUp={() => { dragging.current = false }}
onTouchStart={() => { dragging.current = true }}
onTouchEnd={() => { dragging.current = false }}
onChange={handleSlider}
/>
<input
className="param-value-input"
type="text"
value={inputVal}
onFocus={() => { inputFocused.current = true }}
onBlur={handleInputCommit}
onChange={handleInputChange}
onKeyDown={e => { if (e.key === 'Enter') handleInputCommit() }}
/>
<button className="reset-btn" onClick={handleReset} title={`Reset to ${fmt(param.default)}`}>R</button>
<button className="lfo-btn" onClick={() => setShowLfo(v => !v)} title="LFO">~</button>
</div>
{showLfo && <LfoPanel param={param} onClose={() => setShowLfo(false)} />}
</div>
)
}
+2
View File
@@ -4,12 +4,14 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: true, // listen on 0.0.0.0 so other machines on the LAN can connect
proxy: {
'/params': 'http://127.0.0.1:8000',
'/snapshot': 'http://127.0.0.1:8000',
'/stream': 'http://127.0.0.1:8000',
'/patch': 'http://127.0.0.1:8000',
'/midi': 'http://127.0.0.1:8000',
'/lfo': 'http://127.0.0.1:8000',
}
}
})