package offline import ( "math" "sort" "testing" "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/license" "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 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: composite output, no IQ 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) // Generate composite frame := gen.GenerateFrame(duration) if frame == nil { t.Fatal("GenerateFrame returned nil") } t.Logf("Generated %d composite samples @ %.0f Hz (%.2fs)", len(frame.Samples), frame.SampleRateHz, float64(len(frame.Samples))/frame.SampleRateHz) // Extract mono composite (I channel = composite baseband in non-FM mode) compositeRate := frame.SampleRateHz nSamples := len(frame.Samples) composite := make([]float64, nSamples) for i, s := range frame.Samples { composite[i] = float64(s.I) } // 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) } // 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 } } t.Logf("Phase: %d (energy=%.1f)", bestPhase, bestEnergy) // Correlate all 128 bits with frame averaging nCompleteBits := (nSamples - bestPhase) / samplesPerBit nAvgFrames := nCompleteBits / watermark.PayloadBits if nAvgFrames < 1 { nAvgFrames = 1 } 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) } } var minC, maxC, sumC float64 var nStrong, nDead int for i, 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++ } } t.Logf("Correlations: min=%.1f max=%.1f avg=%.1f strong=%d dead=%d", minC, maxC, sumC/128, nStrong, nDead) if nStrong < 64 { t.Errorf("Too few strong bits: %d/128 (expected >64)", nStrong) } // Frame sync: try all 128 rotations with byte-level erasure type decResult struct { rot int payload [watermark.RsDataBytes]byte ok bool } 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]) } // 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 } } if bestDec != nil { break } } if bestDec == nil { t.Fatal("RS decode FAILED — watermark not recoverable from generator output") } 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) } } 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 }