Files
tensor/signal/windows_test.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

299 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}
}