feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+298
View File
@@ -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)
}
}
}