// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package signal import ( "math" "testing" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // hilbertTone builds an integer number of periods of a cosine, where // the periodic Fourier definition of the analytic signal is exact. func hilbertTone(t *testing.T, cycles, n int) *core.Array { t.Helper() vals := make([]float64, n) for i := range n { vals[i] = math.Cos(2 * math.Pi * float64(cycles) * float64(i) / float64(n)) } a, err := core.FromFloats(vals, n) if err != nil { t.Fatalf("FromFloats: %v", err) } return a } // TestAnalyticSignalTone checks the defining property on a pure tone: // the analytic signal of cos is exp(iωt), so the real part is the // input, the imaginary part the quadrature sine and the modulus one. func TestAnalyticSignalTone(t *testing.T) { const cycles, n = 13, 256 z, err := AnalyticSignal(hilbertTone(t, cycles, n)) if err != nil { t.Fatalf("AnalyticSignal: %v", err) } bins := z.RawComplexes()[:z.Len()] for i, c := range bins { want := 2 * math.Pi * float64(cycles) * float64(i) / float64(n) if math.Abs(real(c)-math.Cos(want)) > 1e-9 { t.Fatalf("bin %d real part %.12g, want the input cosine", i, real(c)) } if math.Abs(imag(c)-math.Sin(want)) > 1e-9 { t.Fatalf("bin %d imaginary part %.12g, want the quadrature sine", i, imag(c)) } if e := math.Hypot(real(c), imag(c)); math.Abs(e-1) > 1e-9 { t.Fatalf("bin %d modulus %.12g, want 1", i, e) } } } // TestEnvelopeAmplitudeModulation checks the envelope on an AM // carrier: the modulus of the analytic signal must recover the // modulating wave, the quantity an AM receiver is after. func TestEnvelopeAmplitudeModulation(t *testing.T) { const n = 512 vals := make([]float64, n) for i := range n { mod := 1 + 0.5*math.Cos(2*math.Pi*4*float64(i)/float64(n)) carrier := math.Cos(2 * math.Pi * 40 * float64(i) / float64(n)) vals[i] = mod * carrier } a, err := core.FromFloats(vals, n) if err != nil { t.Fatalf("FromFloats: %v", err) } env, err := Envelope(a) if err != nil { t.Fatalf("Envelope: %v", err) } vals = env.RawFloats()[:env.Len()] for i, got := range vals { want := 1 + 0.5*math.Cos(2*math.Pi*4*float64(i)/float64(n)) if math.Abs(got-want) > 1e-9 { t.Fatalf("sample %d envelope %.12g, want %.12g", i, got, want) } } } // TestAnalyticSignalRefusals checks the shape and emptiness guards. func TestAnalyticSignalRefusals(t *testing.T) { if _, err := AnalyticSignal(core.New(core.Float, 3, 2)); err == nil { t.Fatal("matrix accepted") } if _, err := AnalyticSignal(core.New(core.Float, 0)); err == nil { t.Fatal("zero-length series accepted") } if _, err := Envelope(core.New(core.Float, 3, 3, 3)); err == nil { t.Fatal("3-D array accepted") } }