From 7f1f408a6aa8ddaf65b15fa2393a97877d0fda35 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Apr 2026 16:43:07 +0200 Subject: [PATCH] test(watermark): refactor E2E coverage and helpers Refactor watermark end-to-end tests around shared generator/decode helpers, use a deterministic broadband source, add short-mode skips, and keep coverage for float32 storage plus FM modulation paths. --- .../offline/watermark_e2e_float32_test.go | 127 +++------ internal/offline/watermark_e2e_test.go | 245 +++++++++--------- 2 files changed, 152 insertions(+), 220 deletions(-) diff --git a/internal/offline/watermark_e2e_float32_test.go b/internal/offline/watermark_e2e_float32_test.go index 0524888..b4c1771 100644 --- a/internal/offline/watermark_e2e_float32_test.go +++ b/internal/offline/watermark_e2e_float32_test.go @@ -5,54 +5,36 @@ import ( "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) { + if testing.Short() { + t.Skip("skipping long watermark E2E float32 test in -short mode") + } 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) - + gen := newWatermarkE2EGenerator(t, 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 + composite := extractCompositeFrame(frame) + payload, _, ok := decodeWatermarkFromComposite(t, composite, compositeRate) + if !ok { + t.Fatal("decode failed after float32 composite storage") + } + if !watermark.KeyMatchesPayload(key, payload) { + t.Fatalf("payload mismatch after float32 storage: %x", payload) } - 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 @@ -60,17 +42,21 @@ func TestWatermarkE2EFloat32(t *testing.T) { 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[i] = dp / phaseInc } demod[0] = demod[1] - testDecode(t, demod, compositeRate, key) + payload, _, ok := decodeWatermarkFromComposite(t, demod, compositeRate) + if !ok { + t.Fatal("decode failed after FM mod/demod") + } + if !watermark.KeyMatchesPayload(key, payload) { + t.Fatalf("payload mismatch after FM mod/demod: %x", payload) + } }) - // 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 @@ -78,86 +64,41 @@ func TestWatermarkE2EFloat32(t *testing.T) { 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 } + 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] } + 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 + payload, _, ok := decodeWatermarkFromComposite(t, downsampled, compositeRate) + if !ok { + t.Fatal("decode failed after FM upsample/downsample path") } - } - - // 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) + if !watermark.KeyMatchesPayload(key, payload) { + t.Fatalf("payload mismatch after FM upsample/downsample path: %x", payload) } - } - - 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) - } + }) } diff --git a/internal/offline/watermark_e2e_test.go b/internal/offline/watermark_e2e_test.go index 85276a8..dc9b901 100644 --- a/internal/offline/watermark_e2e_test.go +++ b/internal/offline/watermark_e2e_test.go @@ -6,172 +6,163 @@ import ( "testing" "time" + "github.com/jan/fm-rds-tx/internal/audio" 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/output" "github.com/jan/fm-rds-tx/internal/watermark" ) -// TestWatermarkE2E runs a FULL generator pipeline (audio source → pre-emphasis → -// LPF → drive → clip → cleanup → watermark injection → stereo encode → composite -// clip → notch → pilot → RDS → FM mod) and then tries to decode the watermark -// from the composite output. This tests the real code path, not just the embedder. -func TestWatermarkE2E(t *testing.T) { - const key = "test-key-e2e" - const duration = 45 * time.Second +// noiseSource provides deterministic broadband stereo material so the +// watermark has energy across many bins and the detector has something real to +// correlate against. +type noiseSource struct { + stateL uint64 + stateR uint64 + amp float64 +} + +func newNoiseSource(seed uint64, amp float64) *noiseSource { + return &noiseSource{stateL: seed, stateR: seed ^ 0x9e3779b97f4a7c15, amp: amp} +} + +func (n *noiseSource) nextNorm(state *uint64) float64 { + *state = *state*6364136223846793005 + 1442695040888963407 + return float64(int32(*state>>33)) / float64(1<<31) +} + +func (n *noiseSource) NextFrame() audio.Frame { + l := n.amp * n.nextNorm(&n.stateL) + r := n.amp * n.nextNorm(&n.stateR) + return audio.NewFrame(audio.Sample(l), audio.Sample(r)) +} +func newWatermarkE2EGenerator(t *testing.T, key string) *Generator { + t.Helper() cfg := cfgpkg.Default() + cfg.RDS.Enabled = false 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: composite output, no IQ - cfg.Audio.ToneLeftHz = 1000 - cfg.Audio.ToneRightHz = 1600 - cfg.Audio.ToneAmplitude = 0.4 + cfg.FM.FMModulationEnabled = false // composite output for inspection + cfg.Audio.ToneAmplitude = 0 // external source only cfg.Audio.Gain = 1.0 cfg.FM.PreEmphasisTauUS = 50 gen := NewGenerator(cfg) - licState := license.NewState("") - gen.SetLicense(licState, key) - - // Generate composite - frame := gen.GenerateFrame(duration) - if frame == nil { - t.Fatal("GenerateFrame returned nil") + if err := gen.SetExternalSource(newNoiseSource(12345, 0.22)); err != nil { + t.Fatalf("SetExternalSource: %v", err) } - t.Logf("Generated %d composite samples @ %.0f Hz (%.2fs)", - len(frame.Samples), frame.SampleRateHz, float64(len(frame.Samples))/frame.SampleRateHz) + gen.SetLicense(license.NewState("")) + gen.ConfigureWatermark(true, key) + return gen +} - // Extract mono composite (I channel = composite baseband in non-FM mode) - compositeRate := frame.SampleRateHz - nSamples := len(frame.Samples) - composite := make([]float64, nSamples) +func extractCompositeFrame(frame *output.CompositeFrame) []float64 { + out := make([]float64, len(frame.Samples)) for i, s := range frame.Samples { - composite[i] = float64(s.I) + out[i] = float64(s.I) } + return out +} - // RMS - var rmsAcc float64 - for _, s := range composite { - rmsAcc += s * s - } - rms := math.Sqrt(rmsAcc / float64(nSamples)) - t.Logf("Composite RMS: %.1f dBFS", 20*math.Log10(rms+1e-12)) - - // Now decode the watermark from the composite - chipRate := float64(watermark.ChipRate) - samplesPerBit := int(float64(watermark.PnChips) * compositeRate / chipRate) - frameLen := samplesPerBit * watermark.PayloadBits - nFrames := nSamples / frameLen - t.Logf("Decode: samplesPerBit=%d, frameLen=%d, nFrames=%d", samplesPerBit, frameLen, nFrames) - - if nFrames < 1 { - t.Fatalf("Need at least 1 frame (%d samples), have %d", frameLen, nSamples) +func downsampleForWatermarkDetection(composite []float64, rate float64) ([]float64, float64) { + decimFactor := int(rate / float64(watermark.WMRate)) + if decimFactor < 1 { + decimFactor = 1 } + actualRate := rate / float64(decimFactor) - // Phase search (should be 0 since we start from sample 0) - bestPhase := 0 - bestEnergy := 0.0 - step := max(1, samplesPerBit/500) - for phase := 0; phase < samplesPerBit; phase += step { - var energy float64 - nBits := min(200, (nSamples-phase)/samplesPerBit) - for b := 0; b < nBits; b++ { - start := phase + b*samplesPerBit - if start+samplesPerBit > nSamples { break } - c := watermark.CorrelateAt(composite, start, compositeRate) - energy += c * c - } - if energy > bestEnergy { - bestEnergy = energy - bestPhase = phase - } + lpf := dsp.NewLPF8(5500, rate) + filtered := make([]float64, len(composite)) + for i, s := range composite { + filtered[i] = lpf.Process(s) + } + out := make([]float64, len(filtered)/decimFactor) + for i := range out { + out[i] = filtered[i*decimFactor] } - t.Logf("Phase: %d (energy=%.1f)", bestPhase, bestEnergy) + return out, actualRate +} - // Correlate all 128 bits with frame averaging - nCompleteBits := (nSamples - bestPhase) / samplesPerBit - nAvgFrames := nCompleteBits / watermark.PayloadBits - if nAvgFrames < 1 { nAvgFrames = 1 } +func decodeWatermarkFromComposite(t *testing.T, composite []float64, rate float64) ([watermark.RsDataBytes]byte, [watermark.PayloadBits]float64, bool) { + t.Helper() + var zero [watermark.RsDataBytes]byte - corrs := make([]float64, watermark.PayloadBits) - for i := 0; i < watermark.PayloadBits; i++ { - for f := 0; f < nAvgFrames; f++ { - bitGlobal := f*watermark.PayloadBits + i - start := bestPhase + bitGlobal*samplesPerBit - if start+samplesPerBit > nSamples { break } - corrs[i] += watermark.CorrelateAt(composite, start, compositeRate) - } + down, actualRate := downsampleForWatermarkDetection(composite, rate) + if math.Abs(actualRate-float64(watermark.WMRate)) > 1e-9 { + t.Fatalf("unexpected detector rate %.3f Hz after decimation", actualRate) } + if len(down) <= watermark.FFTSize*2 { + return zero, [watermark.PayloadBits]float64{}, false + } + + probe := down[watermark.FFTSize:] + det := watermark.NewSTFTDetector() + corrs, bestOffset := det.Detect(probe) - var minC, maxC, sumC float64 - var nStrong, nDead int - for i, c := range corrs { + var sumAbs float64 + var strong int + for _, c := range corrs { ac := math.Abs(c) - sumC += ac - if i == 0 || ac < minC { minC = ac } - if ac > maxC { maxC = ac } - if ac > 50 { nStrong++ } - if ac < 5 { nDead++ } + sumAbs += ac + if ac > 1.0 { + strong++ + } } - t.Logf("Correlations: min=%.1f max=%.1f avg=%.1f strong=%d dead=%d", - minC, maxC, sumC/128, nStrong, nDead) + t.Logf("watermark detect: bestOffset=%d avg|c|=%.2f strong=%d/%d", bestOffset, sumAbs/float64(watermark.PayloadBits), strong, watermark.PayloadBits) - if nStrong < 64 { - t.Errorf("Too few strong bits: %d/128 (expected >64)", nStrong) + var recv [watermark.RsTotalBytes]byte + byteConfs := make([]float64, watermark.RsTotalBytes) + for i := 0; i < watermark.PayloadBits; i++ { + if corrs[i] < 0 { + recv[i/8] |= 1 << uint(7-(i%8)) + } + byteConfs[i/8] += math.Abs(corrs[i]) } - // Frame sync: try all 128 rotations with byte-level erasure - type decResult struct { - rot int - payload [watermark.RsDataBytes]byte - ok bool + type bc struct{ idx int; conf float64 } + ranked := make([]bc, watermark.RsTotalBytes) + for i := range ranked { + ranked[i] = bc{i, byteConfs[i]} } - var bestDec *decResult - - for rot := 0; rot < watermark.PayloadBits; rot++ { - var recv [watermark.RsTotalBytes]byte - byteConfs := make([]float64, watermark.RsTotalBytes) - for i := 0; i < watermark.PayloadBits; i++ { - srcBit := (i + rot) % watermark.PayloadBits - if corrs[srcBit] < 0 { - recv[i/8] |= 1 << uint(7-(i%8)) - } - byteConfs[i/8] += math.Abs(corrs[srcBit]) - } + sort.Slice(ranked, func(a, b int) bool { return ranked[a].conf < ranked[b].conf }) - // Try with 0..8 byte erasures - type bc struct{ idx int; conf float64 } - ranked := make([]bc, watermark.RsTotalBytes) - for i := range ranked { ranked[i] = bc{i, byteConfs[i]} } - sort.Slice(ranked, func(a, b int) bool { return ranked[a].conf < ranked[b].conf }) - - for ne := 0; ne <= watermark.RsCheckBytes; ne++ { - erasePos := make([]int, ne) - for i := 0; i < ne; i++ { erasePos[i] = ranked[i].idx } - sort.Ints(erasePos) - payload, ok := watermark.RSDecode(recv, erasePos) - if ok { - bestDec = &decResult{rot, payload, true} - break - } + for ne := 0; ne <= watermark.RsCheckBytes; ne++ { + erasePos := make([]int, ne) + for i := 0; i < ne; i++ { + erasePos[i] = ranked[i].idx + } + sort.Ints(erasePos) + payload, ok := watermark.RSDecode(recv, erasePos) + if ok { + return payload, corrs, true } - if bestDec != nil { break } } + return zero, corrs, false +} - if bestDec == nil { - t.Fatal("RS decode FAILED — watermark not recoverable from generator output") +func TestWatermarkE2E(t *testing.T) { + if testing.Short() { + t.Skip("skipping long watermark E2E test in -short mode") } + const key = "test-key-e2e" + const duration = 45 * time.Second - t.Logf("Decoded: rotation=%d, payload=%x", bestDec.rot, bestDec.payload) - if !watermark.KeyMatchesPayload(key, bestDec.payload) { - t.Errorf("Key mismatch: %q does not match payload %x", key, bestDec.payload) - } else { - t.Logf("Key %q MATCHES ✓", key) + gen := newWatermarkE2EGenerator(t, key) + frame := gen.GenerateFrame(duration) + if frame == nil { + t.Fatal("GenerateFrame returned nil") + } + composite := extractCompositeFrame(frame) + payload, _, ok := decodeWatermarkFromComposite(t, composite, frame.SampleRateHz) + if !ok { + t.Fatal("RS decode failed — watermark not recoverable from generator composite") + } + if !watermark.KeyMatchesPayload(key, payload) { + t.Fatalf("decoded payload mismatch: key=%q payload=%x", key, payload) } } - -func min(a, b int) int { if a < b { return a }; return b } -func max(a, b int) int { if a > b { return a }; return b }