Files
uopi/internal/dsp/array_nodes_test.go
Martino Ferrari f7f297c3df 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>
2026-06-20 17:06:55 +02:00

214 lines
5.7 KiB
Go

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)
}
}
}