|
- package offline
-
- import (
- "math"
- "testing"
- "time"
-
- cfgpkg "github.com/jan/fm-rds-tx/internal/config"
- "github.com/jan/fm-rds-tx/internal/dsp"
- "github.com/jan/fm-rds-tx/internal/license"
- "github.com/jan/fm-rds-tx/internal/watermark"
- )
-
- // TestWatermarkE2EFloat32 tests the FULL path including float32 storage
- // (as happens in IQSample.I) and FMUpsampler FM modulation + demodulation.
- func TestWatermarkE2EFloat32(t *testing.T) {
- const key = "test-key-e2e-f32"
- const duration = 45 * time.Second
-
- cfg := cfgpkg.Default()
- cfg.FM.CompositeRateHz = 228000
- cfg.FM.StereoEnabled = true
- cfg.FM.OutputDrive = 0.5
- cfg.FM.LimiterEnabled = true
- cfg.FM.LimiterCeiling = 1.0
- cfg.FM.FMModulationEnabled = false // split-rate mode
- cfg.Audio.ToneLeftHz = 1000
- cfg.Audio.ToneRightHz = 1600
- cfg.Audio.ToneAmplitude = 0.4
- cfg.Audio.Gain = 1.0
- cfg.FM.PreEmphasisTauUS = 50
-
- gen := NewGenerator(cfg)
- licState := license.NewState("")
- gen.SetLicense(licState, key)
-
- frame := gen.GenerateFrame(duration)
- nSamples := len(frame.Samples)
- compositeRate := frame.SampleRateHz
- t.Logf("Generated %d samples @ %.0f Hz", nSamples, compositeRate)
-
- // Test 1: float32 truncation
- t.Run("float32_storage", func(t *testing.T) {
- // Simulate what IQSample does: float32(composite)
- composite := make([]float64, nSamples)
- for i, s := range frame.Samples {
- composite[i] = float64(s.I) // s.I is float32
- }
- testDecode(t, composite, compositeRate, key)
- })
-
- // Test 2: FM modulate + demodulate
- t.Run("fm_mod_demod", func(t *testing.T) {
- maxDev := 75000.0
- // FM modulate (same as FMUpsampler phase accumulation)
- phases := make([]float64, nSamples)
- phaseInc := 2 * math.Pi * maxDev / compositeRate
- phase := 0.0
- for i, s := range frame.Samples {
- phase += float64(s.I) * phaseInc
- phases[i] = phase
- }
- // FM demodulate: instantaneous frequency = dphase/dt
- demod := make([]float64, nSamples)
- for i := 1; i < nSamples; i++ {
- dp := phases[i] - phases[i-1]
- demod[i] = dp / phaseInc // recover composite
- }
- demod[0] = demod[1]
- testDecode(t, demod, compositeRate, key)
- })
-
- // Test 3: FM mod + upsample 10× + downsample + demod (full SDR path)
- t.Run("fm_upsample_downsample", func(t *testing.T) {
- maxDev := 75000.0
- deviceRate := 2280000.0
- upsampler := dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev)
- upFrame := upsampler.Process(frame)
- t.Logf("Upsampled: %d IQ samples @ %.0f Hz", len(upFrame.Samples), upFrame.SampleRateHz)
-
- // FM demodulate the IQ output: phase = atan2(Q, I), freq = dphase/dt
- nUp := len(upFrame.Samples)
- demod := make([]float64, nUp)
- prevPhase := 0.0
- for i, s := range upFrame.Samples {
- p := math.Atan2(float64(s.Q), float64(s.I))
- dp := p - prevPhase
- // Unwrap
- for dp > math.Pi { dp -= 2 * math.Pi }
- for dp < -math.Pi { dp += 2 * math.Pi }
- demod[i] = dp * deviceRate / (2 * math.Pi * maxDev)
- prevPhase = p
- }
-
- // Downsample back to composite rate
- ratio := int(deviceRate / compositeRate)
- nDown := nUp / ratio
- downsampled := make([]float64, nDown)
- for i := 0; i < nDown; i++ {
- // Simple decimate (use average over ratio samples)
- sum := 0.0
- for j := 0; j < ratio; j++ {
- idx := i*ratio + j
- if idx < nUp { sum += demod[idx] }
- }
- downsampled[i] = sum / float64(ratio)
- }
- t.Logf("Downsampled: %d samples @ %.0f Hz", nDown, compositeRate)
- testDecode(t, downsampled, compositeRate, key)
- })
- }
-
- func testDecode(t *testing.T, composite []float64, rate float64, key string) {
- t.Helper()
- chipRate := float64(watermark.ChipRate)
- samplesPerBit := int(float64(watermark.PnChips) * rate / chipRate)
- nSamples := len(composite)
-
- // Phase search
- bestPhase := 0
- bestEnergy := 0.0
- step := max(1, samplesPerBit/200)
- for phase := 0; phase < samplesPerBit; phase += step {
- var energy float64
- for b := 0; b < min(100, nSamples/samplesPerBit); b++ {
- start := phase + b*samplesPerBit
- if start+samplesPerBit > nSamples { break }
- c := watermark.CorrelateAt(composite, start, rate)
- energy += c * c
- }
- if energy > bestEnergy {
- bestEnergy = energy; bestPhase = phase
- }
- }
-
- // Correlate
- nComplete := (nSamples - bestPhase) / samplesPerBit
- nFrames := nComplete / 128
- if nFrames < 1 { nFrames = 1 }
- corrs := make([]float64, 128)
- for i := 0; i < 128; i++ {
- for f := 0; f < nFrames; f++ {
- start := bestPhase + (f*128+i)*samplesPerBit
- if start+samplesPerBit > nSamples { break }
- corrs[i] += watermark.CorrelateAt(composite, start, rate)
- }
- }
-
- var nStrong, nDead int
- var sumAbs float64
- for _, c := range corrs {
- ac := math.Abs(c)
- sumAbs += ac
- if ac > 50 { nStrong++ }
- if ac < 5 { nDead++ }
- }
- t.Logf("phase=%d, frames=%d, avg|c|=%.1f, strong=%d, dead=%d",
- bestPhase, nFrames, sumAbs/128, nStrong, nDead)
-
- if nStrong < 100 {
- t.Errorf("Only %d/128 strong bits — watermark degraded", nStrong)
- }
- }
|