Files
tensor/signal/chirp_test.go
T
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

159 lines
5.2 KiB
Go
Raw 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"
"math/big"
"testing"
)
// TestChirpConstantToneIsAPureSine pins the degenerate sweep: with f0
// equal to f1 the slope is exactly zero and every sample must be bit
// for bit the plain sine at that frequency.
func TestChirpConstantToneIsAPureSine(t *testing.T) {
const f, rate = 17.3, 800.0
got, err := Chirp(400, f, f, rate)
if err != nil {
t.Fatalf("Chirp: %v", err)
}
for i := range got {
tt := float64(i) / rate
if want := math.Sin(2 * math.Pi * (f*tt + 0.5*0*tt*tt)); got[i] != want {
t.Fatalf("sample %d = %.17g, want the plain sine %.17g", i, got[i], want)
}
}
if got[0] != 0 {
t.Fatalf("the first sample = %.17g, want the phase-zero zero", got[0])
}
}
// chirpPhaseBig evaluates the sweep phase 2π(f0·t + (f1−f0)t²/(2T)) in
// 256-bit arithmetic from the exact float64 edges, the referent the
// samples are held against.
func chirpPhaseBig(i int, f0, f1, span float64, rate int64) *big.Float {
const prec = 256
t := new(big.Float).SetPrec(prec).SetInt64(int64(i))
t.Quo(t, new(big.Float).SetPrec(prec).SetInt64(rate))
f0b := new(big.Float).SetPrec(prec).SetFloat64(f0)
slope := new(big.Float).SetPrec(prec).SetFloat64(f1 - f0)
slope.Quo(slope, new(big.Float).SetPrec(prec).SetFloat64(span))
phase := new(big.Float).SetPrec(prec).Mul(slope, t)
phase.Mul(phase, t)
phase.Quo(phase, new(big.Float).SetPrec(prec).SetInt64(2))
phase.Add(phase, new(big.Float).SetPrec(prec).Mul(f0b, t))
return phase.Mul(phase, new(big.Float).SetPrec(prec).SetFloat64(2*math.Pi))
}
// TestChirpPhaseAgainstBigFloat holds selected samples against the
// phase evaluated in 256-bit arithmetic: the float64 route may lose
// only the rounding its own phase arithmetic costs, a few parts in
// 1e16 of a phase that stays near 2π·40.
func TestChirpPhaseAgainstBigFloat(t *testing.T) {
const n, rate = 200, 1000
const f0, f1 = 3, 40
span := float64(n-1) / rate
got, err := Chirp(n, f0, f1, float64(rate))
if err != nil {
t.Fatalf("Chirp: %v", err)
}
for _, i := range []int{0, 1, 37, 100, 150, 198, 199} {
phase, _ := chirpPhaseBig(i, f0, f1, span, rate).Float64()
want := math.Sin(phase)
if math.Abs(got[i]-want) > 5e-13 {
t.Fatalf("sample %d = %.17g, want sin(256-bit phase) %.17g", i, got[i], want)
}
}
}
// TestChirpSweepDirection counts zero crossings per eighth of a rising
// sweep: the count must climb from roughly f0's rate to roughly f1's,
// which pins both the direction of the sweep and its approximate
// linearity.
func TestChirpSweepDirection(t *testing.T) {
const n, rate = 8000, 8000.0
const f0, f1 = 100, 900
got, err := Chirp(n, f0, f1, rate)
if err != nil {
t.Fatalf("Chirp: %v", err)
}
crossings := func(from, to int) int {
c := 0
for i := from; i < to; i++ {
if got[i] >= 0 && got[i+1] < 0 {
c++
}
}
return c
}
first := crossings(0, n/8)
last := crossings(7*n/8, n-1)
// The eighth spans 1000 samples; the instantaneous frequency runs
// from about 200 to about 887 Hz, so the crossing counts must
// climb and sit near those rates.
if first < 15 || first > 40 || last < 85 || last > 110 {
t.Fatalf("crossings per eighth: first %d, last %d, want a climb from near 200 Hz to near 890 Hz", first, last)
}
if last <= first {
t.Fatalf("the sweep does not rise: %d crossings at the end against %d at the start", last, first)
}
}
// TestChirpErrors pins the guard rails: the length, the rate, the
// finite edges and the Nyquist boundary the sweep must stay under,
// counting the magnitude for negative edges too.
func TestChirpErrors(t *testing.T) {
if _, err := Chirp(0, 1, 2, 100); err == nil {
t.Fatal("n = 0: want an error")
}
if _, err := Chirp(1, 1, 2, 0); err == nil {
t.Fatal("a zero rate: want an error")
}
if _, err := Chirp(1, 1, 2, math.Inf(1)); err == nil {
t.Fatal("an infinite rate: want an error")
}
if _, err := Chirp(1, math.NaN(), 2, 100); err == nil {
t.Fatal("a NaN edge: want an error")
}
if _, err := Chirp(10, 1, math.Inf(-1), 100); err == nil {
t.Fatal("an infinite edge: want an error")
}
if _, err := Chirp(10, 1, 50, 100); err == nil {
t.Fatal("an edge on the Nyquist frequency: want an error")
}
if _, err := Chirp(10, 60, 1, 100); err == nil {
t.Fatal("a negative edge beyond Nyquist in magnitude: want an error")
}
// The one-sample chirp is legal and carries the phase-zero zero.
one, err := Chirp(1, 7, 9, 100)
if err != nil {
t.Fatalf("Chirp(1, ...): %v", err)
}
if len(one) != 1 || one[0] != 0 {
t.Fatalf("the one-sample chirp = %v, want [0]", one)
}
// A downward sweep inside the guard is legal.
if _, err := Chirp(16, 900, 100, 8000); err != nil {
t.Fatalf("Chirp downward: %v", err)
}
}
// TestChirpDeterministic redraws one sweep and requires the same bits,
// the contract every signal builder here carries.
func TestChirpDeterministic(t *testing.T) {
a, err := Chirp(256, 4, 90, 512)
if err != nil {
t.Fatal(err)
}
b, err := Chirp(256, 4, 90, 512)
if err != nil {
t.Fatal(err)
}
for i := range a {
if math.Float64bits(a[i]) != math.Float64bits(b[i]) {
t.Fatalf("sample %d moved between identical calls", i)
}
}
}