Files
tensor/signal/chirp_test.go
T

159 lines
5.2 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// 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)
}
}
}