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:
@@ -0,0 +1,213 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSampleRoundTrip(t *testing.T) {
|
||||
s := Scalar(3.5)
|
||||
if s.IsArray {
|
||||
t.Error("Scalar should not be an array")
|
||||
}
|
||||
if s.Type() != ValScalar {
|
||||
t.Errorf("Scalar type: want ValScalar, got %v", s.Type())
|
||||
}
|
||||
if s.AsAny() != 3.5 {
|
||||
t.Errorf("Scalar AsAny: want 3.5, got %v", s.AsAny())
|
||||
}
|
||||
if got := s.AsArray(); len(got) != 1 || got[0] != 3.5 {
|
||||
t.Errorf("Scalar AsArray: want [3.5], got %v", got)
|
||||
}
|
||||
|
||||
a := Array([]float64{1, 2, 3})
|
||||
if !a.IsArray {
|
||||
t.Error("Array should be an array")
|
||||
}
|
||||
if a.Type() != ValArray {
|
||||
t.Errorf("Array type: want ValArray, got %v", a.Type())
|
||||
}
|
||||
got, ok := a.AsAny().([]float64)
|
||||
if !ok || len(got) != 3 {
|
||||
t.Errorf("Array AsAny: want []float64 len 3, got %v", a.AsAny())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseAllScalar(t *testing.T) {
|
||||
n := &AddNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Scalar(2), Scalar(3)}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.IsArray || out.F != 5 {
|
||||
t.Errorf("all-scalar add: want scalar 5, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseBroadcast(t *testing.T) {
|
||||
// array ⊕ scalar: scalar is a constant broadcast across the array.
|
||||
n := &AddNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Scalar(10)}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.IsArray {
|
||||
t.Fatalf("array+scalar: want array, got %v", out)
|
||||
}
|
||||
want := []float64{11, 12, 13}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("array+scalar[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseArrayArray(t *testing.T) {
|
||||
n := &MultiplyNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Array([]float64{4, 5, 6})}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []float64{4, 10, 18}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("array*array[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseLengthMismatch(t *testing.T) {
|
||||
n := &AddNode{}
|
||||
_, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2}), Array([]float64{1, 2, 3})}, map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("expected length-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReductionNodes(t *testing.T) {
|
||||
arr := []Sample{Array([]float64{2, 4, 6, 8})}
|
||||
cases := []struct {
|
||||
name string
|
||||
node ArrayNode
|
||||
want float64
|
||||
}{
|
||||
{"sum", &SumNode{}, 20},
|
||||
{"mean", &MeanNode{}, 5},
|
||||
{"min", &MinNode{}, 2},
|
||||
{"max", &MaxNode{}, 8},
|
||||
{"length", &LengthNode{}, 4},
|
||||
{"index", &IndexNode{I: 2}, 6},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := tc.node.ProcessSample(arr, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.IsArray || out.F != tc.want {
|
||||
t.Errorf("%s: want scalar %v, got %v", tc.name, tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexNodeOutOfRange(t *testing.T) {
|
||||
n := &IndexNode{I: 9}
|
||||
_, err := n.ProcessSample([]Sample{Array([]float64{1, 2, 3})}, map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("expected out-of-range error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceNode(t *testing.T) {
|
||||
n := &SliceNode{Start: 1, End: 3}
|
||||
out, err := n.ProcessSample([]Sample{Array([]float64{10, 20, 30, 40})}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []float64{20, 30}
|
||||
if len(out.Arr) != len(want) {
|
||||
t.Fatalf("slice: want len %d, got %d", len(want), len(out.Arr))
|
||||
}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("slice[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFTMagnitude(t *testing.T) {
|
||||
// A constant signal has all energy in bin 0 (the DC term equals the sum).
|
||||
x := []float64{1, 1, 1, 1}
|
||||
mag := fftMagnitude(x)
|
||||
if len(mag) != 4 {
|
||||
t.Fatalf("fft len: want 4, got %d", len(mag))
|
||||
}
|
||||
if math.Abs(mag[0]-4) > 1e-9 {
|
||||
t.Errorf("fft DC bin: want 4, got %v", mag[0])
|
||||
}
|
||||
for k := 1; k < len(mag); k++ {
|
||||
if math.Abs(mag[k]) > 1e-9 {
|
||||
t.Errorf("fft bin %d: want ~0, got %v", k, mag[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFTSingleTone(t *testing.T) {
|
||||
// One full cycle of a cosine over 8 samples → energy in bins 1 and N-1.
|
||||
n := 8
|
||||
x := make([]float64, n)
|
||||
for i := range x {
|
||||
x[i] = math.Cos(2 * math.Pi * float64(i) / float64(n))
|
||||
}
|
||||
mag := fftMagnitude(x)
|
||||
if math.Abs(mag[1]-float64(n)/2) > 1e-6 {
|
||||
t.Errorf("fft tone bin 1: want %v, got %v", float64(n)/2, mag[1])
|
||||
}
|
||||
if math.Abs(mag[n-1]-float64(n)/2) > 1e-6 {
|
||||
t.Errorf("fft tone bin %d: want %v, got %v", n-1, float64(n)/2, mag[n-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpOutputType(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
in []ValType
|
||||
want ValType
|
||||
wantErr bool
|
||||
}{
|
||||
// reductions → scalar regardless of input
|
||||
{"sum", []ValType{ValArray}, ValScalar, false},
|
||||
{"mean", []ValType{ValScalar}, ValScalar, false},
|
||||
{"index", []ValType{ValUnknown}, ValScalar, false},
|
||||
// array producers require array, yield array
|
||||
{"fft", []ValType{ValArray}, ValArray, false},
|
||||
{"slice", []ValType{ValUnknown}, ValArray, false},
|
||||
{"fft", []ValType{ValScalar}, ValUnknown, true},
|
||||
// scalar-only reject arrays
|
||||
{"moving_average", []ValType{ValScalar}, ValScalar, false},
|
||||
{"lua", []ValType{ValArray}, ValUnknown, true},
|
||||
{"rms", []ValType{ValUnknown}, ValScalar, false},
|
||||
// elementwise: array if any array, scalar if all scalar, else unknown
|
||||
{"add", []ValType{ValScalar, ValScalar}, ValScalar, false},
|
||||
{"add", []ValType{ValArray, ValScalar}, ValArray, false},
|
||||
{"gain", []ValType{ValUnknown}, ValUnknown, false},
|
||||
{"expr", []ValType{ValArray, ValScalar}, ValArray, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := OpOutputType(tc.op, tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("OpOutputType(%q,%v): expected error", tc.op, tc.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("OpOutputType(%q,%v): unexpected error %v", tc.op, tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("OpOutputType(%q,%v): want %v, got %v", tc.op, tc.in, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user