feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signal
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// resampleTone builds n samples of a·cos(2π·cycles·i/n) and its
|
||||
// analytic continuation for comparisons.
|
||||
func resampleTone(t *testing.T, a float64, cycles, n int) *core.Array {
|
||||
t.Helper()
|
||||
vals := make([]float64, n)
|
||||
for i := range n {
|
||||
vals[i] = a * math.Cos(2*math.Pi*float64(cycles)*float64(i)/float64(n))
|
||||
}
|
||||
out, err := core.FromFloats(vals, n)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestDecimateTone checks that an in-band tone survives decimation
|
||||
// with its amplitude and phase, sampled on the new grid. Output
|
||||
// starts once the filter has full context, at input index
|
||||
// ceil(delay/factor)·factor + delay.
|
||||
func TestDecimateTone(t *testing.T) {
|
||||
const cycles, n, factor = 4, 256, 4
|
||||
out, err := Decimate(resampleTone(t, 1, cycles, n), factor, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Decimate: %v", err)
|
||||
}
|
||||
// taps = 32·factor+1 by default, delay = 16·factor, so the first
|
||||
// kept compensated index is 64 and sample i sits at input index
|
||||
// 64 + i·factor.
|
||||
const first = 64
|
||||
vals := out.RawFloats()[:out.Len()]
|
||||
for i, got := range vals {
|
||||
want := math.Cos(2 * math.Pi * float64(cycles) * float64(first+i*factor) / float64(n))
|
||||
if math.Abs(got-want) > 0.01 {
|
||||
t.Fatalf("sample %d = %.6g, want %.6g", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecimateAliasedTone checks the anti-alias job: a tone past the
|
||||
// new Nyquist must come out suppressed, not folded down. The first
|
||||
// and last taps samples carry filter transients and are skipped.
|
||||
func TestDecimateAliasedTone(t *testing.T) {
|
||||
// 44 cycles over 256 samples sits past the new Nyquist of 32 when
|
||||
// decimating by 4; the stopband is around 80 dB, so a small leak
|
||||
// is honest, a fold-back to a visible tone is not.
|
||||
out, err := Decimate(resampleTone(t, 1, 44, 256), 4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Decimate: %v", err)
|
||||
}
|
||||
vals := out.RawFloats()[:out.Len()]
|
||||
peak := 0.0
|
||||
for i, v := range vals {
|
||||
if i*4 < 128 || i*4 > 256-128 {
|
||||
continue
|
||||
}
|
||||
if a := math.Abs(v); a > peak {
|
||||
peak = a
|
||||
}
|
||||
}
|
||||
if peak > 0.02 {
|
||||
t.Fatalf("aliased tone survived at peak %.4g, the anti-alias filter leaked", peak)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResampleUpThenDown checks the rational path on a tone: up by 3
|
||||
// lands on a finer grid with the tone intact, and the round trip back
|
||||
// down recovers the input samples.
|
||||
func TestResampleUpThenDown(t *testing.T) {
|
||||
const cycles, n = 5, 60
|
||||
in := resampleTone(t, 1, cycles, n)
|
||||
up, err := Resample(in, 3, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Resample up: %v", err)
|
||||
}
|
||||
if up.Len() != 3*n {
|
||||
t.Fatalf("up-sampled length %d, want %d", up.Len(), 3*n)
|
||||
}
|
||||
vals := up.RawFloats()[:up.Len()]
|
||||
// Skip the edges, where the filter context is partial.
|
||||
for i := 24; i < up.Len()-24; i++ {
|
||||
want := math.Cos(2 * math.Pi * float64(cycles) * float64(i) / float64(3*n))
|
||||
if math.Abs(vals[i]-want) > 0.02 {
|
||||
t.Fatalf("up sample %d = %.6g, want %.6g", i, vals[i], want)
|
||||
}
|
||||
}
|
||||
down, err := Resample(up, 1, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Resample down: %v", err)
|
||||
}
|
||||
if down.Len() != n {
|
||||
t.Fatalf("round-trip length %d, want %d", down.Len(), n)
|
||||
}
|
||||
inVals := in.RawFloats()[:n]
|
||||
dVals := down.RawFloats()[:down.Len()]
|
||||
for i := 12; i < n-12; i++ {
|
||||
if math.Abs(inVals[i]-dVals[i]) > 0.02 {
|
||||
t.Fatalf("round trip sample %d = %.6g, want %.6g", i, dVals[i], inVals[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResampleFourierTone checks the exact band-limited resample: a
|
||||
// tone stays the same amplitude on a doubled grid, point for point.
|
||||
func TestResampleFourierTone(t *testing.T) {
|
||||
const cycles, n, size = 4, 64, 192
|
||||
out, err := ResampleFourier(resampleTone(t, 0.75, cycles, n), size)
|
||||
if err != nil {
|
||||
t.Fatalf("ResampleFourier: %v", err)
|
||||
}
|
||||
if out.Len() != size {
|
||||
t.Fatalf("length %d, want %d", out.Len(), size)
|
||||
}
|
||||
vals := out.RawFloats()[:out.Len()]
|
||||
for i, got := range vals {
|
||||
want := 0.75 * math.Cos(2*math.Pi*float64(cycles)*float64(i)/float64(size))
|
||||
if math.Abs(got-want) > 1e-12 {
|
||||
t.Fatalf("sample %d = %.12g, want %.12g", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResampleFourierDown checks the truncating direction: a mixed
|
||||
// two-tone series resampled to a third of its length keeps the low
|
||||
// tone and drops the one past the new Nyquist.
|
||||
func TestResampleFourierDown(t *testing.T) {
|
||||
const n = 96
|
||||
vals := make([]float64, n)
|
||||
for i := range n {
|
||||
low := math.Cos(2 * math.Pi * 3 * float64(i) / float64(n))
|
||||
high := 0.5 * math.Cos(2*math.Pi*30*float64(i)/float64(n))
|
||||
vals[i] = low + high
|
||||
}
|
||||
in, _ := core.FromFloats(vals, n)
|
||||
out, err := ResampleFourier(in, n/3)
|
||||
if err != nil {
|
||||
t.Fatalf("ResampleFourier: %v", err)
|
||||
}
|
||||
outVals := out.RawFloats()[:out.Len()]
|
||||
for i, got := range outVals {
|
||||
want := math.Cos(2 * math.Pi * 3 * float64(i) / float64(n/3))
|
||||
if math.Abs(got-want) > 1e-12 {
|
||||
t.Fatalf("sample %d = %.12g, want %.12g", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResampleRefusals checks the shape and argument guards.
|
||||
func TestResampleRefusals(t *testing.T) {
|
||||
bad := core.New(core.Float, 2, 2)
|
||||
if _, err := Decimate(bad, 2, 0); err == nil {
|
||||
t.Fatal("matrix accepted by Decimate")
|
||||
}
|
||||
if _, err := Resample(bad, 2, 1, 0); err == nil {
|
||||
t.Fatal("matrix accepted by Resample")
|
||||
}
|
||||
if _, err := ResampleFourier(bad, 8); err == nil {
|
||||
t.Fatal("matrix accepted by ResampleFourier")
|
||||
}
|
||||
one := core.New(core.Float, 16)
|
||||
if _, err := Decimate(one, 1, 0); err == nil {
|
||||
t.Fatal("identity factor accepted by Decimate")
|
||||
}
|
||||
if _, err := Resample(one, 1, 1, 0); err == nil {
|
||||
t.Fatal("identity rate accepted by Resample")
|
||||
}
|
||||
if _, err := Resample(one, 3, 1, 200); err == nil {
|
||||
t.Fatal("taps larger than the series accepted by Resample")
|
||||
}
|
||||
if _, err := ResampleFourier(one, 0); err == nil {
|
||||
t.Fatal("zero size accepted by ResampleFourier")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user