Bladeren bron

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.
main
Jan 1 maand geleden
bovenliggende
commit
7f1f408a6a
2 gewijzigde bestanden met toevoegingen van 152 en 220 verwijderingen
  1. +34
    -93
      internal/offline/watermark_e2e_float32_test.go
  2. +118
    -127
      internal/offline/watermark_e2e_test.go

+ 34
- 93
internal/offline/watermark_e2e_float32_test.go Bestand weergeven

@@ -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)
}
})
}

+ 118
- 127
internal/offline/watermark_e2e_test.go Bestand weergeven

@@ -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 }

Laden…
Annuleren
Opslaan