// Copyright (c) 2026 Petr Balvín (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) } } }