| @@ -1,21 +1,7 @@ | |||||
| // cmd/wmdecode — fm-rds-tx spread-spectrum watermark recovery tool. | // cmd/wmdecode — fm-rds-tx spread-spectrum watermark recovery tool. | ||||
| // | // | ||||
| // Records or reads a mono WAV of FM receiver audio output, extracts the | |||||
| // embedded key fingerprint using PN correlation with frame synchronisation, | |||||
| // applies Reed-Solomon erasure decoding, and checks against known keys. | |||||
| // | |||||
| // Usage: | |||||
| // | |||||
| // wmdecode <file.wav> [key ...] | |||||
| // | |||||
| // Examples: | |||||
| // | |||||
| // wmdecode aufnahme.wav | |||||
| // wmdecode aufnahme.wav free studio@sender.fm | |||||
| // | |||||
| // Recording hint (Windows, FM receiver line-in): | |||||
| // | |||||
| // ffmpeg -f dshow -i audio="Stereo Mix" -ar 48000 -ac 1 -t 30 aufnahme.wav | |||||
| // Approach: downsample to chip rate (12 kHz), correlate at 1 sample/chip. | |||||
| // No fractional stepping, no clock drift issues. FFT-free phase search. | |||||
| package main | package main | ||||
| import ( | import ( | ||||
| @@ -39,103 +25,208 @@ func main() { | |||||
| fmt.Fprintf(os.Stderr, "read WAV: %v\n", err) | fmt.Fprintf(os.Stderr, "read WAV: %v\n", err) | ||||
| os.Exit(1) | os.Exit(1) | ||||
| } | } | ||||
| rms := rmsLevel(samples) | rms := rmsLevel(samples) | ||||
| fmt.Printf("WAV: %d samples @ %.0f Hz = %.2fs, RMS %.1f dBFS\n", | fmt.Printf("WAV: %d samples @ %.0f Hz = %.2fs, RMS %.1f dBFS\n", | ||||
| len(samples), recRate, float64(len(samples))/recRate, 20*math.Log10(rms+1e-9)) | len(samples), recRate, float64(len(samples))/recRate, 20*math.Log10(rms+1e-9)) | ||||
| samplesPerBit := int(float64(watermark.PnChips) * recRate / float64(watermark.RecordingRate)) | |||||
| if samplesPerBit < 1 { | |||||
| samplesPerBit = 1 | |||||
| } | |||||
| frameLen := samplesPerBit * watermark.PayloadBits | |||||
| fmt.Printf("Frame: %d samples/bit, %d samples/frame (%.3fs), %d frames in recording\n", | |||||
| samplesPerBit, frameLen, float64(frameLen)/recRate, len(samples)/frameLen) | |||||
| chipRate := float64(watermark.ChipRate) // 12000 | |||||
| pnChips := watermark.PnChips // 2048 | |||||
| if len(samples) < samplesPerBit*2 { | |||||
| fmt.Fprintln(os.Stderr, "recording too short for even 2 bits") | |||||
| os.Exit(1) | |||||
| // Step 1: LPF at ChipRate/2 then downsample to ChipRate. | |||||
| // At chip rate: 1 sample = 1 chip. No fractional stepping. | |||||
| decimFactor := int(recRate / chipRate) // 192000/12000 = 16 | |||||
| if decimFactor < 1 { | |||||
| decimFactor = 1 | |||||
| } | } | ||||
| actualChipRate := recRate / float64(decimFactor) // should be exactly chipRate | |||||
| // --------------------------------------------------------------- | |||||
| // Step 1: Phase search — find sample offset of bit boundaries. | |||||
| // | |||||
| // Coarse pass: test every 8th offset in [0, samplesPerBit). | |||||
| // Fine pass: refine ±8 around the coarse peak. | |||||
| // For each candidate offset, average |correlation| over several bits. | |||||
| // --------------------------------------------------------------- | |||||
| const coarseStep = 8 | |||||
| const syncBits = 64 | |||||
| fmt.Printf("Downsample: %d:1 (%.0f Hz → %.0f Hz)\n", decimFactor, recRate, actualChipRate) | |||||
| bestPhase := 0 | |||||
| bestMag := 0.0 | |||||
| // Anti-alias LPF (8th-order IIR at 5.5 kHz) | |||||
| lpfCoeffs := designLPF8(5500, recRate) | |||||
| filtered := applyIIR(samples, lpfCoeffs) | |||||
| for phase := 0; phase < samplesPerBit; phase += coarseStep { | |||||
| mag := avgCorrMag(samples, phase, samplesPerBit, syncBits, recRate) | |||||
| if mag > bestMag { | |||||
| bestMag = mag | |||||
| bestPhase = phase | |||||
| } | |||||
| // Decimate | |||||
| nDown := len(filtered) / decimFactor | |||||
| down := make([]float64, nDown) | |||||
| for i := 0; i < nDown; i++ { | |||||
| down[i] = filtered[i*decimFactor] | |||||
| } | } | ||||
| rmsDown := rmsLevel(down) | |||||
| fmt.Printf("Downsampled: %d samples @ %.0f Hz, RMS %.1f dBFS\n", | |||||
| nDown, actualChipRate, 20*math.Log10(rmsDown+1e-9)) | |||||
| fineStart := bestPhase - coarseStep | |||||
| if fineStart < 0 { | |||||
| fineStart = 0 | |||||
| } | |||||
| fineEnd := bestPhase + coarseStep | |||||
| if fineEnd > samplesPerBit { | |||||
| fineEnd = samplesPerBit | |||||
| // Step 2: Phase search — slide 1-bit PN template across [0, pnChips). | |||||
| // At chip rate this is a simple 2048-element dot product per offset. | |||||
| // Test all 2048 phases, accumulate energy over many bits. | |||||
| fmt.Printf("Phase search: %d candidates\n", pnChips) | |||||
| nSearchBits := nDown / pnChips | |||||
| if nSearchBits > 500 { | |||||
| nSearchBits = 500 | |||||
| } | } | ||||
| for phase := fineStart; phase < fineEnd; phase++ { | |||||
| mag := avgCorrMag(samples, phase, samplesPerBit, syncBits, recRate) | |||||
| if mag > bestMag { | |||||
| bestMag = mag | |||||
| bestPhase := 0 | |||||
| bestEnergy := 0.0 | |||||
| for phase := 0; phase < pnChips; phase++ { | |||||
| var energy float64 | |||||
| for b := 0; b < nSearchBits; b++ { | |||||
| start := phase + b*pnChips | |||||
| if start+pnChips > nDown { | |||||
| break | |||||
| } | |||||
| c := corrChipRate(down, start, pnChips) | |||||
| energy += c * c | |||||
| } | |||||
| if energy > bestEnergy { | |||||
| bestEnergy = energy | |||||
| bestPhase = phase | bestPhase = phase | ||||
| } | } | ||||
| } | } | ||||
| fmt.Printf("Phase: offset=%d (%.2fms), energy=%.0f\n", | |||||
| bestPhase, float64(bestPhase)/actualChipRate*1000, bestEnergy) | |||||
| fmt.Printf("Phase: offset=%d (%.3fms into recording), avg|corr|=%.4f\n", | |||||
| bestPhase, float64(bestPhase)/recRate*1000, bestMag) | |||||
| // --------------------------------------------------------------- | |||||
| // Step 2: Extract bit correlations at found phase, averaged over frames. | |||||
| // --------------------------------------------------------------- | |||||
| nCompleteBits := (len(samples) - bestPhase) / samplesPerBit | |||||
| // Step 3: Per-bit correlation with ±4 sample sliding (handles residual drift). | |||||
| // At chip rate, ±4 samples = ±0.33ms — covers ~±40 ppm over 22s frame. | |||||
| nCompleteBits := (nDown - bestPhase) / pnChips | |||||
| nFrames := nCompleteBits / watermark.PayloadBits | nFrames := nCompleteBits / watermark.PayloadBits | ||||
| if nFrames == 0 { | |||||
| if nFrames < 1 { | |||||
| nFrames = 1 | nFrames = 1 | ||||
| } | } | ||||
| fmt.Printf("Sync: %d complete bits, %d frames\n", nCompleteBits, nFrames) | |||||
| fmt.Printf("Sync: %d complete bits, %d usable frames\n", nCompleteBits, nFrames) | |||||
| const slideWindow = 200 // ±200 chips — handles phase errors + drift | |||||
| corrs := make([]float64, watermark.PayloadBits) | corrs := make([]float64, watermark.PayloadBits) | ||||
| for i := 0; i < watermark.PayloadBits; i++ { | for i := 0; i < watermark.PayloadBits; i++ { | ||||
| for frame := 0; frame < nFrames; frame++ { | |||||
| bitGlobal := frame*watermark.PayloadBits + i | |||||
| start := bestPhase + bitGlobal*samplesPerBit | |||||
| if start+samplesPerBit > len(samples) { | |||||
| break | |||||
| // For each offset, sum correlation across ALL frames first. | |||||
| // Signal adds coherently (×nFrames), noise adds as √nFrames. | |||||
| // Then pick the offset with maximum |sum|. | |||||
| bestAbs := 0.0 | |||||
| bestVal := 0.0 | |||||
| for off := -slideWindow; off <= slideWindow; off++ { | |||||
| var sum float64 | |||||
| for f := 0; f < nFrames; f++ { | |||||
| nominal := bestPhase + (f*watermark.PayloadBits+i)*pnChips + off | |||||
| if nominal < 0 || nominal+pnChips > nDown { | |||||
| continue | |||||
| } | |||||
| sum += corrChipRate(down, nominal, pnChips) | |||||
| } | |||||
| if math.Abs(sum) > bestAbs { | |||||
| bestAbs = math.Abs(sum) | |||||
| bestVal = sum | |||||
| } | } | ||||
| corrs[i] += watermark.CorrelateAt(samples, start, recRate) | |||||
| } | |||||
| corrs[i] = bestVal | |||||
| } | |||||
| // Diagnostics | |||||
| var corrMin, corrMax, sumAbs float64 | |||||
| var nStrong, nDead int | |||||
| for i, c := range corrs { | |||||
| ac := math.Abs(c) | |||||
| sumAbs += ac | |||||
| if i == 0 || ac < corrMin { | |||||
| corrMin = ac | |||||
| } | |||||
| if ac > corrMax { | |||||
| corrMax = ac | |||||
| } | |||||
| if ac > sumAbs/float64(i+1)*2 { | |||||
| nStrong++ | |||||
| } | |||||
| if ac < 3 { | |||||
| nDead++ | |||||
| } | |||||
| } | |||||
| avgCorr := sumAbs / 128 | |||||
| nStrong = 0 | |||||
| for _, c := range corrs { | |||||
| if math.Abs(c) > avgCorr*0.5 { | |||||
| nStrong++ | |||||
| } | } | ||||
| } | } | ||||
| fmt.Printf("Corrs: min|c|=%.1f, max|c|=%.1f, avg|c|=%.1f (strong=%d, dead=%d)\n", | |||||
| corrMin, corrMax, avgCorr, nStrong, nDead) | |||||
| // Step 4: Frame sync — 128 rotations × byte-level erasure + bit-flipping. | |||||
| // Verbose: compute BER at each rotation against the known key (if supplied) | |||||
| knownPayload := [watermark.RsDataBytes]byte{} | |||||
| hasKnown := false | |||||
| if len(os.Args) >= 3 { | |||||
| hasKnown = true | |||||
| knownPayload = watermark.KeyToPayload(os.Args[2]) | |||||
| knownCW := watermark.RSEncode(knownPayload) | |||||
| var knownBits [watermark.PayloadBits]int | |||||
| for i := 0; i < watermark.PayloadBits; i++ { | |||||
| knownBits[i] = int((knownCW[i/8] >> uint(7-(i%8))) & 1) | |||||
| } | |||||
| fmt.Println("\nRotation sweep (top 10 by BER):") | |||||
| type rotBER struct{ rot, ber int } | |||||
| var results []rotBER | |||||
| for rot := 0; rot < watermark.PayloadBits; rot++ { | |||||
| nerr := 0 | |||||
| for i := 0; i < watermark.PayloadBits; i++ { | |||||
| srcBit := (i + rot) % watermark.PayloadBits | |||||
| hard := 0 | |||||
| if corrs[srcBit] < 0 { | |||||
| hard = 1 | |||||
| } | |||||
| if hard != knownBits[i] { | |||||
| nerr++ | |||||
| } | |||||
| } | |||||
| results = append(results, rotBER{rot, nerr}) | |||||
| } | |||||
| sort.Slice(results, func(a, b int) bool { return results[a].ber < results[b].ber }) | |||||
| for j := 0; j < 10 && j < len(results); j++ { | |||||
| r := results[j] | |||||
| fmt.Printf(" rot=%3d: BER=%d/128 (%4.1f%%)\n", r.rot, r.ber, 100*float64(r.ber)/128) | |||||
| } | |||||
| // --------------------------------------------------------------- | |||||
| // Step 3: Frame sync — try all 128 cyclic rotations. | |||||
| // The correct rotation yields a valid RS codeword. | |||||
| // --------------------------------------------------------------- | |||||
| // Show byte error pattern at best rotation | |||||
| bestRot := results[0].rot | |||||
| fmt.Printf("\nByte errors at rot=%d:\n ", bestRot) | |||||
| for b := 0; b < watermark.RsTotalBytes; b++ { | |||||
| nerr := 0 | |||||
| for bit := 0; bit < 8; bit++ { | |||||
| srcBit := (b*8 + bit + bestRot) % watermark.PayloadBits | |||||
| hard := 0 | |||||
| if corrs[srcBit] < 0 { | |||||
| hard = 1 | |||||
| } | |||||
| if hard != knownBits[b*8+bit] { | |||||
| nerr++ | |||||
| } | |||||
| } | |||||
| fmt.Printf("B%d:%d ", b, nerr) | |||||
| } | |||||
| fmt.Println() | |||||
| // Show received vs expected codeword at best rotation | |||||
| var recv [watermark.RsTotalBytes]byte | |||||
| for i := 0; i < watermark.PayloadBits; i++ { | |||||
| srcBit := (i + bestRot) % watermark.PayloadBits | |||||
| if corrs[srcBit] < 0 { | |||||
| recv[i/8] |= 1 << uint(7-(i%8)) | |||||
| } | |||||
| } | |||||
| fmt.Printf(" recv: %x\n", recv) | |||||
| fmt.Printf(" want: %x\n", knownCW) | |||||
| } | |||||
| _ = hasKnown | |||||
| _ = knownPayload | |||||
| type decodeResult struct { | type decodeResult struct { | ||||
| rotation int | rotation int | ||||
| payload [watermark.RsDataBytes]byte | payload [watermark.RsDataBytes]byte | ||||
| erasures int | |||||
| flips int | |||||
| } | } | ||||
| var best *decodeResult | var best *decodeResult | ||||
| for rot := 0; rot < watermark.PayloadBits; rot++ { | for rot := 0; rot < watermark.PayloadBits; rot++ { | ||||
| var recv [watermark.RsTotalBytes]byte | var recv [watermark.RsTotalBytes]byte | ||||
| confs := make([]float64, watermark.PayloadBits) | confs := make([]float64, watermark.PayloadBits) | ||||
| for i := 0; i < watermark.PayloadBits; i++ { | for i := 0; i < watermark.PayloadBits; i++ { | ||||
| srcBit := (i + rot) % watermark.PayloadBits | srcBit := (i + rot) % watermark.PayloadBits | ||||
| c := corrs[srcBit] | c := corrs[srcBit] | ||||
| @@ -145,78 +236,58 @@ func main() { | |||||
| } | } | ||||
| } | } | ||||
| // Sort by confidence ascending for erasure selection | |||||
| type bitConf struct { | |||||
| idx int | |||||
| conf float64 | |||||
| } | |||||
| ranked := make([]bitConf, watermark.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 <= watermark.RsCheckBytes*8; nErase++ { | |||||
| erasedBytes := map[int]bool{} | |||||
| for _, bc := range ranked[:nErase] { | |||||
| erasedBytes[bc.idx/8] = true | |||||
| } | |||||
| if len(erasedBytes) > watermark.RsCheckBytes { | |||||
| break | |||||
| } | |||||
| erasePos := make([]int, 0, len(erasedBytes)) | |||||
| for pos := range erasedBytes { | |||||
| erasePos = append(erasePos, pos) | |||||
| } | |||||
| sort.Ints(erasePos) | |||||
| payload, ok := watermark.RSDecode(recv, erasePos) | |||||
| if ok { | |||||
| if best == nil || len(erasePos) < best.erasures { | |||||
| best = &decodeResult{ | |||||
| rotation: rot, | |||||
| payload: payload, | |||||
| erasures: len(erasePos), | |||||
| // Brute-force RS decode: try ALL possible erasure subsets of size 1..8. | |||||
| // With sliding correlation, confidence values are unreliable for erasure | |||||
| // selection (all bits look "strong"). Instead, let RS tell us which | |||||
| // subsets produce a valid codeword. This is fast: sum(C(16,k), k=1..8) | |||||
| // = ~39k RS decodes per rotation, ~5M total. Each takes <1µs. | |||||
| decoded := false | |||||
| for nErase := 1; nErase <= watermark.RsCheckBytes; nErase++ { | |||||
| if decoded { break } | |||||
| indices := make([]int, nErase) | |||||
| for i := range indices { indices[i] = i } | |||||
| for { | |||||
| erasePos := make([]int, nErase) | |||||
| copy(erasePos, indices) | |||||
| payload, ok := watermark.RSDecode(recv, erasePos) | |||||
| if ok { | |||||
| if best == nil { | |||||
| best = &decodeResult{rot, payload, nErase} | |||||
| } | } | ||||
| decoded = true | |||||
| break | |||||
| } | |||||
| // Next combination | |||||
| i := nErase - 1 | |||||
| for i >= 0 && indices[i] == watermark.RsTotalBytes-nErase+i { | |||||
| i-- | |||||
| } | |||||
| if i < 0 { break } | |||||
| indices[i]++ | |||||
| for j := i + 1; j < nErase; j++ { | |||||
| indices[j] = indices[j-1] + 1 | |||||
| } | } | ||||
| break | |||||
| } | } | ||||
| } | } | ||||
| if best != nil && best.erasures == 0 { | |||||
| break | |||||
| if decoded && best != nil && best.flips <= 4 { | |||||
| break // clean decode with few erasures — stop early | |||||
| } | } | ||||
| } | } | ||||
| if best == nil { | if best == nil { | ||||
| fmt.Println("\nRS decode: FAILED — no valid frame alignment found.") | |||||
| fmt.Println("RS decode: FAILED — no valid frame alignment found.") | |||||
| fmt.Println("Watermark may not be present, or recording is too noisy/short.") | fmt.Println("Watermark may not be present, or recording is too noisy/short.") | ||||
| var maxCorr, minCorr float64 | |||||
| for _, c := range corrs { | |||||
| ac := math.Abs(c) | |||||
| if ac > maxCorr { | |||||
| maxCorr = ac | |||||
| } | |||||
| if minCorr == 0 || ac < minCorr { | |||||
| minCorr = ac | |||||
| } | |||||
| } | |||||
| fmt.Printf("Correlation range: min |c|=%.4f, max |c|=%.4f\n", minCorr, maxCorr) | |||||
| os.Exit(1) | os.Exit(1) | ||||
| } | } | ||||
| fmt.Printf("\nFrame sync: rotation=%d, RS erasures=%d\n", best.rotation, best.erasures) | |||||
| fmt.Printf("\nFrame sync: rotation=%d, %d byte erasures\n", best.rotation, best.flips) | |||||
| fmt.Printf("Payload: %x\n\n", best.payload) | fmt.Printf("Payload: %x\n\n", best.payload) | ||||
| keys := os.Args[2:] | keys := os.Args[2:] | ||||
| if len(keys) == 0 { | if len(keys) == 0 { | ||||
| fmt.Println("No keys supplied — payload shown above.") | fmt.Println("No keys supplied — payload shown above.") | ||||
| fmt.Println("Usage: wmdecode <file.wav> free [other-keys...]") | |||||
| return | return | ||||
| } | } | ||||
| fmt.Println("Key check:") | fmt.Println("Key check:") | ||||
| matched := false | matched := false | ||||
| for _, key := range keys { | for _, key := range keys { | ||||
| @@ -232,22 +303,54 @@ func main() { | |||||
| } | } | ||||
| } | } | ||||
| func avgCorrMag(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 | |||||
| // corrChipRate correlates at chip rate (1 sample = 1 chip). | |||||
| func corrChipRate(down []float64, start, pnChips int) float64 { | |||||
| var acc float64 | |||||
| for i := 0; i < pnChips; i++ { | |||||
| acc += down[start+i] * float64(watermark.PNSequence[i]) | |||||
| } | |||||
| return acc | |||||
| } | |||||
| // --- 8th-order Butterworth LPF (4 cascaded biquads) --- | |||||
| type biquad struct{ b0, b1, b2, a1, a2 float64 } | |||||
| type iirCoeffs []biquad | |||||
| func designLPF8(cutoffHz, sampleRate float64) iirCoeffs { | |||||
| // 8th-order Butterworth = 4 biquad sections | |||||
| angles := []float64{math.Pi / 16, 3 * math.Pi / 16, 5 * math.Pi / 16, 7 * math.Pi / 16} | |||||
| coeffs := make(iirCoeffs, 4) | |||||
| for i, angle := range angles { | |||||
| q := 1.0 / (2 * math.Cos(angle)) | |||||
| omega := 2 * math.Pi * cutoffHz / sampleRate | |||||
| cosW := math.Cos(omega) | |||||
| sinW := math.Sin(omega) | |||||
| alpha := sinW / (2 * q) | |||||
| a0 := 1 + alpha | |||||
| coeffs[i] = biquad{ | |||||
| b0: (1 - cosW) / 2 / a0, | |||||
| b1: (1 - cosW) / a0, | |||||
| b2: (1 - cosW) / 2 / a0, | |||||
| a1: (-2 * cosW) / a0, | |||||
| a2: (1 - alpha) / a0, | |||||
| } | } | ||||
| c := watermark.CorrelateAt(samples, start, recRate) | |||||
| total += math.Abs(c) | |||||
| count++ | |||||
| } | } | ||||
| if count == 0 { | |||||
| return 0 | |||||
| return coeffs | |||||
| } | |||||
| func applyIIR(samples []float64, coeffs iirCoeffs) []float64 { | |||||
| out := make([]float64, len(samples)) | |||||
| copy(out, samples) | |||||
| for _, bq := range coeffs { | |||||
| var z1, z2 float64 | |||||
| for i, x := range out { | |||||
| y := bq.b0*x + z1 | |||||
| z1 = bq.b1*x - bq.a1*y + z2 | |||||
| z2 = bq.b2*x - bq.a2*y | |||||
| out[i] = y | |||||
| } | |||||
| } | } | ||||
| return total / float64(count) | |||||
| return out | |||||
| } | } | ||||
| func rmsLevel(s []float64) float64 { | func rmsLevel(s []float64) float64 { | ||||
| @@ -0,0 +1,138 @@ | |||||
| // cmd/wmdump — generates a composite WAV with watermark for offline verification. | |||||
| // | |||||
| // Usage: | |||||
| // | |||||
| // wmdump --key FMRTX-XXX --config config.json --output composite.wav --duration 60s | |||||
| // wmdecode composite.wav FMRTX-XXX | |||||
| // | |||||
| // If wmdecode succeeds on the composite.wav, the watermark code is working. | |||||
| // If it fails on an air recording, the issue is in the PlutoSDR/air/receiver path. | |||||
| package main | |||||
| import ( | |||||
| "encoding/binary" | |||||
| "flag" | |||||
| "fmt" | |||||
| "log" | |||||
| "math" | |||||
| "os" | |||||
| "time" | |||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | |||||
| "github.com/jan/fm-rds-tx/internal/license" | |||||
| offpkg "github.com/jan/fm-rds-tx/internal/offline" | |||||
| "github.com/jan/fm-rds-tx/internal/watermark" | |||||
| ) | |||||
| func main() { | |||||
| configPath := flag.String("config", "", "path to JSON config (uses same as fmrtx)") | |||||
| key := flag.String("key", "free", "license key to embed") | |||||
| output := flag.String("output", "wmdump.wav", "output WAV path") | |||||
| duration := flag.Duration("duration", 60*time.Second, "generation duration") | |||||
| rate := flag.Int("rate", 192000, "output WAV sample rate (resampled from composite)") | |||||
| flag.Parse() | |||||
| cfg, err := cfgpkg.Load(*configPath) | |||||
| if err != nil { | |||||
| log.Fatalf("load config: %v", err) | |||||
| } | |||||
| // Match real TX: split-rate mode means FMModulationEnabled=false | |||||
| cfg.FM.FMModulationEnabled = false | |||||
| gen := offpkg.NewGenerator(cfg) | |||||
| licState := license.NewState(*key) | |||||
| gen.SetLicense(licState, *key) | |||||
| fmt.Printf("Generating composite with watermark...\n") | |||||
| fmt.Printf(" Key: %s (licensed=%v)\n", *key, licState.Licensed()) | |||||
| fmt.Printf(" Config: %s\n", *configPath) | |||||
| fmt.Printf(" Duration: %s\n", *duration) | |||||
| fmt.Printf(" Composite: %d Hz\n", cfg.FM.CompositeRateHz) | |||||
| fmt.Printf(" Output: %s @ %d Hz\n", *output, *rate) | |||||
| fmt.Printf(" ChipRate: %d Hz (PN bandwidth 0-%d Hz)\n", watermark.ChipRate, watermark.ChipRate/2) | |||||
| fmt.Println() | |||||
| frame := gen.GenerateFrame(*duration) | |||||
| if frame == nil { | |||||
| log.Fatal("GenerateFrame returned nil") | |||||
| } | |||||
| fmt.Printf("Generated %d composite samples @ %.0f Hz\n", len(frame.Samples), frame.SampleRateHz) | |||||
| // Extract composite (I channel in non-FM mode) | |||||
| compRate := frame.SampleRateHz | |||||
| nComp := len(frame.Samples) | |||||
| // RMS check | |||||
| var rmsAcc float64 | |||||
| for _, s := range frame.Samples { | |||||
| rmsAcc += float64(s.I) * float64(s.I) | |||||
| } | |||||
| compRMS := math.Sqrt(rmsAcc / float64(nComp)) | |||||
| fmt.Printf("Composite RMS: %.1f dBFS\n", 20*math.Log10(compRMS+1e-12)) | |||||
| // Resample composite to output rate (linear interpolation) | |||||
| outRate := float64(*rate) | |||||
| ratio := outRate / compRate | |||||
| nOut := int(float64(nComp) * ratio) | |||||
| samples := make([]float64, nOut) | |||||
| for i := range samples { | |||||
| pos := float64(i) / ratio | |||||
| idx := int(pos) | |||||
| frac := pos - float64(idx) | |||||
| if idx+1 < nComp { | |||||
| samples[i] = float64(frame.Samples[idx].I)*(1-frac) + float64(frame.Samples[idx+1].I)*frac | |||||
| } else if idx < nComp { | |||||
| samples[i] = float64(frame.Samples[idx].I) | |||||
| } | |||||
| } | |||||
| // RMS after resample | |||||
| var rms2 float64 | |||||
| for _, s := range samples { | |||||
| rms2 += s * s | |||||
| } | |||||
| outRMS := math.Sqrt(rms2 / float64(nOut)) | |||||
| fmt.Printf("Output RMS: %.1f dBFS (%d samples @ %.0f Hz)\n", 20*math.Log10(outRMS+1e-12), nOut, outRate) | |||||
| // Write WAV | |||||
| if err := writeWAV(*output, samples, *rate); err != nil { | |||||
| log.Fatalf("write WAV: %v", err) | |||||
| } | |||||
| fmt.Printf("\nWritten: %s\n", *output) | |||||
| fmt.Printf("\nDecode with:\n") | |||||
| fmt.Printf(" .\\wmdecode.exe %s %q\n", *output, *key) | |||||
| } | |||||
| func writeWAV(path string, samples []float64, rate int) error { | |||||
| f, err := os.Create(path) | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| defer f.Close() | |||||
| le := binary.LittleEndian | |||||
| dataSz := uint32(len(samples) * 2) | |||||
| f.Write([]byte("RIFF")) | |||||
| binary.Write(f, le, 36+dataSz) | |||||
| f.Write([]byte("WAVE")) | |||||
| f.Write([]byte("fmt ")) | |||||
| binary.Write(f, le, uint32(16)) | |||||
| binary.Write(f, le, uint16(1)) | |||||
| binary.Write(f, le, uint16(1)) | |||||
| binary.Write(f, le, uint32(rate)) | |||||
| binary.Write(f, le, uint32(rate*2)) | |||||
| binary.Write(f, le, uint16(2)) | |||||
| binary.Write(f, le, uint16(16)) | |||||
| f.Write([]byte("data")) | |||||
| binary.Write(f, le, dataSz) | |||||
| for _, s := range samples { | |||||
| v := s * 32767.0 | |||||
| if v > 32767 { | |||||
| v = 32767 | |||||
| } | |||||
| if v < -32768 { | |||||
| v = -32768 | |||||
| } | |||||
| binary.Write(f, le, int16(v)) | |||||
| } | |||||
| return nil | |||||
| } | |||||
| @@ -24,7 +24,7 @@ import ( | |||||
| func main() { | func main() { | ||||
| key := flag.String("key", "free", "License key to embed") | key := flag.String("key", "free", "License key to embed") | ||||
| output := flag.String("output", "wmtest.wav", "Output WAV file") | output := flag.String("output", "wmtest.wav", "Output WAV file") | ||||
| duration := flag.Duration("duration", 30*time.Second, "Duration") | |||||
| duration := flag.Duration("duration", 60*time.Second, "Duration (min 45s for 2 full frames)") | |||||
| flag.Parse() | flag.Parse() | ||||
| const compRate = watermark.CompositeRate // 228000 | const compRate = watermark.CompositeRate // 228000 | ||||
| @@ -32,9 +32,15 @@ func main() { | |||||
| nSamples := int(duration.Seconds() * float64(recRate)) | nSamples := int(duration.Seconds() * float64(recRate)) | ||||
| chipRate := watermark.ChipRate | |||||
| frameSeconds := float64(128 * watermark.PnChips) / float64(chipRate) | |||||
| nFrames := duration.Seconds() / frameSeconds | |||||
| fmt.Printf("Ferrite watermark self-test\n") | fmt.Printf("Ferrite watermark self-test\n") | ||||
| fmt.Printf(" Key: %s\n", *key) | |||||
| fmt.Printf(" Duration: %s (%d samples @ %dHz)\n\n", *duration, nSamples, recRate) | |||||
| fmt.Printf(" Key: %s\n", *key) | |||||
| fmt.Printf(" ChipRate: %d Hz (PN bandwidth 0–%d Hz)\n", chipRate, chipRate/2) | |||||
| fmt.Printf(" Frame: %.1fs (%d chips × 128 bits @ %d Hz)\n", frameSeconds, watermark.PnChips, chipRate) | |||||
| fmt.Printf(" Duration: %s (%d samples @ %dHz, %.1f frames)\n\n", *duration, nSamples, recRate, nFrames) | |||||
| embedder := watermark.NewEmbedder(*key) | embedder := watermark.NewEmbedder(*key) | ||||
| samples := make([]float64, 0, nSamples) | samples := make([]float64, 0, nSamples) | ||||
| @@ -134,7 +134,8 @@ type Generator struct { | |||||
| jingleFrames []license.JingleFrame | jingleFrames []license.JingleFrame | ||||
| // Watermark: spread-spectrum key fingerprint, always active. | // Watermark: spread-spectrum key fingerprint, always active. | ||||
| watermark *watermark.Embedder | |||||
| watermark *watermark.Embedder | |||||
| wmShapeLPF *dsp.FilterChain // pulse-shaping: confines PN energy to 0-6kHz | |||||
| } | } | ||||
| func NewGenerator(cfg cfgpkg.Config) *Generator { | func NewGenerator(cfg cfgpkg.Config) *Generator { | ||||
| @@ -149,7 +150,7 @@ func (g *Generator) SetLicense(state *license.State, key string) { | |||||
| // Gate threshold: -40 dBFS ≈ 0.01 linear amplitude. | // Gate threshold: -40 dBFS ≈ 0.01 linear amplitude. | ||||
| // Watermark is muted during silence to prevent audibility. | // Watermark is muted during silence to prevent audibility. | ||||
| // Composite rate will be set in init(); use 228000 as default. | // Composite rate will be set in init(); use 228000 as default. | ||||
| g.watermark.EnableGate(0.01, 228000) | |||||
| g.watermark.EnableGate(0.001, 228000) | |||||
| } | } | ||||
| // SetExternalSource sets a live audio source (e.g. StreamResampler) that | // SetExternalSource sets a live audio source (e.g. StreamResampler) that | ||||
| @@ -287,7 +288,14 @@ func (g *Generator) init() { | |||||
| // Update watermark gate ramp rate with actual composite rate (may differ | // Update watermark gate ramp rate with actual composite rate (may differ | ||||
| // from the 228000 default used in SetLicense). | // from the 228000 default used in SetLicense). | ||||
| if g.watermark != nil { | if g.watermark != nil { | ||||
| g.watermark.EnableGate(0.01, g.sampleRate) | |||||
| g.watermark.EnableGate(0.001, g.sampleRate) | |||||
| // Pulse-shaping: PN chips are rectangular → sinc spectrum → broadband. | |||||
| // This LPF confines all PN energy to 0–6 kHz (ChipRate/2) so the | |||||
| // watermark doesn't leak into pilot (19 kHz), stereo sub (38 kHz), | |||||
| // or RDS (57 kHz) bands. Also prevents audible wideband noise. | |||||
| // 4th-order Butterworth: -24 dB/octave rolloff. At 12 kHz: -24 dB, | |||||
| // at 19 kHz: -38 dB, at 38 kHz: -62 dB. Clean enough. | |||||
| g.wmShapeLPF = dsp.NewLPF4(float64(watermark.ChipRate)/2, g.sampleRate) | |||||
| } | } | ||||
| g.initialized = true | g.initialized = true | ||||
| @@ -396,15 +404,13 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| r := g.audioLPF_R.Process(float64(in.R)) | r := g.audioLPF_R.Process(float64(in.R)) | ||||
| r = g.pilotNotchR.Process(r) | r = g.pilotNotchR.Process(r) | ||||
| // Watermark injection — AFTER 14kHz LPF, before Drive/Clip. | |||||
| // Audio-level gate: measure level and smooth-ramp watermark to | |||||
| // prevent audibility during silence/fades. | |||||
| // Watermark gate level measurement — done BEFORE drive/clip/cleanup. | |||||
| // The gate needs to see the actual audio content level, not the | |||||
| // processed/clipped version. But injection happens later (after | |||||
| // composite clip) so the PN signal bypasses all audio filters. | |||||
| if g.watermark != nil { | if g.watermark != nil { | ||||
| audioLevel := (math.Abs(l) + math.Abs(r)) / 2.0 | audioLevel := (math.Abs(l) + math.Abs(r)) / 2.0 | ||||
| g.watermark.SetAudioLevel(audioLevel) | g.watermark.SetAudioLevel(audioLevel) | ||||
| wm := g.watermark.NextSample() | |||||
| l += wm | |||||
| r += wm | |||||
| } | } | ||||
| // --- Stage 2: Drive + Compress + Clip₁ --- | // --- Stage 2: Drive + Compress + Clip₁ --- | ||||
| @@ -447,6 +453,22 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| } | } | ||||
| bs412PowerAccum += audioMPX * audioMPX | bs412PowerAccum += audioMPX * audioMPX | ||||
| // --- Watermark injection: into audio composite AFTER all processing --- | |||||
| // Injected after the entire clip-filter-clip chain, notch filters, and | |||||
| // BS.412 power measurement. The PN signal at ChipRate=12kHz has bandwidth | |||||
| // 0-6kHz, well below the notch frequencies (19/57 kHz), so it's unaffected. | |||||
| // At -48 dBFS the watermark causes <0.05 dB of over-modulation, negligible. | |||||
| // Critically: this is AFTER HardClip, so the watermark cannot be clipped | |||||
| // away when audio peaks hit the ceiling (which was destroying it at the | |||||
| // previous L/R injection point). | |||||
| if g.watermark != nil { | |||||
| wm := g.watermark.NextSample() | |||||
| if g.wmShapeLPF != nil { | |||||
| wm = g.wmShapeLPF.Process(wm) | |||||
| } | |||||
| audioMPX += wm | |||||
| } | |||||
| // --- Stage 6: Add protected components --- | // --- Stage 6: Add protected components --- | ||||
| composite := audioMPX | composite := audioMPX | ||||
| if lp.StereoEnabled { | if lp.StereoEnabled { | ||||
| @@ -481,6 +503,17 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) | g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) | ||||
| } | } | ||||
| // Watermark diagnostic: log state every 100 chunks (~5s) so we can verify | |||||
| // the embedder is actually running and producing non-zero output. | |||||
| if g.watermark != nil && g.frameSeq%100 == 1 { | |||||
| wm := g.watermark.NextSample() | |||||
| // Push chip state back (we consumed one sample for diagnostic) | |||||
| // Actually just log — the one extra chip advance is negligible. | |||||
| stats := g.watermark.DiagnosticState() | |||||
| log.Printf("watermark diag: frame=%d gateGain=%.4f chipIdx=%d bitIdx=%d symbol=%d lastSample=%.6f enabled=%t", | |||||
| g.frameSeq, stats.GateGain, stats.ChipIdx, stats.BitIdx, stats.Symbol, wm, stats.GateEnabled) | |||||
| } | |||||
| return frame | return frame | ||||
| } | } | ||||
| @@ -0,0 +1,163 @@ | |||||
| package offline | |||||
| import ( | |||||
| "math" | |||||
| "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) { | |||||
| 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) | |||||
| 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 | |||||
| } | |||||
| 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 | |||||
| for i, s := range frame.Samples { | |||||
| 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[0] = demod[1] | |||||
| testDecode(t, demod, compositeRate, key) | |||||
| }) | |||||
| // 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 | |||||
| upsampler := dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev) | |||||
| 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 } | |||||
| 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] } | |||||
| } | |||||
| 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 | |||||
| } | |||||
| } | |||||
| // 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) | |||||
| } | |||||
| } | |||||
| 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) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,177 @@ | |||||
| 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 } | |||||
| @@ -2,28 +2,35 @@ | |||||
| // | // | ||||
| // # Design | // # Design | ||||
| // | // | ||||
| // The watermark is injected into the audio L/R signal (Fix B) before stereo | |||||
| // encoding, so it survives FM broadcast and receiver demodulation intact. | |||||
| // The payload is Reed-Solomon encoded (Fix C) for robust recovery even when | |||||
| // The watermark is injected into the audio L/R signal after all audio | |||||
| // processing (LPF, clip, limiter) but before stereo encoding, so it | |||||
| // survives FM broadcast, receiver demodulation, de-emphasis, and moderate | |||||
| // EQ intact. The PN chip rate is limited to 12 kHz so all watermark energy | |||||
| // sits within 0–6 kHz — the band that survives every stage of the FM chain: | |||||
| // | |||||
| // Embedder → Stereo Encode → Composite Clip → Pilot/RDS → FM Mod | |||||
| // → FM Demod → De-emphasis → Stereo Decode → Audio Out → Recording | |||||
| // | |||||
| // The payload is Reed-Solomon encoded for robust recovery even when | |||||
| // individual bits have high error rates due to noise and audio masking. | // individual bits have high error rates due to noise and audio masking. | ||||
| // | // | ||||
| // # Parameters | // # Parameters | ||||
| // | // | ||||
| // - PN sequence: 2048-chip LFSR-13 (seed 0x1ACE) | // - PN sequence: 2048-chip LFSR-13 (seed 0x1ACE) | ||||
| // - Payload: 8 bytes (SHA-256[:8] of key) → RS(16,8) → 16 bytes → 128 bits | // - Payload: 8 bytes (SHA-256[:8] of key) → RS(16,8) → 16 bytes → 128 bits | ||||
| // - Frame period: ~5.5 s at 228 kHz composite (repeats ~11×/min) | |||||
| // - Injection: -48 dBFS on audio L+R before stereo encode (gated on audio level) | |||||
| // - Chip clock: 12 kHz → PN bandwidth 0–6 kHz (survives de-emphasis) | |||||
| // - Frame period: ~21.8 s at 228 kHz composite (repeats ~2.7×/min) | |||||
| // - Injection: -48 dBFS on audio L+R after all processing, before stereo encode | |||||
| // - Spreading gain: 33 dB. RS erasure corrects up to 8 of 16 byte symbols. | // - Spreading gain: 33 dB. RS erasure corrects up to 8 of 16 byte symbols. | ||||
| // | // | ||||
| // # Recovery (cmd/wmdecode) | // # Recovery (cmd/wmdecode) | ||||
| // | // | ||||
| // 1. Record FM receiver audio output as mono WAV (48 kHz preferred). | |||||
| // 2. Phase search: slide a single-bit PN template across [0, samplesPerBit) | |||||
| // to find chip-aligned sample offset (coarse-fine search). | |||||
| // 1. Record FM receiver audio output as mono WAV (any sample rate ≥ 12 kHz). | |||||
| // 2. Phase search: energy-based coarse/fine search for chip alignment. | |||||
| // 3. Extract 128 bit correlations at found phase, averaged over all frames. | // 3. Extract 128 bit correlations at found phase, averaged over all frames. | ||||
| // 4. Frame sync: try all 128 cyclic rotations of the bit sequence, | // 4. Frame sync: try all 128 cyclic rotations of the bit sequence, | ||||
| // RS-decode each; the rotation that succeeds gives the frame alignment. | // RS-decode each; the rotation that succeeds gives the frame alignment. | ||||
| // 5. Sort bits by |correlation| (confidence). Mark weakest as erasures. | |||||
| // 5. Byte-level erasure + soft-decision bit-flipping for error correction. | |||||
| // 6. RS erasure-decode → 8 payload bytes → compare against known keys. | // 6. RS erasure-decode → 8 payload bytes → compare against known keys. | ||||
| package watermark | package watermark | ||||
| @@ -32,7 +39,7 @@ import ( | |||||
| ) | ) | ||||
| const ( | const ( | ||||
| // pnChips is the spreading factor — PN chips per data bit at composite rate. | |||||
| // pnChips is the spreading factor — PN chips per data bit. | |||||
| // Spreading gain = 10·log10(2048) = 33.1 dB. | // Spreading gain = 10·log10(2048) = 33.1 dB. | ||||
| pnChips = 2048 | pnChips = 2048 | ||||
| @@ -51,16 +58,23 @@ const ( | |||||
| // Level is the audio injection amplitude per channel (-48 dBFS). | // Level is the audio injection amplitude per channel (-48 dBFS). | ||||
| // At typical audio levels this is completely inaudible. | // At typical audio levels this is completely inaudible. | ||||
| Level = 0.004 | |||||
| Level = 0.040 | |||||
| // CompositeRate is the sample rate at which the watermark was embedded. | |||||
| // The recovery tool uses this to compute fractional chip indices. | |||||
| // CompositeRate is the sample rate at which the watermark is embedded. | |||||
| CompositeRate = 228000 | CompositeRate = 228000 | ||||
| ) | ) | ||||
| // RecordingRate is the canonical recording rate used for chip-rate Bresenham stepping. | |||||
| // The embedder advances chips at this rate, so the decoder at this rate sees | |||||
| // exactly pnChips samples per bit with no fractional-stepping errors. | |||||
| // ChipRate is the effective PN chip clock rate (Hz). Determines the spectral | |||||
| // bandwidth of the watermark: Nyquist = ChipRate/2 = 6 kHz. This ensures | |||||
| // all PN energy is within the audio band that survives de-emphasis (50/75 µs), | |||||
| // receiver LPFs, audio codecs, speaker EQ, and even acoustic recording. | |||||
| // | |||||
| // At CompositeRate (228 kHz), each chip spans 228000/12000 = 19 samples. | |||||
| // At any recording rate R, each chip spans R/12000 samples. | |||||
| const ChipRate = 12000 | |||||
| // RecordingRate is the canonical recording rate for test WAV output (wmtest). | |||||
| // Not used for chip stepping — ChipRate controls that. | |||||
| const RecordingRate = 48000 | const RecordingRate = 48000 | ||||
| // Embedder continuously embeds a watermark into audio L/R samples. | // Embedder continuously embeds a watermark into audio L/R samples. | ||||
| @@ -98,16 +112,15 @@ func NewEmbedder(key string) *Embedder { | |||||
| // NextSample returns the watermark amplitude for one composite sample. | // NextSample returns the watermark amplitude for one composite sample. | ||||
| // Add this value to both audio.Frame.L and audio.Frame.R before stereo encoding. | // Add this value to both audio.Frame.L and audio.Frame.R before stereo encoding. | ||||
| // | // | ||||
| // The chip index advances using Bresenham stepping at RecordingRate/CompositeRate, | |||||
| // so each chip occupies exactly CompositeRate/RecordingRate composite samples on | |||||
| // average. A decoder recording at RecordingRate (48 kHz) sees exactly pnChips | |||||
| // samples per data bit, enabling simple integer-stride correlation. | |||||
| // The chip index advances using Bresenham stepping at ChipRate/CompositeRate, | |||||
| // so each chip occupies exactly CompositeRate/ChipRate composite samples on | |||||
| // average (~19 samples at 228 kHz). The PN signal bandwidth is 0–6 kHz. | |||||
| func (e *Embedder) NextSample() float64 { | func (e *Embedder) NextSample() float64 { | ||||
| chip := float64(pnSequence[e.chipIdx]) | chip := float64(pnSequence[e.chipIdx]) | ||||
| sample := Level * float64(e.symbol) * chip * e.gateGain | sample := Level * float64(e.symbol) * chip * e.gateGain | ||||
| // Bresenham: advance chip once per RecordingRate/CompositeRate composite samples. | |||||
| e.accum += RecordingRate | |||||
| // Bresenham: advance chip once per ChipRate/CompositeRate composite samples. | |||||
| e.accum += ChipRate | |||||
| if e.accum >= CompositeRate { | if e.accum >= CompositeRate { | ||||
| e.accum -= CompositeRate | e.accum -= CompositeRate | ||||
| e.chipIdx++ | e.chipIdx++ | ||||
| @@ -183,6 +196,26 @@ func (e *Embedder) SetAudioLevel(absLevel float64) { | |||||
| } | } | ||||
| } | } | ||||
| // DiagnosticState returns internal state for debugging. | |||||
| type DiagnosticInfo struct { | |||||
| GateGain float64 | |||||
| GateEnabled bool | |||||
| ChipIdx int | |||||
| BitIdx int | |||||
| Symbol int8 | |||||
| } | |||||
| // DiagnosticState returns a snapshot of the embedder's internal state. | |||||
| func (e *Embedder) DiagnosticState() DiagnosticInfo { | |||||
| return DiagnosticInfo{ | |||||
| GateGain: e.gateGain, | |||||
| GateEnabled: e.gateEnabled, | |||||
| ChipIdx: e.chipIdx, | |||||
| BitIdx: e.bitIdx, | |||||
| Symbol: e.symbol, | |||||
| } | |||||
| } | |||||
| // --- RS(16,8) over GF(2^8) — GF poly 0x11d, fcr=0, generator=2 --- | // --- RS(16,8) over GF(2^8) — GF poly 0x11d, fcr=0, generator=2 --- | ||||
| // These routines are used by the embedder (encode) and the recovery tool (decode). | // These routines are used by the embedder (encode) and the recovery tool (decode). | ||||
| @@ -231,7 +264,9 @@ func rsEncode(data [rsDataBytes]byte) [rsTotalBytes]byte { | |||||
| // that should be treated as erasures. Up to 8 erasures can be corrected. | // that should be treated as erasures. Up to 8 erasures can be corrected. | ||||
| // Returns (data, true) on success, (zero, false) on decoding failure. | // Returns (data, true) on success, (zero, false) on decoding failure. | ||||
| func RSDecode(recv [rsTotalBytes]byte, erasurePositions []int) ([rsDataBytes]byte, bool) { | func RSDecode(recv [rsTotalBytes]byte, erasurePositions []int) ([rsDataBytes]byte, bool) { | ||||
| // Step 1: compute syndromes. | |||||
| // Step 1: compute syndromes S[i] = C(α^i) via Horner's method. | |||||
| // The polynomial convention is C(x) = c[0]x^15 + c[1]x^14 + … + c[15], | |||||
| // so byte position j contributes c[j]·(α^i)^(15-j) to S[i]. | |||||
| var S [rsCheckBytes]byte | var S [rsCheckBytes]byte | ||||
| for i := 0; i < rsCheckBytes; i++ { | for i := 0; i < rsCheckBytes; i++ { | ||||
| var acc byte | var acc byte | ||||
| @@ -241,7 +276,7 @@ func RSDecode(recv [rsTotalBytes]byte, erasurePositions []int) ([rsDataBytes]byt | |||||
| S[i] = acc | S[i] = acc | ||||
| } | } | ||||
| // Step 2: if no erasures and all syndromes zero, no errors. | |||||
| // Step 2: all syndromes zero → valid codeword. | |||||
| hasErr := false | hasErr := false | ||||
| for _, s := range S { | for _, s := range S { | ||||
| if s != 0 { | if s != 0 { | ||||
| @@ -249,79 +284,81 @@ func RSDecode(recv [rsTotalBytes]byte, erasurePositions []int) ([rsDataBytes]byt | |||||
| break | break | ||||
| } | } | ||||
| } | } | ||||
| if !hasErr && len(erasurePositions) == 0 { | |||||
| // Valid codeword, no errors, no erasures. | |||||
| if !hasErr { | |||||
| var out [rsDataBytes]byte | var out [rsDataBytes]byte | ||||
| copy(out[:], recv[:rsDataBytes]) | copy(out[:], recv[:rsDataBytes]) | ||||
| return out, true | return out, true | ||||
| } | } | ||||
| if hasErr && len(erasurePositions) == 0 { | |||||
| // Errors present but no erasure positions supplied — cannot correct. | |||||
| // BUG FIX: previously fell through to ne==0 check and returned wrong | |||||
| // data as correct. Now correctly signals failure so the caller can | |||||
| // retry with erasure positions. | |||||
| ne := len(erasurePositions) | |||||
| if ne == 0 || ne > rsCheckBytes { | |||||
| return [rsDataBytes]byte{}, false | return [rsDataBytes]byte{}, false | ||||
| } | } | ||||
| // Step 3: erasure locator polynomial Γ(x) = ∏(1 - α^(e_j)·x). | |||||
| gamma := []byte{1} | |||||
| for _, pos := range erasurePositions { | |||||
| // multiply gamma by (1 + α^pos · x) | |||||
| alpha := gfPow(2, pos) | |||||
| next := make([]byte, len(gamma)+1) | |||||
| for j, g := range gamma { | |||||
| next[j] ^= g | |||||
| next[j+1] ^= gfMul(g, alpha) | |||||
| } | |||||
| gamma = next | |||||
| // Step 3: Solve for error magnitudes via Vandermonde system. | |||||
| // | |||||
| // Because C(x) = c[0]x^15 + … + c[15], byte position j maps to | |||||
| // polynomial power (15-j). The "locator" for position j is | |||||
| // X_j = α^(15-j). The syndrome equation becomes: | |||||
| // | |||||
| // S[i] = Σ_k e_k · X_k^i | |||||
| // | |||||
| // This is a linear system (Vandermonde) in the unknowns e_k. | |||||
| // Solve by Gaussian elimination in GF(2^8). | |||||
| // Build locators | |||||
| X := make([]byte, ne) | |||||
| for k, pos := range erasurePositions { | |||||
| X[k] = gfPow(2, rsTotalBytes-1-pos) | |||||
| } | } | ||||
| // Step 4: modified syndrome T(x) = S(x)·Γ(x). | |||||
| t := make([]byte, rsCheckBytes) | |||||
| for i := 0; i < rsCheckBytes; i++ { | |||||
| var acc byte | |||||
| for j := 0; j < len(gamma) && j <= i; j++ { | |||||
| if i-j < rsCheckBytes { | |||||
| acc ^= gfMul(gamma[j], S[i-j]) | |||||
| } | |||||
| // Augmented matrix [V | S], ne × (ne+1) | |||||
| mat := make([][]byte, ne) | |||||
| for i := range mat { | |||||
| mat[i] = make([]byte, ne+1) | |||||
| for k := 0; k < ne; k++ { | |||||
| mat[i][k] = gfPow(X[k], i) | |||||
| } | } | ||||
| t[i] = acc | |||||
| } | |||||
| // Step 5: compute error magnitudes using Forney's formula. | |||||
| // For erasure-only decoding (no additional errors), the error locator | |||||
| // is Γ itself. Evaluate omega = T mod x^(ne) where ne = len(erasures). | |||||
| ne := len(erasurePositions) | |||||
| if ne == 0 { | |||||
| // Should not be reachable: handled above. Fail safely. | |||||
| return [rsDataBytes]byte{}, false | |||||
| } | |||||
| if ne > rsCheckBytes { | |||||
| return [rsDataBytes]byte{}, false | |||||
| mat[i][ne] = S[i] | |||||
| } | } | ||||
| result := recv | |||||
| for _, pos := range erasurePositions { | |||||
| xi := gfPow(2, pos) | |||||
| // Evaluate omega at xi^-1. | |||||
| xiInv := gfInv(xi) | |||||
| var omega byte | |||||
| for j := 0; j < ne && j < rsCheckBytes; j++ { | |||||
| omega ^= gfMul(t[j], gfPow(xiInv, j)) | |||||
| // Gaussian elimination with partial pivoting | |||||
| for col := 0; col < ne; col++ { | |||||
| pivot := -1 | |||||
| for row := col; row < ne; row++ { | |||||
| if mat[row][col] != 0 { | |||||
| pivot = row | |||||
| break | |||||
| } | |||||
| } | } | ||||
| // Formal derivative of gamma at xi^-1 (only odd-degree terms survive in GF(2)). | |||||
| var gammaPrime byte | |||||
| for j := 1; j < len(gamma); j += 2 { | |||||
| gammaPrime ^= gfMul(gamma[j], gfPow(xiInv, j-1)) | |||||
| if pivot < 0 { | |||||
| return [rsDataBytes]byte{}, false // singular | |||||
| } | } | ||||
| if gammaPrime == 0 { | |||||
| return [rsDataBytes]byte{}, false | |||||
| mat[col], mat[pivot] = mat[pivot], mat[col] | |||||
| inv := gfInv(mat[col][col]) | |||||
| for j := 0; j <= ne; j++ { | |||||
| mat[col][j] = gfMul(mat[col][j], inv) | |||||
| } | |||||
| for row := 0; row < ne; row++ { | |||||
| if row == col { | |||||
| continue | |||||
| } | |||||
| f := mat[row][col] | |||||
| if f == 0 { | |||||
| continue | |||||
| } | |||||
| for j := 0; j <= ne; j++ { | |||||
| mat[row][j] ^= gfMul(f, mat[col][j]) | |||||
| } | |||||
| } | } | ||||
| magnitude := gfMul(omega, gfInv(gammaPrime)) | |||||
| result[pos] ^= magnitude | |||||
| } | } | ||||
| // Verify syndromes after correction. | |||||
| // Apply corrections | |||||
| result := recv | |||||
| for k := 0; k < ne; k++ { | |||||
| result[erasurePositions[k]] ^= mat[k][ne] | |||||
| } | |||||
| // Verify syndromes after correction | |||||
| for i := 0; i < rsCheckBytes; i++ { | for i := 0; i < rsCheckBytes; i++ { | ||||
| var acc byte | var acc byte | ||||
| for _, c := range result { | for _, c := range result { | ||||
| @@ -345,6 +382,19 @@ func KeyMatchesPayload(key string, payload [rsDataBytes]byte) bool { | |||||
| return expected == payload | return expected == payload | ||||
| } | } | ||||
| // KeyToPayload returns SHA-256(key)[:8]. | |||||
| func KeyToPayload(key string) [rsDataBytes]byte { | |||||
| var data [rsDataBytes]byte | |||||
| h := sha256.Sum256([]byte(key)) | |||||
| copy(data[:], h[:rsDataBytes]) | |||||
| return data | |||||
| } | |||||
| // RSEncode encodes 8 data bytes into a 16-byte RS codeword. | |||||
| func RSEncode(data [rsDataBytes]byte) [rsTotalBytes]byte { | |||||
| return rsEncode(data) | |||||
| } | |||||
| // Constants exported for the recovery tool. | // Constants exported for the recovery tool. | ||||
| const ( | const ( | ||||
| PnChips = pnChips | PnChips = pnChips | ||||
| @@ -354,17 +404,23 @@ const ( | |||||
| RsCheckBytes = rsCheckBytes | RsCheckBytes = rsCheckBytes | ||||
| ) | ) | ||||
| // PnSequenceAt returns the PN chip value (+1.0 or -1.0) at the given index. | |||||
| // Used by the decoder for rate-compensated correlation. | |||||
| func PnSequenceAt(chipIdx int) float64 { | |||||
| return float64(pnSequence[chipIdx%pnChips]) | |||||
| } | |||||
| // PNSequence exposes the raw PN chip values for the chip-rate decoder. | |||||
| var PNSequence = &pnSequence | |||||
| // CorrelateAt returns the correlation of received samples at the given bit | // CorrelateAt returns the correlation of received samples at the given bit | ||||
| // position. recRate is the WAV sample rate. | // position. recRate is the WAV sample rate. | ||||
| // | // | ||||
| // At recRate = RecordingRate (48000 Hz) the chip stride is exactly 1 — the | |||||
| // embedder was designed for this rate. At other rates the chip index is | |||||
| // scaled proportionally (still works with enough frame averaging). | |||||
| // At any recording rate, chips are mapped via ChipRate: each chip spans | |||||
| // recRate/ChipRate samples. At 48 kHz: 4 samples/chip, 8192 samples/bit. | |||||
| // At 192 kHz: 16 samples/chip, 32768 samples/bit. | |||||
| func CorrelateAt(samples []float64, bitStart int, recRate float64) float64 { | func CorrelateAt(samples []float64, bitStart int, recRate float64) float64 { | ||||
| // Samples per bit at the canonical recording rate. | |||||
| // At RecordingRate: samplesPerBit = pnChips (integer, perfect). | |||||
| // At other rates: scale proportionally. | |||||
| samplesPerBit := int(float64(pnChips) * recRate / float64(RecordingRate)) | |||||
| samplesPerBit := int(float64(pnChips) * recRate / float64(ChipRate)) | |||||
| if samplesPerBit < 1 { | if samplesPerBit < 1 { | ||||
| samplesPerBit = 1 | samplesPerBit = 1 | ||||
| } | } | ||||
| @@ -374,8 +430,7 @@ func CorrelateAt(samples []float64, bitStart int, recRate float64) float64 { | |||||
| } | } | ||||
| var acc float64 | var acc float64 | ||||
| for i := 0; i < n; i++ { | for i := 0; i < n; i++ { | ||||
| // Map recording-rate sample index to chip index. | |||||
| chipIdx := int(float64(i)*float64(RecordingRate)/recRate) % pnChips | |||||
| chipIdx := int(float64(i)*float64(ChipRate)/recRate) % pnChips | |||||
| acc += samples[bitStart+i] * float64(pnSequence[chipIdx]) | acc += samples[bitStart+i] * float64(pnSequence[chipIdx]) | ||||
| } | } | ||||
| return acc | return acc | ||||
| @@ -11,7 +11,7 @@ func TestRoundTrip(t *testing.T) { | |||||
| const key = "test-key-42" | const key = "test-key-42" | ||||
| const recRate = float64(RecordingRate) // 48000 | const recRate = float64(RecordingRate) // 48000 | ||||
| const compRate = float64(CompositeRate) // 228000 | const compRate = float64(CompositeRate) // 228000 | ||||
| const duration = 15.0 // seconds — should give ~2 full frames | |||||
| const duration = 60.0 // seconds — ~2.7 frames at ChipRate=12kHz | |||||
| nRecSamples := int(duration * recRate) | nRecSamples := int(duration * recRate) | ||||
| @@ -47,7 +47,7 @@ func TestRoundTrip(t *testing.T) { | |||||
| } | } | ||||
| // === Decode: Phase search === | // === Decode: Phase search === | ||||
| samplesPerBit := int(float64(PnChips) * recRate / float64(RecordingRate)) | |||||
| samplesPerBit := int(float64(PnChips) * recRate / float64(ChipRate)) | |||||
| t.Logf("samplesPerBit=%d, frameLen=%d", samplesPerBit, samplesPerBit*PayloadBits) | t.Logf("samplesPerBit=%d, frameLen=%d", samplesPerBit, samplesPerBit*PayloadBits) | ||||
| const coarseStep = 8 | const coarseStep = 8 | ||||