Add synthetic array (waveform) DSP support + UX improvements
Adds full array/waveform support through the synthetic DSP engine: a dsp.Sample value model (scalar or []float64), array ops (index, slice, sum, mean, min, max, length, fft) with an in-tree radix-2 FFT, and static type propagation (OpOutputType) that the editor mirrors to colour wires by data type and flag invalid wirings. Stateful filters and lua stay scalar-only. Adds a waveform plot mode (x-vs-index trace). Also: errored-node hover reasons, S/N add-signal/add-node HUD shortcuts in the synthetic editor, and view-mode widgets that blend with the canvas background (chrome kept in edit mode). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,10 @@ import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
||||
import { inferNodeTypes, SynthType } from './lib/synthTypes';
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; }
|
||||
interface SignalInfo { name: string; type?: string; }
|
||||
|
||||
interface Props {
|
||||
// Existing signal name (edit mode). Omitted/empty in create mode.
|
||||
@@ -52,6 +53,18 @@ const OPS: OpDef[] = [
|
||||
]},
|
||||
{ type: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] },
|
||||
{ type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] },
|
||||
// ── Array (waveform) ops ──
|
||||
{ type: 'index', label: 'Index (a[i])', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Index (i)', key: 'i', type: 'number', default: '0' }] },
|
||||
{ type: 'slice', label: 'Slice', arity: { kind: 'fixed', n: 1 }, params: [
|
||||
{ label: 'Start', key: 'start', type: 'number', default: '0' },
|
||||
{ label: 'End (0 = to end)', key: 'end', type: 'number', default: '0' },
|
||||
]},
|
||||
{ type: 'sum', label: 'Sum (Σ array)', arity: { kind: 'fixed', n: 1 }, params: [] },
|
||||
{ type: 'mean', label: 'Mean (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
|
||||
{ type: 'min', label: 'Min (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
|
||||
{ type: 'max', label: 'Max (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
|
||||
{ type: 'length', label: 'Length', arity: { kind: 'fixed', n: 1 }, params: [] },
|
||||
{ type: 'fft', label: 'FFT (magnitude)', arity: { kind: 'fixed', n: 1 }, params: [] },
|
||||
];
|
||||
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
|
||||
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
|
||||
@@ -297,6 +310,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// Quick-add HUD (S = signal, N = node); null when closed.
|
||||
const [hud, setHud] = useState<null | 'signal' | 'node'>(null);
|
||||
const [hudFilter, setHudFilter] = useState('');
|
||||
|
||||
// Identity fields — editable only when creating a new signal.
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -308,7 +324,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
const sigName = create ? newName.trim() : (name ?? '');
|
||||
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
|
||||
@@ -338,7 +354,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -346,11 +362,36 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
// and a hook to lazily load all data sources' signals when a picker opens.
|
||||
function allSignalOptions(): SignalOption[] {
|
||||
const out: SignalOption[] = [];
|
||||
for (const ds of dataSources) for (const name of (dsSignals[ds] ?? [])) out.push({ ds, name });
|
||||
for (const ds of dataSources) for (const s of (dsSignals[ds] ?? [])) out.push({ ds, name: s.name });
|
||||
return out;
|
||||
}
|
||||
function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); }
|
||||
|
||||
// Resolve a source node's data type from the upstream signal's metadata.
|
||||
// 'float64[]' is the only array type today; anything else is scalar, and an
|
||||
// unresolved/unloaded source is 'unknown' (no error, runtime typing wins).
|
||||
function sourceSynthType(n: SynthGraphNode): SynthType {
|
||||
if (!n.ds || !n.signal) return 'unknown';
|
||||
const sigs = dsSignals[n.ds];
|
||||
if (!sigs) return 'unknown';
|
||||
const info = sigs.find(s => s.name === n.signal);
|
||||
if (!info || !info.type) return 'unknown';
|
||||
return info.type === 'float64[]' ? 'array' : 'scalar';
|
||||
}
|
||||
|
||||
// HUD filtering: case-insensitive substring over signal name/ds and op label/type.
|
||||
function filteredSignalOptions(): SignalOption[] {
|
||||
const q = hudFilter.trim().toLowerCase();
|
||||
const all = allSignalOptions();
|
||||
if (!q) return all;
|
||||
return all.filter(o => o.name.toLowerCase().includes(q) || o.ds.toLowerCase().includes(q));
|
||||
}
|
||||
function filteredOps(): OpDef[] {
|
||||
const q = hudFilter.trim().toLowerCase();
|
||||
if (!q) return OPS;
|
||||
return OPS.filter(o => o.label.toLowerCase().includes(q) || o.type.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (create) {
|
||||
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
|
||||
@@ -408,6 +449,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
}
|
||||
// Add a source node pre-filled with a chosen ds:signal (used by the quick-add HUD).
|
||||
function addSourceWith(ds: string, signal: string) {
|
||||
const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds, signal };
|
||||
if (ds) loadSignals(ds);
|
||||
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
|
||||
setSelected(node.id); setSelectedWire(null);
|
||||
}
|
||||
// Open the quick-add HUD; for signals, lazily load every source's signal list.
|
||||
function openHud(kind: 'signal' | 'node') {
|
||||
if (kind === 'signal') openAllSignals();
|
||||
setHudFilter('');
|
||||
setHud(kind);
|
||||
}
|
||||
function closeHud() { setHud(null); setHudFilter(''); }
|
||||
function addOp(op: OpDef, x?: number, y?: number) {
|
||||
const params: Record<string, any> = {};
|
||||
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
||||
@@ -582,22 +637,34 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if (mod) return;
|
||||
if (e.key === 'Escape') { if (hud) closeHud(); return; }
|
||||
if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
|
||||
if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
if (selectedWire !== null) deleteWire(selectedWire);
|
||||
else if (selected) deleteNode(selected);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selected, selectedWire, graph]);
|
||||
}, [selected, selectedWire, graph, hud]);
|
||||
|
||||
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
||||
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
||||
const { errors: nodeErrors, first: validationError } = validate(graph);
|
||||
|
||||
// Static type propagation: infer each node's output type (scalar/array) to
|
||||
// colour wires and surface type-incompatible wirings as node errors. Mirrors
|
||||
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
|
||||
const { types: nodeTypes, errors: typeErrors } = inferNodeTypes(compile(graph), sourceSynthType);
|
||||
for (const [id, msg] of typeErrors) if (!nodeErrors.has(id)) nodeErrors.set(id, msg);
|
||||
const typeError = !validationError ? [...typeErrors.values()][0] : undefined;
|
||||
const firstError = validationError ?? typeError;
|
||||
|
||||
async function handleSave() {
|
||||
if (!def) return;
|
||||
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
|
||||
if (validationError) { setError(validationError); return; }
|
||||
if (firstError) { setError(firstError); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -692,9 +759,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
const a = byId.get(w.from); const b = byId.get(w.to);
|
||||
if (!a || !b) return null;
|
||||
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
|
||||
const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
|
||||
return (
|
||||
<path key={idx}
|
||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||
class={`flow-wire${tClass}${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
|
||||
);
|
||||
@@ -712,6 +780,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
return (
|
||||
<div key={node.id}
|
||||
class={nodeClass(node)}
|
||||
title={nodeErrors.get(node.id) || undefined}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
|
||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
|
||||
@@ -867,11 +936,57 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hud && (
|
||||
<div class="synth-hud-backdrop" onMouseDown={closeHud}>
|
||||
<div class="synth-hud" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
class="synth-hud-input"
|
||||
autoFocus
|
||||
placeholder={hud === 'signal' ? 'Add signal — filter by name…' : 'Add node — filter operations…'}
|
||||
value={hudFilter}
|
||||
onInput={(e) => setHudFilter((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeHud(); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (hud === 'signal') {
|
||||
const opt = filteredSignalOptions()[0];
|
||||
if (opt) { addSourceWith(opt.ds, opt.name); closeHud(); }
|
||||
} else {
|
||||
const op = filteredOps()[0];
|
||||
if (op) { addOp(op); closeHud(); }
|
||||
}
|
||||
}
|
||||
}} />
|
||||
<div class="synth-hud-list">
|
||||
{hud === 'signal'
|
||||
? filteredSignalOptions().slice(0, 50).map(opt => (
|
||||
<button key={`${opt.ds}:${opt.name}`} class="synth-hud-item"
|
||||
onClick={() => { addSourceWith(opt.ds, opt.name); closeHud(); }}>
|
||||
<span class="synth-hud-item-name">{opt.name}</span>
|
||||
<span class="synth-hud-item-meta">{opt.ds}</span>
|
||||
</button>
|
||||
))
|
||||
: filteredOps().map(op => (
|
||||
<button key={op.type} class="synth-hud-item"
|
||||
onClick={() => { addOp(op); closeHud(); }}>
|
||||
<span class="synth-hud-item-name">{op.label}</span>
|
||||
<span class="synth-hud-item-meta">{op.type}</span>
|
||||
</button>
|
||||
))}
|
||||
{hud === 'signal' && filteredSignalOptions().length === 0 && (
|
||||
<div class="hint synth-hud-empty">No matching signals.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="wizard-footer">
|
||||
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
|
||||
{!error && validationError && <span class="hint" style="flex:1;">{validationError}</span>}
|
||||
{!error && firstError && <span class="hint" style="flex:1;">{firstError}</span>}
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!validationError}>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!firstError}>
|
||||
{saving ? 'Saving…' : 'Save Signal'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user