mirror of
https://github.com/Khendi1/PVS.git
synced 2026-06-16 12:33:19 +02:00
debugging web app
This commit is contained in:
@@ -6,3 +6,8 @@ __pycache__/
|
||||
*.log
|
||||
tasks.txt
|
||||
nul
|
||||
|
||||
# Web
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1773365480.5726748
|
||||
+11
-2
@@ -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
@@ -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:
|
||||
|
||||
Generated
+1677
File diff suppressed because it is too large
Load Diff
+101
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user