|
- package watermark
-
- import (
- "math"
- "sort"
- "testing"
- )
-
- // TestRoundTrip verifies the full embed → downsample → phase-search → rotation → RS-decode chain.
- func TestRoundTrip(t *testing.T) {
- const key = "test-key-42"
- const recRate = float64(RecordingRate) // 48000
- const compRate = float64(CompositeRate) // 228000
- const duration = 15.0 // seconds — should give ~2 full frames
-
- nRecSamples := int(duration * recRate)
-
- // === Embed ===
- emb := NewEmbedder(key)
- // No gate — test pure watermark signal
- samples := make([]float64, 0, nRecSamples)
-
- // Drive embedder at CompositeRate, collect at RecordingRate via Bresenham
- accum := 0
- var last float64
- for len(samples) < nRecSamples {
- last = emb.NextSample()
- accum += RecordingRate
- if accum >= CompositeRate {
- accum -= CompositeRate
- samples = append(samples, last)
- }
- }
-
- t.Logf("Embedded: %d samples @ %.0f Hz = %.2fs", len(samples), recRate, float64(len(samples))/recRate)
-
- // RMS check
- var rmsAcc float64
- for _, s := range samples {
- rmsAcc += s * s
- }
- rms := math.Sqrt(rmsAcc / float64(len(samples)))
- rmsDBFS := 20 * math.Log10(rms+1e-12)
- t.Logf("Watermark RMS: %.1f dBFS (expect ~-48)", rmsDBFS)
- if rmsDBFS < -52 || rmsDBFS > -44 {
- t.Errorf("RMS %.1f dBFS out of expected range [-52, -44]", rmsDBFS)
- }
-
- // === Decode: Phase search ===
- samplesPerBit := int(float64(PnChips) * recRate / float64(RecordingRate))
- t.Logf("samplesPerBit=%d, frameLen=%d", samplesPerBit, samplesPerBit*PayloadBits)
-
- const coarseStep = 8
- const syncBits = 64
-
- bestPhase := 0
- bestMag := 0.0
- for phase := 0; phase < samplesPerBit; phase += coarseStep {
- mag := testAvgCorrMag(samples, phase, samplesPerBit, syncBits, recRate)
- if mag > bestMag {
- bestMag = mag
- bestPhase = phase
- }
- }
- fineStart := bestPhase - coarseStep
- if fineStart < 0 { fineStart = 0 }
- fineEnd := bestPhase + coarseStep
- if fineEnd > samplesPerBit { fineEnd = samplesPerBit }
- for phase := fineStart; phase < fineEnd; phase++ {
- mag := testAvgCorrMag(samples, phase, samplesPerBit, syncBits, recRate)
- if mag > bestMag {
- bestMag = mag
- bestPhase = phase
- }
- }
- t.Logf("Phase search: bestPhase=%d, avgCorr=%.4f", bestPhase, bestMag)
-
- // Phase should be 0 for clean signal starting at sample 0
- if bestPhase != 0 {
- t.Errorf("expected bestPhase=0, got %d", bestPhase)
- }
-
- // === Decode: Extract correlations ===
- nCompleteBits := (len(samples) - bestPhase) / samplesPerBit
- nFrames := nCompleteBits / PayloadBits
- if nFrames == 0 { nFrames = 1 }
- t.Logf("Complete bits: %d, frames: %d", nCompleteBits, nFrames)
-
- corrs := make([]float64, PayloadBits)
- for i := 0; i < PayloadBits; i++ {
- for frame := 0; frame < nFrames; frame++ {
- bitGlobal := frame*PayloadBits + i
- start := bestPhase + bitGlobal*samplesPerBit
- if start+samplesPerBit > len(samples) { break }
- corrs[i] += CorrelateAt(samples, start, recRate)
- }
- }
-
- // Log correlation stats
- var minAbs, maxAbs float64
- for i, c := range corrs {
- ac := math.Abs(c)
- if i == 0 || ac < minAbs { minAbs = ac }
- if ac > maxAbs { maxAbs = ac }
- }
- t.Logf("Correlation range: min|c|=%.2f, max|c|=%.2f", minAbs, maxAbs)
-
- // === Decode: Frame sync via rotation ===
- type decodeResult struct {
- rotation int
- payload [RsDataBytes]byte
- erasures int
- }
- var best *decodeResult
-
- for rot := 0; rot < PayloadBits; rot++ {
- var recv [RsTotalBytes]byte
- confs := make([]float64, PayloadBits)
- for i := 0; i < PayloadBits; i++ {
- srcBit := (i + rot) % PayloadBits
- c := corrs[srcBit]
- confs[i] = math.Abs(c)
- if c < 0 {
- recv[i/8] |= 1 << uint(7-(i%8))
- }
- }
-
- type bitConf struct { idx int; conf float64 }
- ranked := make([]bitConf, PayloadBits)
- for i := range ranked { ranked[i] = bitConf{i, confs[i]} }
- sort.Slice(ranked, func(a, b int) bool { return ranked[a].conf < ranked[b].conf })
-
- for nErase := 0; nErase <= RsCheckBytes*8; nErase++ {
- erasedBytes := map[int]bool{}
- for _, bc := range ranked[:nErase] {
- erasedBytes[bc.idx/8] = true
- }
- if len(erasedBytes) > RsCheckBytes { break }
- erasePos := make([]int, 0, len(erasedBytes))
- for pos := range erasedBytes { erasePos = append(erasePos, pos) }
- sort.Ints(erasePos)
-
- payload, ok := RSDecode(recv, erasePos)
- if ok {
- if best == nil || len(erasePos) < best.erasures {
- best = &decodeResult{rotation: rot, payload: payload, erasures: len(erasePos)}
- }
- break
- }
- }
- if best != nil && best.erasures == 0 { break }
- }
-
- if best == nil {
- t.Fatal("RS decode FAILED — no valid rotation found")
- }
-
- t.Logf("Decoded: rotation=%d, erasures=%d, payload=%x", best.rotation, best.erasures, best.payload)
-
- // Rotation should be 0 for clean signal
- if best.rotation != 0 {
- t.Errorf("expected rotation=0, got %d", best.rotation)
- }
- if best.erasures != 0 {
- t.Errorf("expected 0 erasures, got %d", best.erasures)
- }
-
- // Key match
- if !KeyMatchesPayload(key, best.payload) {
- t.Errorf("key %q does NOT match decoded payload %x", key, best.payload)
- } else {
- t.Logf("Key %q MATCHES", key)
- }
- }
-
- func testAvgCorrMag(samples []float64, phase, samplesPerBit, nBits int, recRate float64) float64 {
- var total float64
- var count int
- for b := 0; b < nBits; b++ {
- start := phase + b*samplesPerBit
- if start+samplesPerBit > len(samples) { break }
- c := CorrelateAt(samples, start, recRate)
- total += math.Abs(c)
- count++
- }
- if count == 0 { return 0 }
- return total / float64(count)
- }
|