package offline import ( "math" "sort" "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" ) // 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 // composite output for inspection cfg.Audio.ToneAmplitude = 0 // external source only cfg.Audio.Gain = 1.0 cfg.FM.PreEmphasisTauUS = 50 gen := NewGenerator(cfg) if err := gen.SetExternalSource(newNoiseSource(12345, 0.22)); err != nil { t.Fatalf("SetExternalSource: %v", err) } gen.SetLicense(license.NewState("")) gen.ConfigureWatermark(true, key) return gen } func extractCompositeFrame(frame *output.CompositeFrame) []float64 { out := make([]float64, len(frame.Samples)) for i, s := range frame.Samples { out[i] = float64(s.I) } return out } func downsampleForWatermarkDetection(composite []float64, rate float64) ([]float64, float64) { decimFactor := int(rate / float64(watermark.WMRate)) if decimFactor < 1 { decimFactor = 1 } actualRate := rate / float64(decimFactor) 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] } return out, actualRate } func decodeWatermarkFromComposite(t *testing.T, composite []float64, rate float64) ([watermark.RsDataBytes]byte, [watermark.PayloadBits]float64, bool) { t.Helper() var zero [watermark.RsDataBytes]byte 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 sumAbs float64 var strong int for _, c := range corrs { ac := math.Abs(c) sumAbs += ac if ac > 1.0 { strong++ } } t.Logf("watermark detect: bestOffset=%d avg|c|=%.2f strong=%d/%d", bestOffset, sumAbs/float64(watermark.PayloadBits), strong, watermark.PayloadBits) 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]) } 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 { return payload, corrs, true } } return zero, corrs, false } 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 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) } }