|
|
@@ -6,172 +6,163 @@ import ( |
|
|
"testing" |
|
|
"testing" |
|
|
"time" |
|
|
"time" |
|
|
|
|
|
|
|
|
|
|
|
"github.com/jan/fm-rds-tx/internal/audio" |
|
|
cfgpkg "github.com/jan/fm-rds-tx/internal/config" |
|
|
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/license" |
|
|
|
|
|
"github.com/jan/fm-rds-tx/internal/output" |
|
|
"github.com/jan/fm-rds-tx/internal/watermark" |
|
|
"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 := cfgpkg.Default() |
|
|
|
|
|
cfg.RDS.Enabled = false |
|
|
cfg.FM.CompositeRateHz = 228000 |
|
|
cfg.FM.CompositeRateHz = 228000 |
|
|
cfg.FM.StereoEnabled = true |
|
|
cfg.FM.StereoEnabled = true |
|
|
cfg.FM.OutputDrive = 0.5 |
|
|
cfg.FM.OutputDrive = 0.5 |
|
|
cfg.FM.LimiterEnabled = true |
|
|
cfg.FM.LimiterEnabled = true |
|
|
cfg.FM.LimiterCeiling = 1.0 |
|
|
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.Audio.Gain = 1.0 |
|
|
cfg.FM.PreEmphasisTauUS = 50 |
|
|
cfg.FM.PreEmphasisTauUS = 50 |
|
|
|
|
|
|
|
|
gen := NewGenerator(cfg) |
|
|
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 { |
|
|
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) |
|
|
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 } |
|
|
|