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:
@@ -13,9 +13,10 @@ import (
|
||||
// each node's inputs already resolved. Op-node state maps persist across
|
||||
// evaluations (for stateful nodes like moving_average / lua).
|
||||
type runtimeGraph struct {
|
||||
order []*rtNode // topological order (sources first, output last)
|
||||
sources []rtSource // source nodes, in topological order
|
||||
outputID string // id of the output node
|
||||
order []*rtNode // topological order (sources first, output last)
|
||||
sources []rtSource // source nodes, in topological order
|
||||
outputID string // id of the output node
|
||||
outType dsp.ValType // best-effort output type (scalar/array/unknown)
|
||||
}
|
||||
|
||||
type rtNode struct {
|
||||
@@ -41,24 +42,30 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
||||
return refs
|
||||
}
|
||||
|
||||
// eval computes the output value given the latest value for each source node
|
||||
// (keyed by source node id). Nodes are visited in topological order so every
|
||||
// input is already present in vals by the time a node is processed.
|
||||
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||
vals := make(map[string]float64, len(rg.order))
|
||||
// evalSample computes the output Sample (scalar or array) given the latest
|
||||
// value for each source node (keyed by source node id). Nodes are visited in
|
||||
// topological order so every input is present by the time a node is processed.
|
||||
//
|
||||
// Op dispatch:
|
||||
// - ArrayNode ops (reductions/producers) run natively on Samples.
|
||||
// - stateless elementwise ops broadcast over array inputs.
|
||||
// - stateful ops (filters) and lua are scalar-only; an array input errors,
|
||||
// since their per-evaluation state cannot be split across array lanes.
|
||||
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
|
||||
vals := make(map[string]dsp.Sample, len(rg.order))
|
||||
for id, v := range sourceVals {
|
||||
vals[id] = v
|
||||
}
|
||||
for _, n := range rg.order {
|
||||
switch n.kind {
|
||||
case "op":
|
||||
in := make([]float64, len(n.inputs))
|
||||
in := make([]dsp.Sample, len(n.inputs))
|
||||
for i, id := range n.inputs {
|
||||
in[i] = vals[id]
|
||||
}
|
||||
r, err := n.op.Process(in, n.state)
|
||||
r, err := evalOp(n, in)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
}
|
||||
vals[n.id] = r
|
||||
case "output":
|
||||
@@ -70,6 +77,44 @@ func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||
return vals[rg.outputID], nil
|
||||
}
|
||||
|
||||
// evalOp runs a single op node over its Sample inputs, choosing the right
|
||||
// execution path for the node type.
|
||||
func evalOp(n *rtNode, in []dsp.Sample) (dsp.Sample, error) {
|
||||
if an, ok := n.op.(dsp.ArrayNode); ok {
|
||||
return an.ProcessSample(in, n.state)
|
||||
}
|
||||
if dsp.StatelessElementwise(n.op.Type()) {
|
||||
return dsp.ApplyElementwise(n.op, in, n.state)
|
||||
}
|
||||
// Stateful / lua: scalar-only.
|
||||
row := make([]float64, len(in))
|
||||
for i, s := range in {
|
||||
if s.IsArray {
|
||||
return dsp.Sample{}, fmt.Errorf("does not accept an array input")
|
||||
}
|
||||
row[i] = s.F
|
||||
}
|
||||
r, err := n.op.Process(row, n.state)
|
||||
if err != nil {
|
||||
return dsp.Sample{}, err
|
||||
}
|
||||
return dsp.Scalar(r), nil
|
||||
}
|
||||
|
||||
// eval is the scalar wrapper around evalSample, kept so callers and tests that
|
||||
// deal purely in float64 (legacy linear graphs, scalar sources) are unchanged.
|
||||
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||
sv := make(map[string]dsp.Sample, len(sourceVals))
|
||||
for id, v := range sourceVals {
|
||||
sv[id] = dsp.Scalar(v)
|
||||
}
|
||||
out, err := rg.evalSample(sv)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return out.F, nil
|
||||
}
|
||||
|
||||
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
|
||||
// def carries an explicit Graph it is used directly; otherwise the legacy
|
||||
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
|
||||
@@ -83,23 +128,42 @@ func compileGraph(def SignalDef) (*runtimeGraph, error) {
|
||||
return nil, err
|
||||
}
|
||||
rg := &runtimeGraph{outputID: g.Output}
|
||||
// nodeType tracks each node's best-effort output type for static
|
||||
// propagation. Sources are unknown at compile time (their real type is
|
||||
// only known once data flows), so type errors here are advisory; runtime
|
||||
// Sample typing is authoritative.
|
||||
nodeType := make(map[string]dsp.ValType, len(order))
|
||||
for _, gn := range order {
|
||||
switch gn.Kind {
|
||||
case "source":
|
||||
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
|
||||
nodeType[gn.ID] = dsp.ValUnknown
|
||||
case "op":
|
||||
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
|
||||
}
|
||||
inTypes := make([]dsp.ValType, len(gn.Inputs))
|
||||
for i, id := range gn.Inputs {
|
||||
inTypes[i] = nodeType[id]
|
||||
}
|
||||
ot, terr := dsp.OpOutputType(gn.Op, inTypes)
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("node %q: %w", gn.ID, terr)
|
||||
}
|
||||
nodeType[gn.ID] = ot
|
||||
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
|
||||
case "output":
|
||||
rg.outputID = gn.ID
|
||||
if len(gn.Inputs) > 0 {
|
||||
nodeType[gn.ID] = nodeType[gn.Inputs[0]]
|
||||
}
|
||||
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
|
||||
default:
|
||||
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
|
||||
}
|
||||
}
|
||||
rg.outType = nodeType[rg.outputID]
|
||||
return rg, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user