feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signal
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWindowTaperPeriodicBits pins the exact bit patterns the legacy
|
||||
// windowTaper loops produced for WelchPSD, STFT and Spectrogram: the
|
||||
// catalogue's periodic forms must reproduce them sample for sample,
|
||||
// not merely to a tolerance.
|
||||
func TestWindowTaperPeriodicBits(t *testing.T) {
|
||||
hann := []uint64{
|
||||
0x0, 0x3fa37ca1866b95d0, 0x3fc2bec333018866, 0x3fd3c10eaca8ab4e,
|
||||
0x3fdfffffffffffff, 0x3fe61f78a9abaa58, 0x3feb504f333f9de6, 0x3feec835e79946a3,
|
||||
0x3ff0000000000000, 0x3feec835e79946a4, 0x3feb504f333f9de7, 0x3fe61f78a9abaa5b,
|
||||
0x3fe0000000000001, 0x3fd3c10eaca8ab4c, 0x3fc2bec333018868, 0x3fa37ca1866b95e0,
|
||||
}
|
||||
hamming := []uint64{
|
||||
0x3fb47ae147ae147c, 0x3fbd71a676273fcc, 0x3fcb7c4d2eecee22, 0x3fd74b3675e2db0b,
|
||||
0x3fe147ae147ae148, 0x3fe6e9c0ee04550a, 0x3febb048dd3a8707, 0x3feee1275a30da96,
|
||||
0x3ff0000000000000, 0x3feee1275a30da97, 0x3febb048dd3a8708, 0x3fe6e9c0ee04550d,
|
||||
0x3fe147ae147ae149, 0x3fd74b3675e2db0a, 0x3fcb7c4d2eecee24, 0x3fbd71a676273fd4,
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
want []uint64
|
||||
}{
|
||||
{"hann", hann},
|
||||
{"hamming", hamming},
|
||||
} {
|
||||
w, err := windowTaper(c.name, 16)
|
||||
if err != nil {
|
||||
t.Fatalf("windowTaper(%s): %v", c.name, err)
|
||||
}
|
||||
for i := range w {
|
||||
if got := math.Float64bits(w[i]); got != c.want[i] {
|
||||
t.Fatalf("%s[%d]: bits %x, want %x", c.name, i, got, c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
w, err := windowTaper("box", 16)
|
||||
if err != nil {
|
||||
t.Fatalf("windowTaper(box): %v", err)
|
||||
}
|
||||
for i := range w {
|
||||
if got := math.Float64bits(w[i]); got != 0x3ff0000000000000 {
|
||||
t.Fatalf("box[%d]: bits %x, want 3ff0000000000000", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowSymmetry pins the two length conventions: symmetric
|
||||
// windows read the same at both ends and vanish there where their
|
||||
// shape says so, periodic windows keep the raised tail the spectral
|
||||
// estimates treat as one period.
|
||||
func TestWindowSymmetry(t *testing.T) {
|
||||
const n = 16
|
||||
for _, c := range []struct {
|
||||
build func(n int, periodic bool) ([]float64, error)
|
||||
edge float64 // the symmetric window's edge value
|
||||
}{
|
||||
{WindowHann, 0},
|
||||
{WindowHamming, 0.08},
|
||||
{WindowBlackman, 0},
|
||||
{WindowBartlett, 0},
|
||||
{WindowCosine, 0},
|
||||
} {
|
||||
sym, err := c.build(n, false)
|
||||
if err != nil {
|
||||
t.Fatalf("symmetric: %v", err)
|
||||
}
|
||||
per, err := c.build(n, true)
|
||||
if err != nil {
|
||||
t.Fatalf("periodic: %v", err)
|
||||
}
|
||||
if math.Abs(sym[0]-c.edge) > 1e-12 || math.Abs(sym[n-1]-c.edge) > 1e-12 {
|
||||
t.Fatalf("symmetric edges %v, %v, want %v", sym[0], sym[n-1], c.edge)
|
||||
}
|
||||
for i := range n {
|
||||
// Symmetry holds to the rounding of the per-sample
|
||||
// argument, not bitwise: i and n−1−i compute their
|
||||
// cosines from independently rounded arguments.
|
||||
if math.Abs(sym[i]-sym[n-1-i]) > 1e-14 {
|
||||
t.Fatalf("symmetric window off at %d: %v vs %v", i, sym[i], sym[n-1-i])
|
||||
}
|
||||
if per[i] == per[n-1-i] && i != n-1-i {
|
||||
t.Fatalf("periodic window mirrors its symmetric twin at %d", i)
|
||||
}
|
||||
}
|
||||
// The conventions share only the first sample (argument 0);
|
||||
// the periodic window continues to the raised tail, the
|
||||
// symmetric one closes to the edge value.
|
||||
if per[0] != sym[0] {
|
||||
t.Fatalf("conventions disagree at the first sample: %v vs %v", per[0], sym[0])
|
||||
}
|
||||
if c.edge == 0 && per[n-1] <= 0 {
|
||||
t.Fatalf("periodic tail %v not above the symmetric edge", per[n-1])
|
||||
}
|
||||
}
|
||||
// Blackman-Harris and flat top share the symmetry, with the
|
||||
// flat top's slightly negative edge.
|
||||
bh, err := WindowBlackmanHarris(n, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range n {
|
||||
if math.Abs(bh[i]-bh[n-1-i]) > 1e-14 {
|
||||
t.Fatalf("Blackman-Harris off symmetry at %d: %v vs %v", i, bh[i], bh[n-1-i])
|
||||
}
|
||||
}
|
||||
ft, err := WindowFlatTop(n, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEdge := -0.008 / 19
|
||||
if math.Abs(ft[0]-wantEdge) > 1e-14 {
|
||||
t.Fatalf("flat top edge %v, want %v", ft[0], wantEdge)
|
||||
}
|
||||
// The flat top's flatness is a frequency-domain property: a
|
||||
// spectral line reads the same amplitude wherever it falls
|
||||
// between bins. The DTFT of the periodic window, sampled at
|
||||
// bin offsets, must stay flat to the window's hundredth of a
|
||||
// decibel.
|
||||
ftPer, err := WindowFlatTop(16, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dtft := func(bins float64) float64 {
|
||||
var re, im float64
|
||||
for i, v := range ftPer {
|
||||
ang := 2 * math.Pi * bins * float64(i) / float64(len(ftPer))
|
||||
re += v * math.Cos(ang)
|
||||
im -= v * math.Sin(ang)
|
||||
}
|
||||
return math.Hypot(re, im)
|
||||
}
|
||||
w0 := dtft(0)
|
||||
for _, bins := range []float64{0.25, 0.5} {
|
||||
dev := math.Abs(dtft(bins)-w0) / w0
|
||||
if dev > 2e-3 {
|
||||
t.Fatalf("flat top scalloping at %.2f bins: %g relative, want under 2e-3", bins, dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowKaiser pins the Kaiser taper: beta 0 is the box, the
|
||||
// window peaks at its centre, and the underlying Bessel I0 hits its
|
||||
// tabulated values.
|
||||
func TestWindowKaiser(t *testing.T) {
|
||||
box, err := WindowKaiser(9, 0, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range box {
|
||||
if box[i] != 1 {
|
||||
t.Fatalf("beta 0 sample %d = %v, want 1", i, box[i])
|
||||
}
|
||||
}
|
||||
w, err := WindowKaiser(11, 8.6, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w[5] != 1 {
|
||||
t.Fatalf("Kaiser centre %v, want 1", w[5])
|
||||
}
|
||||
for i := range w {
|
||||
if math.Abs(w[i]-w[10-i]) > 1e-14 {
|
||||
t.Fatalf("Kaiser off symmetry at %d: %v vs %v", i, w[i], w[10-i])
|
||||
}
|
||||
}
|
||||
// I0 against tabulated values.
|
||||
for _, c := range []struct{ x, want float64 }{
|
||||
{0, 1},
|
||||
{1, 1.2660658777520084},
|
||||
{5, 27.23987182360444},
|
||||
} {
|
||||
if got := kaiserI0(c.x); math.Abs(got-c.want) > 1e-12 {
|
||||
t.Fatalf("I0(%g) = %v, want %v", c.x, got, c.want)
|
||||
}
|
||||
}
|
||||
// A bigger beta is a stricter taper: lower at the same offset.
|
||||
hard, err := WindowKaiser(11, 14, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hard[1] >= w[1] {
|
||||
t.Fatalf("beta 14 edge %v not below beta 8.6 edge %v", hard[1], w[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowShapes pins a few hand-computed sample values per
|
||||
// builder.
|
||||
func TestWindowShapes(t *testing.T) {
|
||||
hann, err := WindowHann(5, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []float64{0, 0.5, 1, 0.5, 0} {
|
||||
if math.Abs(hann[i]-want) > 1e-12 {
|
||||
t.Fatalf("Hann(5)[%d] = %v, want %v", i, hann[i], want)
|
||||
}
|
||||
}
|
||||
bart, err := WindowBartlett(5, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []float64{0, 0.5, 1, 0.5, 0} {
|
||||
if math.Abs(bart[i]-want) > 1e-12 {
|
||||
t.Fatalf("Bartlett(5)[%d] = %v, want %v", i, bart[i], want)
|
||||
}
|
||||
}
|
||||
cos, err := WindowCosine(5, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []float64{0, math.Sqrt2 / 2, 1, math.Sqrt2 / 2, 0} {
|
||||
if math.Abs(cos[i]-want) > 1e-12 {
|
||||
t.Fatalf("Cosine(5)[%d] = %v, want %v", i, cos[i], want)
|
||||
}
|
||||
}
|
||||
black, err := WindowBlackman(5, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if math.Abs(black[2]-1) > 1e-12 {
|
||||
t.Fatalf("Blackman centre %v, want 1", black[2])
|
||||
}
|
||||
// The periodic cosine window vanishes only at its first sample.
|
||||
per, err := WindowCosine(8, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if per[0] != 0 {
|
||||
t.Fatalf("periodic cosine starts at %v, want 0", per[0])
|
||||
}
|
||||
for i := 1; i < 8; i++ {
|
||||
if per[i] <= 0 {
|
||||
t.Fatalf("periodic cosine non-positive at %d: %v", i, per[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowOneSample pins the one-sample convention: the constant 1
|
||||
// in both modes, for every builder.
|
||||
func TestWindowOneSample(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
build func(n int, periodic bool) ([]float64, error)
|
||||
}{
|
||||
{"box", WindowBox},
|
||||
{"hann", WindowHann},
|
||||
{"hamming", WindowHamming},
|
||||
{"blackman", WindowBlackman},
|
||||
{"blackman-harris", WindowBlackmanHarris},
|
||||
{"flat-top", WindowFlatTop},
|
||||
{"bartlett", WindowBartlett},
|
||||
{"kaiser", func(n int, periodic bool) ([]float64, error) { return WindowKaiser(n, 6, periodic) }},
|
||||
{"cosine", WindowCosine},
|
||||
} {
|
||||
for _, periodic := range []bool{false, true} {
|
||||
w, err := c.build(1, periodic)
|
||||
if err != nil {
|
||||
t.Fatalf("%s periodic=%v: %v", c.name, periodic, err)
|
||||
}
|
||||
if len(w) != 1 || w[0] != 1 {
|
||||
t.Fatalf("%s periodic=%v: one-sample window %v, want [1]", c.name, periodic, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowErrors pins the length and beta gates of the catalogue.
|
||||
func TestWindowErrors(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
build func() error
|
||||
}{
|
||||
{"box n=0", func() error { _, err := WindowBox(0, true); return err }},
|
||||
{"hann n=-3", func() error { _, err := WindowHann(-3, false); return err }},
|
||||
{"hamming n=0", func() error { _, err := WindowHamming(0, true); return err }},
|
||||
{"blackman n=0", func() error { _, err := WindowBlackman(0, false); return err }},
|
||||
{"blackman-harris n=0", func() error { _, err := WindowBlackmanHarris(0, true); return err }},
|
||||
{"flat-top n=0", func() error { _, err := WindowFlatTop(0, false); return err }},
|
||||
{"bartlett n=0", func() error { _, err := WindowBartlett(0, true); return err }},
|
||||
{"kaiser n=0", func() error { _, err := WindowKaiser(0, 5, false); return err }},
|
||||
{"kaiser negative beta", func() error { _, err := WindowKaiser(8, -1, false); return err }},
|
||||
{"cosine n=0", func() error { _, err := WindowCosine(0, true); return err }},
|
||||
{"unknown taper name", func() error { _, err := windowTaper("hann2", 8); return err }},
|
||||
} {
|
||||
if err := c.build(); err == nil {
|
||||
t.Errorf("%s: want an error", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user