f7f297c3df
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>
152 lines
4.2 KiB
Go
152 lines
4.2 KiB
Go
package dsp
|
|
|
|
import "fmt"
|
|
|
|
// ValType is the data type of a Sample: a scalar float, a float array
|
|
// (waveform), or — at graph-compile time, before a source's real type is
|
|
// known — unknown.
|
|
type ValType uint8
|
|
|
|
const (
|
|
ValUnknown ValType = iota
|
|
ValScalar
|
|
ValArray
|
|
)
|
|
|
|
func (t ValType) String() string {
|
|
switch t {
|
|
case ValScalar:
|
|
return "scalar"
|
|
case ValArray:
|
|
return "array"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// Sample is a value flowing through the synthetic DSP graph: either a scalar
|
|
// float64 or a float64 array (waveform). It is the array-aware counterpart of
|
|
// the bare float64 the legacy scalar Node interface uses.
|
|
type Sample struct {
|
|
F float64
|
|
Arr []float64
|
|
IsArray bool
|
|
}
|
|
|
|
// Scalar wraps a float64 as a scalar Sample.
|
|
func Scalar(f float64) Sample { return Sample{F: f} }
|
|
|
|
// Array wraps a []float64 as an array Sample.
|
|
func Array(a []float64) Sample { return Sample{Arr: a, IsArray: true} }
|
|
|
|
// Type reports whether the sample is a scalar or an array.
|
|
func (s Sample) Type() ValType {
|
|
if s.IsArray {
|
|
return ValArray
|
|
}
|
|
return ValScalar
|
|
}
|
|
|
|
// AsAny returns the value in the form datasource.Value.Data expects: a
|
|
// []float64 for arrays, a float64 for scalars.
|
|
func (s Sample) AsAny() any {
|
|
if s.IsArray {
|
|
return s.Arr
|
|
}
|
|
return s.F
|
|
}
|
|
|
|
// AsArray returns the sample's data as a slice: the array itself, or a
|
|
// single-element slice for a scalar. Used by reductions that accept either.
|
|
func (s Sample) AsArray() []float64 {
|
|
if s.IsArray {
|
|
return s.Arr
|
|
}
|
|
return []float64{s.F}
|
|
}
|
|
|
|
// ArrayNode is an optional extension of Node implemented by ops that operate
|
|
// natively on Samples (reductions array→scalar, producers array→array, etc.).
|
|
// eval prefers ProcessSample when a node implements it.
|
|
type ArrayNode interface {
|
|
Node
|
|
ProcessSample(inputs []Sample, state map[string]any) (Sample, error)
|
|
}
|
|
|
|
// statelessElementwise lists scalar ops that are safe to broadcast element-wise
|
|
// over array inputs: they hold no per-evaluation state, so running the legacy
|
|
// Process once per array lane is well-defined. Stateful ops (moving_average,
|
|
// rms, lowpass, derivative, integrate) and lua are excluded — a single shared
|
|
// state map cannot be meaningfully split across lanes.
|
|
var statelessElementwise = map[string]bool{
|
|
"gain": true, "offset": true, "add": true, "subtract": true,
|
|
"multiply": true, "divide": true, "clamp": true, "threshold": true,
|
|
"expr": true,
|
|
}
|
|
|
|
// StatelessElementwise reports whether a scalar op type may be broadcast over
|
|
// array inputs via ApplyElementwise.
|
|
func StatelessElementwise(nodeType string) bool { return statelessElementwise[nodeType] }
|
|
|
|
// scalarInputs wraps a legacy float64 input slice as scalar Samples.
|
|
func scalarInputs(in []float64) []Sample {
|
|
out := make([]Sample, len(in))
|
|
for i, v := range in {
|
|
out[i] = Scalar(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ApplyElementwise runs a stateless scalar Node over Sample inputs. If every
|
|
// input is scalar it calls Process once and wraps the result. If any input is
|
|
// an array it broadcasts: scalar inputs act as constants, all array inputs must
|
|
// share a common length (else an error), and Process is invoked once per index.
|
|
//
|
|
// The node MUST be stateless (see StatelessElementwise) — a shared state map
|
|
// cannot be split across array lanes.
|
|
func ApplyElementwise(n Node, inputs []Sample, state map[string]any) (Sample, error) {
|
|
// Determine the array length, if any input is an array.
|
|
length := -1
|
|
for _, s := range inputs {
|
|
if !s.IsArray {
|
|
continue
|
|
}
|
|
if length == -1 {
|
|
length = len(s.Arr)
|
|
} else if len(s.Arr) != length {
|
|
return Sample{}, fmt.Errorf("%s: array length mismatch (%d vs %d)", n.Type(), length, len(s.Arr))
|
|
}
|
|
}
|
|
|
|
if length == -1 {
|
|
// All scalar — single legacy call.
|
|
row := make([]float64, len(inputs))
|
|
for i, s := range inputs {
|
|
row[i] = s.F
|
|
}
|
|
r, err := n.Process(row, state)
|
|
if err != nil {
|
|
return Sample{}, err
|
|
}
|
|
return Scalar(r), nil
|
|
}
|
|
|
|
out := make([]float64, length)
|
|
row := make([]float64, len(inputs))
|
|
for i := 0; i < length; i++ {
|
|
for j, s := range inputs {
|
|
if s.IsArray {
|
|
row[j] = s.Arr[i]
|
|
} else {
|
|
row[j] = s.F
|
|
}
|
|
}
|
|
r, err := n.Process(row, state)
|
|
if err != nil {
|
|
return Sample{}, err
|
|
}
|
|
out[i] = r
|
|
}
|
|
return Array(out), nil
|
|
}
|