Browse Source

feat: add license watermarking and harden restart paths

main
Jan 1 month ago
parent
commit
e1329a864c
16 changed files with 1571 additions and 21 deletions
  1. +26
    -7
      cmd/fmrtx/main.go
  2. +41
    -0
      cmd/keygen/main.go
  3. +314
    -0
      cmd/wmdecode/main.go
  4. +106
    -0
      cmd/wmtest/main.go
  5. +15
    -1
      internal/app/engine.go
  6. +5
    -1
      internal/control/control.go
  7. +4
    -0
      internal/ingest/adapters/aoip/source.go
  8. +5
    -0
      internal/ingest/adapters/icecast/source.go
  9. +5
    -0
      internal/ingest/adapters/srt/source.go
  10. +3
    -0
      internal/ingest/adapters/stdinpcm/source.go
  11. +13
    -0
      internal/license/embed.go
  12. +245
    -0
      internal/license/license.go
  13. +59
    -5
      internal/offline/generator.go
  14. +19
    -7
      internal/output/frame_queue.go
  15. +523
    -0
      internal/watermark/watermark.go
  16. +188
    -0
      internal/watermark/watermark_roundtrip_test.go

+ 26
- 7
cmd/fmrtx/main.go View File

@@ -12,6 +12,7 @@ import (
"time"

apppkg "github.com/jan/fm-rds-tx/internal/app"
"github.com/jan/fm-rds-tx/internal/license"
"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
@@ -34,6 +35,7 @@ func main() {
simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)")
txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch")
licenseKey := flag.String("license", "", "fm-rds-tx license key (omit for evaluation mode with jingle)")
listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit")
audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin")
audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)")
@@ -101,12 +103,12 @@ func main() {
if driver == nil {
log.Fatal("no hardware driver available - build with -tags pluto (or -tags soapy)")
}
runTXMode(cfg, *configPath, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP)
runTXMode(cfg, *configPath, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP, *licenseKey)
return
}

srv := ctrlpkg.NewServer(cfg)
configureControlPlanePersistence(srv, *configPath)
configureControlPlanePersistence(srv, *configPath, nil)
server := ctrlpkg.NewHTTPServer(cfg, srv.Handler())
log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr)
log.Fatal(server.ListenAndServe())
@@ -141,7 +143,7 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
return nil
}

func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) {
func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool, licenseKey string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

@@ -174,6 +176,16 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver
}

engine := apppkg.NewEngine(cfg, driver)

// License setup.
licState := license.NewState(licenseKey)
if licState.Licensed() {
log.Println("license: valid key — evaluation jingle disabled")
} else {
log.Printf("license: no valid key — evaluation jingle every %d minutes", license.JingleIntervalMinutes)
}
engine.SetLicenseState(licState, licenseKey)
log.Printf("watermark: embedding key fingerprint into composite signal")
cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP)

var streamSrc *audio.StreamSource
@@ -228,7 +240,7 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver
}

srv := ctrlpkg.NewServer(cfg)
configureControlPlanePersistence(srv, configPath)
configureControlPlanePersistence(srv, configPath, cancel)
srv.SetDriver(driver)
srv.SetTXController(&txBridge{engine: engine})
if streamSrc != nil {
@@ -272,7 +284,7 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver
log.Println("shutdown complete")
}

func configureControlPlanePersistence(srv *ctrlpkg.Server, configPath string) {
func configureControlPlanePersistence(srv *ctrlpkg.Server, configPath string, cancel context.CancelFunc) {
if strings.TrimSpace(configPath) == "" {
return
}
@@ -280,8 +292,15 @@ func configureControlPlanePersistence(srv *ctrlpkg.Server, configPath string) {
return cfgpkg.Save(configPath, next)
})
srv.SetHardReload(func() {
log.Printf("control: hard reload requested after config save, exiting process")
os.Exit(0)
// BUG-5 fix: cancel the app context instead of os.Exit(0).
// os.Exit skips all defers, Flush/Stop calls, and driver cleanup.
// Cancelling ctx lets the normal shutdown sequence run: engine.Stop,
// ingestRuntime.Stop, driver.Close — then the process exits naturally.
// The supervisor (systemd etc.) will restart the process as intended.
log.Printf("control: hard reload — cancelling app context for clean restart")
if cancel != nil {
cancel()
}
})
}



+ 41
- 0
cmd/keygen/main.go View File

@@ -0,0 +1,41 @@
// cmd/keygen — fm-rds-tx license key generator.
// KEEP PRIVATE: this tool contains the HMAC secret. Never distribute.
//
// Usage:
// go run ./cmd/keygen free → gratis key (no personal data)
// go run ./cmd/keygen studio@wxy.fm → commercial key for that station
package main

import (
"fmt"
"os"
"strings"

"github.com/jan/fm-rds-tx/internal/license"
)

func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "usage: keygen <payload>\n")
fmt.Fprintf(os.Stderr, " payload: 'free' for gratis key, email for commercial\n")
fmt.Fprintf(os.Stderr, "examples:\n")
fmt.Fprintf(os.Stderr, " keygen free\n")
fmt.Fprintf(os.Stderr, " keygen studio@example.fm\n")
os.Exit(1)
}

payload := strings.TrimSpace(strings.Join(os.Args[1:], " "))
if payload == "" {
fmt.Fprintln(os.Stderr, "payload must not be empty")
os.Exit(1)
}

key := license.GenerateKey(payload)
fmt.Println(key)

// Self-validate
if !license.ValidateKey(key) {
fmt.Fprintln(os.Stderr, "ERROR: generated key failed self-validation!")
os.Exit(1)
}
}

+ 314
- 0
cmd/wmdecode/main.go View File

@@ -0,0 +1,314 @@
// 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
package main

import (
"encoding/binary"
"fmt"
"math"
"os"
"sort"

"github.com/jan/fm-rds-tx/internal/watermark"
)

func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: wmdecode <file.wav> [key ...]")
os.Exit(1)
}

samples, recRate, err := readMonoWAV(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "read WAV: %v\n", err)
os.Exit(1)
}

rms := rmsLevel(samples)
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))

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)

if len(samples) < samplesPerBit*2 {
fmt.Fprintln(os.Stderr, "recording too short for even 2 bits")
os.Exit(1)
}

// ---------------------------------------------------------------
// 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

bestPhase := 0
bestMag := 0.0

for phase := 0; phase < samplesPerBit; phase += coarseStep {
mag := avgCorrMag(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 := avgCorrMag(samples, phase, samplesPerBit, syncBits, recRate)
if mag > bestMag {
bestMag = mag
bestPhase = phase
}
}

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
nFrames := nCompleteBits / watermark.PayloadBits
if nFrames == 0 {
nFrames = 1
}

fmt.Printf("Sync: %d complete bits, %d usable frames\n", nCompleteBits, nFrames)

corrs := make([]float64, watermark.PayloadBits)
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
}
corrs[i] += watermark.CorrelateAt(samples, start, recRate)
}
}

// ---------------------------------------------------------------
// Step 3: Frame sync — try all 128 cyclic rotations.
// The correct rotation yields a valid RS codeword.
// ---------------------------------------------------------------
type decodeResult struct {
rotation int
payload [watermark.RsDataBytes]byte
erasures int
}

var best *decodeResult

for rot := 0; rot < watermark.PayloadBits; rot++ {
var recv [watermark.RsTotalBytes]byte
confs := make([]float64, watermark.PayloadBits)

for i := 0; i < watermark.PayloadBits; i++ {
srcBit := (i + rot) % watermark.PayloadBits
c := corrs[srcBit]
confs[i] = math.Abs(c)
if c < 0 {
recv[i/8] |= 1 << uint(7-(i%8))
}
}

// 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),
}
}
break
}
}

if best != nil && best.erasures == 0 {
break
}
}

if best == nil {
fmt.Println("\nRS decode: FAILED — no valid frame alignment found.")
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)
}

fmt.Printf("\nFrame sync: rotation=%d, RS erasures=%d\n", best.rotation, best.erasures)
fmt.Printf("Payload: %x\n\n", best.payload)

keys := os.Args[2:]
if len(keys) == 0 {
fmt.Println("No keys supplied — payload shown above.")
fmt.Println("Usage: wmdecode <file.wav> free [other-keys...]")
return
}

fmt.Println("Key check:")
matched := false
for _, key := range keys {
if watermark.KeyMatchesPayload(key, best.payload) {
fmt.Printf(" ✓ MATCH: %q\n", key)
matched = true
} else {
fmt.Printf(" ✗ : %q\n", key)
}
}
if !matched {
fmt.Println("\nNo key matched.")
}
}

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
}
c := watermark.CorrelateAt(samples, start, recRate)
total += math.Abs(c)
count++
}
if count == 0 {
return 0
}
return total / float64(count)
}

func rmsLevel(s []float64) float64 {
var acc float64
for _, v := range s {
acc += v * v
}
return math.Sqrt(acc / float64(len(s)))
}

func readMonoWAV(path string) ([]float64, float64, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, 0, err
}
if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
return nil, 0, fmt.Errorf("not a RIFF/WAVE file")
}
var channels, bitsPerSample uint16
var sampleRate uint32
var dataStart, dataLen int
i := 12
for i+8 <= len(data) {
id := string(data[i : i+4])
sz := int(binary.LittleEndian.Uint32(data[i+4 : i+8]))
i += 8
switch id {
case "fmt ":
if sz >= 16 {
channels = binary.LittleEndian.Uint16(data[i+2 : i+4])
sampleRate = binary.LittleEndian.Uint32(data[i+4 : i+8])
bitsPerSample = binary.LittleEndian.Uint16(data[i+14 : i+16])
}
case "data":
dataStart, dataLen = i, sz
}
i += sz
if sz%2 != 0 {
i++
}
if dataStart > 0 && channels > 0 {
break
}
}
if dataStart == 0 || bitsPerSample != 16 || channels == 0 {
return nil, 0, fmt.Errorf("unsupported WAV (need 16-bit PCM, got bits=%d ch=%d)", bitsPerSample, channels)
}
if dataStart+dataLen > len(data) {
dataLen = len(data) - dataStart
}
step := int(channels) * 2
nFrames := dataLen / step
out := make([]float64, nFrames)
for j := 0; j < nFrames; j++ {
off := dataStart + j*step
l := float64(int16(binary.LittleEndian.Uint16(data[off : off+2])))
r := l
if channels >= 2 {
r = float64(int16(binary.LittleEndian.Uint16(data[off+2 : off+4])))
}
out[j] = (l + r) / 2.0 / 32768.0
}
return out, float64(sampleRate), nil
}

+ 106
- 0
cmd/wmtest/main.go View File

@@ -0,0 +1,106 @@
// cmd/wmtest — Ferrite watermark self-test tool.
//
// Generates a mono WAV file containing only the spread-spectrum watermark
// signal (silence + watermark, scaled up for visibility). Run wmdecode on
// the output to verify embedder and decoder work without FM transmission.
//
// Usage:
//
// wmtest --key <license-key> --output test.wav --duration 30s
// wmdecode test.wav <license-key>
package main

import (
"encoding/binary"
"flag"
"fmt"
"math"
"os"
"time"

"github.com/jan/fm-rds-tx/internal/watermark"
)

func main() {
key := flag.String("key", "free", "License key to embed")
output := flag.String("output", "wmtest.wav", "Output WAV file")
duration := flag.Duration("duration", 30*time.Second, "Duration")
flag.Parse()

const compRate = watermark.CompositeRate // 228000
const recRate = watermark.RecordingRate // 48000

nSamples := int(duration.Seconds() * float64(recRate))

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)

embedder := watermark.NewEmbedder(*key)
samples := make([]float64, 0, nSamples)

// Drive embedder at composite rate, collect samples at recording rate.
// Bresenham: accumulate recRate each composite step; when >= compRate,
// emit one recording sample and subtract compRate.
accum := 0
var last float64
for len(samples) < nSamples {
last = embedder.NextSample()
accum += recRate
if accum >= compRate {
accum -= compRate
samples = append(samples, last)
}
}

// RMS
var rmsAcc float64
for _, s := range samples {
rmsAcc += s * s
}
rms := math.Sqrt(rmsAcc / float64(len(samples)))
fmt.Printf("Watermark RMS: %.1f dBFS (nominal -48 dBFS)\n", 20*math.Log10(rms+1e-12))

if err := writeMonoWAV(*output, samples, recRate); err != nil {
fmt.Fprintf(os.Stderr, "write WAV: %v\n", err)
os.Exit(1)
}
fmt.Printf("Written: %s\n\n", *output)
fmt.Printf("Decode with:\n")
fmt.Printf(" .\\wmdecode.exe %s %q\n\n", *output, *key)
fmt.Printf("Expected: RS decode clean + MATCH\n")
}

func writeMonoWAV(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)) // PCM
binary.Write(f, le, uint16(1)) // mono
binary.Write(f, le, uint32(rate))
binary.Write(f, le, uint32(rate*2)) // byte rate
binary.Write(f, le, uint16(2)) // block align
binary.Write(f, le, uint16(16)) // bits/sample
f.Write([]byte("data"))
binary.Write(f, le, dataSz)
for _, s := range samples {
// Scale to int16 range — watermark at -48dBFS → ~0.004 amplitude
// Multiply by 32767 to get full 16-bit range
v := s * 32767.0
if v > 32767 { v = 32767 }
if v < -32768 { v = -32768 }
binary.Write(f, le, int16(v))
}
return nil
}

+ 15
- 1
internal/app/engine.go View File

@@ -12,6 +12,7 @@ import (

"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/license"
"github.com/jan/fm-rds-tx/internal/dsp"
offpkg "github.com/jan/fm-rds-tx/internal/offline"
"github.com/jan/fm-rds-tx/internal/output"
@@ -196,11 +197,21 @@ func (e *Engine) SetStreamSource(src *audio.StreamSource) {
compositeRate = 228000
}
resampler := audio.NewStreamResampler(src, compositeRate)
e.generator.SetExternalSource(resampler)
if err := e.generator.SetExternalSource(resampler); err != nil {
// Should never happen: SetStreamSource must be called before Start().
log.Printf("engine: SetExternalSource failed (called too late): %v", err)
return
}
log.Printf("engine: live audio stream wired — initial %d Hz → %.0f Hz composite (buffer %d frames); actual decoded rate auto-corrects on first chunk",
src.SampleRate, compositeRate, src.Stats().Capacity)
}

// SetLicenseState passes the license/jingle state and raw key to the generator.
// Must be called before Start(). key is used to derive the watermark payload.
func (e *Engine) SetLicenseState(s *license.State, key string) {
e.generator.SetLicense(s, key)
}

// StreamSource returns the live audio stream source, or nil.
// Used by the control server for stats and HTTP audio ingest.
func (e *Engine) StreamSource() *audio.StreamSource {
@@ -407,6 +418,9 @@ func (e *Engine) Start(ctx context.Context) error {
e.state = EngineRunning
e.setRuntimeState(RuntimeStateArming)
e.startedAt = time.Now()
// BUG-A fix: discard any frames left from a previous run so writerLoop
// does not send stale data with expired timestamps on restart.
e.frameQueue.Drain()
e.wg.Add(1)
e.mu.Unlock()



+ 5
- 1
internal/control/control.go View File

@@ -515,7 +515,11 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
}
r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
var patch ConfigPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
// BUG-4 fix: reject unknown JSON fields (typos) with 400 rather than
// silently ignoring them (e.g. "outputDrvie" would succeed and do nothing).
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&patch); err != nil {
statusCode := http.StatusBadRequest
if strings.Contains(err.Error(), "http: request body too large") {
statusCode = http.StatusRequestEntityTooLarge


+ 4
- 0
internal/ingest/adapters/aoip/source.go View File

@@ -136,6 +136,10 @@ func (s *Source) Start(ctx context.Context) error {
if !s.started.CompareAndSwap(false, true) {
return nil
}
// BUG-2 fix: recreate channels and reset closeOnce so Stop+Start works.
s.chunks = make(chan ingest.PCMChunk, 64)
s.errs = make(chan error, 8)
s.closeOnce = sync.Once{}

rx, err := s.factory(s.cfg, s.handleFrame)
if err != nil {


+ 5
- 0
internal/ingest/adapters/icecast/source.go View File

@@ -129,6 +129,11 @@ func (s *Source) Start(ctx context.Context) error {
if s.url == "" {
return fmt.Errorf("icecast url is required")
}
// BUG-2 fix: recreate channels on every Start() so that Stop+Start works.
// loop() closes chunks/errs/title when it exits; reusing closed channels panics.
s.chunks = make(chan ingest.PCMChunk, 64)
s.errs = make(chan error, 8)
s.title = make(chan string, 16)
runCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.lastError.Store("")


+ 5
- 0
internal/ingest/adapters/srt/source.go View File

@@ -110,6 +110,11 @@ func (s *Source) Start(ctx context.Context) error {
if !s.started.CompareAndSwap(false, true) {
return nil
}
// BUG-2 fix: recreate channels and reset closeOnce so Stop+Start works.
// closeChannels() uses sync.Once — reset it so the new channels can be closed.
s.chunks = make(chan ingest.PCMChunk, 64)
s.errs = make(chan error, 8)
s.closeOnce = sync.Once{}

var (
rx *aoiprxkit.SRTReceiver


+ 3
- 0
internal/ingest/adapters/stdinpcm/source.go View File

@@ -77,6 +77,9 @@ func (s *Source) Start(ctx context.Context) error {
if s.reader == nil {
return fmt.Errorf("stdin source reader is nil")
}
// BUG-2 fix: recreate channels — readLoop() closes them on exit.
s.chunks = make(chan ingest.PCMChunk, 8)
s.errs = make(chan error, 4)
runCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.state.Store("running")


+ 13
- 0
internal/license/embed.go View File

@@ -0,0 +1,13 @@
package license

import _ "embed"

// jingleWAV holds the fm-rds-tx station identification jingle.
// Replace jingle.wav with your actual jingle before shipping.
// Requirements: 16-bit PCM WAV, stereo, 44100 Hz, max ~30 seconds.
//
//go:embed jingle.wav
var jingleWAV []byte

// JingleWAV returns the raw embedded jingle WAV bytes.
func JingleWAV() []byte { return jingleWAV }

+ 245
- 0
internal/license/license.go View File

@@ -0,0 +1,245 @@
// Package license handles fm-rds-tx key validation and jingle injection.
//
// Key format: FMRTX-<BASE32(HMAC-SHA256(payload, secret)[:10])>
// payload: "free" for gratis keys, email for paid keys.
//
// Without a valid key the jingle WAV is mixed into the composite output
// every JingleIntervalMinutes minutes. With a valid key the jingle is silent.
package license

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"strings"
"time"
)

// hmacSecret is the shared secret used to sign and verify keys.
// Change this value in your private fork — keys signed with the old
// secret stop working, forcing a re-issue. Never commit the real secret.
const hmacSecret = "Q7m!xP2#rL9$vN4@tK8%hD3&yF6*zC1+uB5"

// JingleIntervalMinutes is how often the jingle fires when unlicensed.
const JingleIntervalMinutes = 20

// jingleSampleRate and jingleChannels must match the embedded jingle.wav.
// The mixer resamples on-the-fly if the composite rate differs.
const (
jingleWAVRate = 44100
jingleWAVChannels = 2
)

// State holds the runtime license + jingle state for a running generator.
type State struct {
licensed bool

active bool // jingle currently playing
pos int // playback position in jingleFrames
nextFire time.Time
jingleLevel float64 // composite injection amplitude (0..1)
}

// NewState validates the provided key and returns a ready State.
// If key is empty or invalid, the jingle fires every JingleIntervalMinutes.
func NewState(key string) *State {
s := &State{
licensed: ValidateKey(key),
jingleLevel: 0.25, // 25% composite injection — loud but not clipping
}
if !s.licensed {
s.nextFire = time.Now().Add(time.Duration(JingleIntervalMinutes) * time.Minute)
}
return s
}

// Licensed reports whether a valid key was supplied.
func (s *State) Licensed() bool { return s.licensed }

// NextSample returns the jingle contribution for one composite sample.
// Call once per sample from the DSP loop — it is not thread-safe and must
// be called from the single GenerateFrame goroutine only.
// Returns 0 when licensed, not active, or no jingle loaded.
func (s *State) NextSample(frames []JingleFrame) float64 {
if s.licensed || len(frames) == 0 {
return 0
}
if !s.active {
return 0
}
f := frames[s.pos%len(frames)]
jingleMono := float64(f.L+f.R) / 2.0
s.pos++
if s.pos >= len(frames) {
s.active = false
s.pos = 0
s.nextFire = time.Now().Add(time.Duration(JingleIntervalMinutes) * time.Minute)
}
return s.jingleLevel * jingleMono
}

// Tick checks whether a new jingle playback should start.
// Call once per chunk (not per sample) from GenerateFrame.
// Safe to call from the single DSP goroutine — no locking needed after init.
func (s *State) Tick() {
if s.licensed || s.active {
return
}
if time.Now().After(s.nextFire) {
s.active = true
s.pos = 0
}
}

// MixComposite is kept for compatibility; prefer Tick()+NextSample() per sample.
func (s *State) MixComposite(composite float64, frames []JingleFrame, _ time.Time) float64 {
return composite + s.NextSample(frames)
}

// jingleFrame is a normalised stereo frame from the embedded WAV.
type JingleFrame struct{ L, R float32 }

// LoadJingleFrames decodes the embedded WAV bytes into normalised frames
// and resamples them from jingleWAVRate to targetRate using linear interpolation.
func LoadJingleFrames(wavBytes []byte, targetRate float64) ([]JingleFrame, error) {
raw, err := decodeWAV(wavBytes)
if err != nil {
return nil, fmt.Errorf("license: decode jingle WAV: %w", err)
}
if targetRate <= 0 || math.Abs(targetRate-float64(jingleWAVRate)) < 1 {
return raw, nil
}
// Linear resample to composite rate.
ratio := float64(jingleWAVRate) / targetRate
dstLen := int(float64(len(raw)) / ratio)
out := make([]JingleFrame, dstLen)
for i := range out {
pos := float64(i) * ratio
idx := int(pos)
frac := float32(pos - float64(idx))
if idx+1 < len(raw) {
a, b := raw[idx], raw[idx+1]
out[i] = JingleFrame{
L: a.L*(1-frac) + b.L*frac,
R: a.R*(1-frac) + b.R*frac,
}
} else if idx < len(raw) {
out[i] = raw[idx]
}
}
return out, nil
}

// decodeWAV parses a minimal PCM WAV (16-bit stereo) into normalised frames.
func decodeWAV(data []byte) ([]JingleFrame, error) {
if len(data) < 44 {
return nil, fmt.Errorf("WAV too short")
}
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
return nil, fmt.Errorf("not a RIFF/WAVE file")
}
// Find fmt and data chunks.
var (
channels uint16
bitsPerSample uint16
dataStart int
dataLen int
)
i := 12
for i+8 <= len(data) {
id := string(data[i : i+4])
chunkSize := int(binary.LittleEndian.Uint32(data[i+4 : i+8]))
i += 8
switch id {
case "fmt ":
if chunkSize < 16 || i+16 > len(data) {
return nil, fmt.Errorf("fmt chunk too small")
}
if binary.LittleEndian.Uint16(data[i:i+2]) != 1 {
return nil, fmt.Errorf("only PCM WAV supported")
}
channels = binary.LittleEndian.Uint16(data[i+2 : i+4])
bitsPerSample = binary.LittleEndian.Uint16(data[i+14 : i+16])
case "data":
dataStart = i
dataLen = chunkSize
}
i += chunkSize
if chunkSize%2 != 0 {
i++
}
if dataStart > 0 && channels > 0 {
break
}
}
if dataStart == 0 || channels == 0 || bitsPerSample != 16 {
return nil, fmt.Errorf("unsupported WAV format (need 16-bit PCM, got bits=%d ch=%d)", bitsPerSample, channels)
}
if dataStart+dataLen > len(data) {
dataLen = len(data) - dataStart
}

step := int(channels) * 2
frames := make([]JingleFrame, 0, dataLen/step)
for j := dataStart; j+step <= dataStart+dataLen; j += step {
l := float32(int16(binary.LittleEndian.Uint16(data[j:j+2]))) / 32768.0
r := l
if channels >= 2 {
r = float32(int16(binary.LittleEndian.Uint16(data[j+2:j+4]))) / 32768.0
}
frames = append(frames, JingleFrame{L: l, R: r})
}
return frames, nil
}

// --- Key validation ---

// ValidateKey returns true if key is a valid fm-rds-tx license key.
func ValidateKey(key string) bool {
key = strings.TrimSpace(key)
if !strings.HasPrefix(key, "FMRTX-") {
return false
}
body := strings.TrimPrefix(key, "FMRTX-")
// Decode the base32 payload.
// Format: BASE32(payload_len_byte || payload_bytes || mac_10_bytes)
padded := body
if pad := len(padded) % 8; pad != 0 {
padded += strings.Repeat("=", 8-pad)
}
raw, err := base32.StdEncoding.DecodeString(strings.ToUpper(padded))
if err != nil || len(raw) < 11 {
return false
}
payloadLen := int(raw[0])
if payloadLen+1+10 > len(raw) {
return false
}
payload := raw[1 : 1+payloadLen]
mac := raw[1+payloadLen : 1+payloadLen+10]
expected := computeMAC(payload)
return hmac.Equal(mac, expected[:10])
}

// GenerateKey generates a signed license key for the given payload string.
// Call this from cmd/keygen — not from the main binary.
func GenerateKey(payload string) string {
p := []byte(payload)
raw := make([]byte, 1+len(p)+10)
raw[0] = byte(len(p))
copy(raw[1:], p)
mac := computeMAC(p)
copy(raw[1+len(p):], mac[:10])
encoded := base32.StdEncoding.EncodeToString(raw)
encoded = strings.TrimRight(encoded, "=")
return "FMRTX-" + encoded
}

func computeMAC(payload []byte) []byte {
h := hmac.New(sha256.New, []byte(hmacSecret))
h.Write(payload)
return h.Sum(nil)
}

+ 59
- 5
internal/offline/generator.go View File

@@ -4,12 +4,16 @@ import (
"context"
"encoding/binary"
"fmt"
"log"
"math"
"path/filepath"
"sync/atomic"
"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/license"
"github.com/jan/fm-rds-tx/internal/watermark"
"github.com/jan/fm-rds-tx/internal/dsp"
"github.com/jan/fm-rds-tx/internal/mpx"
"github.com/jan/fm-rds-tx/internal/output"
@@ -121,24 +125,40 @@ type Generator struct {
// Tone source reference — non-nil when a ToneSource is the active audio input.
// Allows live-updating tone parameters via LiveParams each chunk.
toneSource *audio.ToneSource

// License: jingle injection when unlicensed.
licenseState *license.State
jingleFrames []license.JingleFrame

// Watermark: spread-spectrum key fingerprint, always active.
watermark *watermark.Embedder
}

func NewGenerator(cfg cfgpkg.Config) *Generator {
return &Generator{cfg: cfg}
}

// SetLicense configures license state (jingle) and creates the watermark
// embedder. Must be called before the first GenerateFrame.
func (g *Generator) SetLicense(state *license.State, key string) {
g.licenseState = state
g.watermark = watermark.NewEmbedder(key)
// Gate threshold: -40 dBFS ≈ 0.01 linear amplitude.
// Watermark is muted during silence to prevent audibility.
// Composite rate will be set in init(); use 228000 as default.
g.watermark.EnableGate(0.01, 228000)
}

// SetExternalSource sets a live audio source (e.g. StreamResampler) that
// takes priority over WAV/tone sources. Must be called before the first
// GenerateFrame() call; calling it after init() has no effect because
// g.source is already wired to the old source.
func (g *Generator) SetExternalSource(src frameSource) {
func (g *Generator) SetExternalSource(src frameSource) error {
if g.initialized {
// init() already called sourceFor() and wired g.source. Updating
// g.externalSource here would have no effect on the live DSP chain.
// This is a programming error — log loudly rather than silently break.
panic("generator: SetExternalSource called after GenerateFrame; call it before the engine starts")
return fmt.Errorf("generator: SetExternalSource called after GenerateFrame; call it before the engine starts")
}
g.externalSource = src
return nil
}

// UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API,
@@ -242,6 +262,21 @@ func (g *Generator) init() {
AudioGain: g.cfg.Audio.Gain,
})

if g.licenseState != nil {
frames, err := license.LoadJingleFrames(license.JingleWAV(), g.sampleRate)
if err != nil {
log.Printf("license: jingle load failed: %v", err)
} else {
g.jingleFrames = frames
}
}

// Update watermark gate ramp rate with actual composite rate (may differ
// from the 228000 default used in SetLicense).
if g.watermark != nil {
g.watermark.EnableGate(0.01, g.sampleRate)
}

g.initialized = true
}

@@ -335,6 +370,10 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
bs412Gain = g.bs412.CurrentGain()
}

if g.licenseState != nil {
g.licenseState.Tick()
}

for i := 0; i < samples; i++ {
in := g.source.NextFrame()

@@ -344,6 +383,17 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
r := g.audioLPF_R.Process(float64(in.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.
if g.watermark != nil {
audioLevel := (math.Abs(l) + math.Abs(r)) / 2.0
g.watermark.SetAudioLevel(audioLevel)
wm := g.watermark.NextSample()
l += wm
r += wm
}

// --- Stage 2: Drive + Compress + Clip₁ ---
l *= lp.OutputDrive
r *= lp.OutputDrive
@@ -389,6 +439,10 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
composite += rdsAmp * rdsValue
}

// Jingle: injected when unlicensed, bypasses drive/gain controls.
if g.licenseState != nil && len(g.jingleFrames) > 0 {
composite += g.licenseState.NextSample(g.jingleFrames)
}
if g.fmMod != nil {
iq_i, iq_q := g.fmMod.Modulate(composite)
frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}


+ 19
- 7
internal/output/frame_queue.go View File

@@ -126,19 +126,18 @@ func (q *FrameQueue) Push(ctx context.Context, frame *CompositeFrame) error {
return errors.New("frame required")
}

// BUG-A fix: use closeCh in the select so that a concurrent Close() can
// never race with the send. The old isClosed() pre-check + separate
// ch<- send had a TOCTOU gap that could panic with "send on closed channel".
// BUG-05 fix: increment depth before the send; undo on cancel/close.
q.updateDepth(+1)
// BUG-A fix: use closeCh in the select — no TOCTOU gap.
// BUG-3 fix: updateDepth(+1) and trackDepth (highWaterMark) only on
// successful send. Pre-incrementing before the select caused the
// high-watermark to count frames that were never actually queued
// (cancelled or closed path), potentially reporting capacity+1.
select {
case q.ch <- frame:
q.updateDepth(+1)
return nil
case <-q.closeCh:
q.updateDepth(-1)
return ErrFrameQueueClosed
case <-ctx.Done():
q.updateDepth(-1)
q.recordPushTimeout()
return ctx.Err()
}
@@ -159,6 +158,19 @@ func (q *FrameQueue) Pop(ctx context.Context) (*CompositeFrame, error) {
}
}

// Drain removes and discards all frames currently in the queue.
// Call before restarting a stopped engine to avoid replaying stale frames.
func (q *FrameQueue) Drain() {
for {
select {
case <-q.ch:
q.updateDepth(-1)
default:
return
}
}
}

// Close marks the queue as closed and wakes up blocked callers.
func (q *FrameQueue) Close() {
q.closeOnce.Do(func() {


+ 523
- 0
internal/watermark/watermark.go View File

@@ -0,0 +1,523 @@
// Package watermark implements spread-spectrum audio watermarking for fm-rds-tx.
//
// # 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
// individual bits have high error rates due to noise and audio masking.
//
// # Parameters
//
// - PN sequence: 2048-chip LFSR-13 (seed 0x1ACE)
// - 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)
// - Spreading gain: 33 dB. RS erasure corrects up to 8 of 16 byte symbols.
//
// # 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).
// 3. Extract 128 bit correlations at found phase, averaged over all frames.
// 4. Frame sync: try all 128 cyclic rotations of the bit sequence,
// RS-decode each; the rotation that succeeds gives the frame alignment.
// 5. Sort bits by |correlation| (confidence). Mark weakest as erasures.
// 6. RS erasure-decode → 8 payload bytes → compare against known keys.
package watermark

import (
"crypto/sha256"
)

const (
// pnChips is the spreading factor — PN chips per data bit at composite rate.
// Spreading gain = 10·log10(2048) = 33.1 dB.
pnChips = 2048

// rsDataBytes is the number of payload bytes before RS encoding.
rsDataBytes = 8

// rsCheckBytes is the number of RS parity bytes. With 8 check bytes the
// code corrects up to 4 errors or up to 8 erasures per 16-byte codeword.
rsCheckBytes = 8

// rsTotalBytes is the full RS codeword length.
rsTotalBytes = rsDataBytes + rsCheckBytes // 16

// payloadBits is the total number of BPSK bits per watermark frame.
payloadBits = rsTotalBytes * 8 // 128

// Level is the audio injection amplitude per channel (-48 dBFS).
// At typical audio levels this is completely inaudible.
Level = 0.004

// CompositeRate is the sample rate at which the watermark was embedded.
// The recovery tool uses this to compute fractional chip indices.
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.
const RecordingRate = 48000

// Embedder continuously embeds a watermark into audio L/R samples.
// Not thread-safe: call NextSample from the single DSP goroutine only.
type Embedder struct {
codeword [rsTotalBytes]byte // RS-encoded payload, 16 bytes
chipIdx int // chip position within current bit (0..pnChips-1)
bitIdx int // current bit in codeword (0..127)
symbol int8 // BPSK symbol for current bit: +1 or -1
accum int // Bresenham accumulator for chip-rate stepping

// Audio-level gate: mutes watermark during silence to prevent audibility.
gateGain float64 // smooth ramp 0.0 (muted) → 1.0 (open)
gateThreshold float64 // audio level below which gate closes
gateRampUp float64 // per-sample increment when opening (~5ms)
gateRampDown float64 // per-sample decrement when closing (~5ms)
gateEnabled bool
}

// NewEmbedder creates an Embedder for the given license key.
// The key's SHA-256 hash (first 8 bytes) is RS-encoded and embedded.
// An empty key embeds a null payload (still watermarks, just anonymous).
func NewEmbedder(key string) *Embedder {
var data [rsDataBytes]byte
if key != "" {
h := sha256.Sum256([]byte(key))
copy(data[:], h[:rsDataBytes])
}
e := &Embedder{gateGain: 1.0}
e.codeword = rsEncode(data)
e.loadSymbol()
return e
}

// NextSample returns the watermark amplitude for one composite sample.
// 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.
func (e *Embedder) NextSample() float64 {
chip := float64(pnSequence[e.chipIdx])
sample := Level * float64(e.symbol) * chip * e.gateGain

// Bresenham: advance chip once per RecordingRate/CompositeRate composite samples.
e.accum += RecordingRate
if e.accum >= CompositeRate {
e.accum -= CompositeRate
e.chipIdx++
if e.chipIdx >= pnChips {
e.chipIdx = 0
e.bitIdx = (e.bitIdx + 1) % payloadBits
e.loadSymbol()
}
}
return sample
}

// loadSymbol sets e.symbol from the current bit in the codeword (MSB first).
func (e *Embedder) loadSymbol() {
byteIdx := e.bitIdx / 8
bitPos := uint(7 - (e.bitIdx % 8))
if (e.codeword[byteIdx]>>bitPos)&1 == 0 {
e.symbol = 1
} else {
e.symbol = -1
}
}

// PayloadHex returns the RS-encoded codeword as hex for logging.
func (e *Embedder) PayloadHex() string {
const hx = "0123456789abcdef"
out := make([]byte, rsTotalBytes*2)
for i, b := range e.codeword {
out[i*2] = hx[b>>4]
out[i*2+1] = hx[b&0xf]
}
return string(out)
}

// EnableGate activates audio-level gating with asymmetric ramp times.
// threshold is the linear audio amplitude below which the watermark is muted
// (e.g. 0.01 ≈ -40 dBFS). compositeRate is needed to compute ramp speed.
// Attack (open) is fast (5ms) so the watermark starts immediately with audio.
// Release (close) is slow (200ms) to keep the watermark running through normal
// inter-word and inter-phrase gaps — only extended silence mutes.
func (e *Embedder) EnableGate(threshold, compositeRate float64) {
attackSamples := compositeRate * 0.005 // 5ms open
releaseSamples := compositeRate * 0.200 // 200ms close
if attackSamples < 1 {
attackSamples = 1
}
if releaseSamples < 1 {
releaseSamples = 1
}
e.gateThreshold = threshold
e.gateRampUp = 1.0 / attackSamples
e.gateRampDown = 1.0 / releaseSamples
e.gateEnabled = true
}

// SetAudioLevel updates the gate state based on the current audio amplitude.
// Call once per sample before NextSample. absLevel should be the absolute
// mono audio level (pre- or post-pre-emphasis, either works).
func (e *Embedder) SetAudioLevel(absLevel float64) {
if !e.gateEnabled {
return
}
if absLevel > e.gateThreshold {
e.gateGain += e.gateRampUp
if e.gateGain > 1.0 {
e.gateGain = 1.0
}
} else {
e.gateGain -= e.gateRampDown
if e.gateGain < 0.0 {
e.gateGain = 0.0
}
}
}

// --- 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).

func gfMul(a, b byte) byte {
if a == 0 || b == 0 {
return 0
}
return gfExp[(int(gfLog[a])+int(gfLog[b]))%255]
}

func gfInv(a byte) byte {
if a == 0 {
return 0
}
return gfExp[255-int(gfLog[a])]
}

func gfPow(a byte, n int) byte {
if a == 0 {
return 0
}
return gfExp[(int(gfLog[a])*n)%255]
}

// rsEncode encodes 8 data bytes into a 16-byte RS codeword.
func rsEncode(data [rsDataBytes]byte) [rsTotalBytes]byte {
var work [rsTotalBytes]byte
copy(work[:rsDataBytes], data[:])
// Polynomial long division by the generator polynomial.
for i := 0; i < rsDataBytes; i++ {
fb := work[i]
if fb != 0 {
for j := 1; j <= rsCheckBytes; j++ {
work[i+j] ^= gfMul(rsGen[j], fb)
}
}
}
var cw [rsTotalBytes]byte
copy(cw[:rsDataBytes], data[:])
copy(cw[rsDataBytes:], work[rsDataBytes:])
return cw
}

// RSDecode recovers 8 data bytes from a (possibly corrupted) 16-byte codeword.
// erasurePositions lists the byte indices (0..15) of symbols with low confidence
// that should be treated as erasures. Up to 8 erasures can be corrected.
// Returns (data, true) on success, (zero, false) on decoding failure.
func RSDecode(recv [rsTotalBytes]byte, erasurePositions []int) ([rsDataBytes]byte, bool) {
// Step 1: compute syndromes.
var S [rsCheckBytes]byte
for i := 0; i < rsCheckBytes; i++ {
var acc byte
for _, c := range recv {
acc = gfMul(acc, gfPow(2, i)) ^ c
}
S[i] = acc
}

// Step 2: if no erasures and all syndromes zero, no errors.
hasErr := false
for _, s := range S {
if s != 0 {
hasErr = true
break
}
}
if !hasErr && len(erasurePositions) == 0 {
// Valid codeword, no errors, no erasures.
var out [rsDataBytes]byte
copy(out[:], recv[:rsDataBytes])
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.
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 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])
}
}
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
}

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))
}
// 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 gammaPrime == 0 {
return [rsDataBytes]byte{}, false
}
magnitude := gfMul(omega, gfInv(gammaPrime))
result[pos] ^= magnitude
}

// Verify syndromes after correction.
for i := 0; i < rsCheckBytes; i++ {
var acc byte
for _, c := range result {
acc = gfMul(acc, gfPow(2, i)) ^ c
}
if acc != 0 {
return [rsDataBytes]byte{}, false
}
}

var out [rsDataBytes]byte
copy(out[:], result[:rsDataBytes])
return out, true
}

// KeyMatchesPayload returns true if SHA-256(key)[:8] matches payload.
func KeyMatchesPayload(key string, payload [rsDataBytes]byte) bool {
h := sha256.Sum256([]byte(key))
var expected [rsDataBytes]byte
copy(expected[:], h[:rsDataBytes])
return expected == payload
}

// Constants exported for the recovery tool.
const (
PnChips = pnChips
PayloadBits = payloadBits
RsDataBytes = rsDataBytes
RsTotalBytes = rsTotalBytes
RsCheckBytes = rsCheckBytes
)

// CorrelateAt returns the correlation of received samples at the given bit
// 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).
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))
if samplesPerBit < 1 {
samplesPerBit = 1
}
n := samplesPerBit
if bitStart+n > len(samples) {
n = len(samples) - bitStart
}
var acc float64
for i := 0; i < n; i++ {
// Map recording-rate sample index to chip index.
chipIdx := int(float64(i)*float64(RecordingRate)/recRate) % pnChips
acc += samples[bitStart+i] * float64(pnSequence[chipIdx])
}
return acc
}


// pnSequence is the 2048-chip LFSR-13 spreading code (seed 0x1ACE).
var pnSequence = [pnChips]int8{
1, -1, -1, -1, 1, 1, -1, -1, 1, -1, 1, -1, -1, 1, -1, -1,
-1, -1, -1, 1, -1, 1, 1, -1, 1, 1, -1, 1, 1, 1, -1, 1,
-1, -1, 1, 1, 1, 1, 1, -1, -1, 1, 1, 1, 1, -1, 1, -1,
1, -1, 1, -1, -1, -1, -1, -1, -1, -1, 1, 1, 1, 1, -1, -1,
-1, 1, -1, -1, -1, 1, -1, 1, -1, 1, -1, -1, -1, 1, 1, 1,
-1, 1, -1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1, 1, 1, -1,
1, -1, -1, 1, 1, -1, 1, 1, -1, -1, 1, 1, 1, 1, -1, 1,
-1, 1, 1, -1, -1, -1, 1, 1, -1, -1, 1, -1, 1, 1, -1, -1,
-1, 1, -1, 1, -1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1,
-1, -1, 1, -1, -1, 1, 1, 1, 1, 1, 1, 1, -1, 1, 1, -1,
1, 1, -1, -1, 1, 1, -1, -1, 1, -1, 1, -1, -1, 1, 1, 1,
-1, -1, 1, -1, -1, 1, -1, 1, 1, 1, 1, 1, 1, -1, 1, -1,
-1, -1, -1, 1, 1, -1, -1, 1, 1, -1, 1, -1, -1, -1, 1, -1,
1, -1, -1, -1, -1, -1, 1, 1, 1, -1, 1, -1, -1, 1, -1, -1,
-1, 1, -1, 1, -1, 1, 1, -1, 1, 1, -1, 1, 1, 1, 1, -1,
-1, -1, -1, -1, 1, 1, -1, 1, -1, 1, -1, 1, 1, 1, -1, 1,
1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1, 1, -1, -1,
-1, 1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1, 1, 1, 1, 1,
-1, 1, -1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1, 1, 1, 1,
1, 1, -1, 1, 1, 1, -1, 1, 1, 1, 1, 1, 1, 1, 1, -1,
-1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1, 1, -1, 1,
-1, 1, -1, 1, -1, 1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1,
-1, -1, 1, -1, 1, 1, 1, 1, -1, -1, 1, -1, 1, 1, 1, 1,
-1, -1, 1, 1, -1, 1, 1, -1, 1, -1, 1, -1, 1, 1, 1, 1,
1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 1, -1, 1, -1, -1, -1,
-1, 1, 1, -1, 1, -1, -1, 1, -1, 1, 1, 1, -1, 1, -1, 1,
1, -1, 1, 1, -1, 1, -1, 1, -1, -1, 1, -1, 1, -1, -1, 1,
1, 1, 1, -1, -1, -1, 1, 1, 1, 1, -1, 1, -1, 1, -1, 1,
1, 1, -1, -1, 1, 1, -1, 1, -1, -1, 1, -1, 1, -1, 1, 1,
1, 1, 1, -1, -1, -1, 1, 1, 1, 1, -1, -1, 1, 1, -1, -1,
-1, 1, -1, 1, -1, 1, -1, -1, -1, -1, -1, 1, -1, -1, 1, -1,
1, 1, -1, 1, 1, 1, -1, 1, -1, -1, -1, -1, 1, 1, -1, 1,
-1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, 1, -1, -1, 1, -1,
-1, 1, -1, -1, -1, -1, -1, 1, 1, 1, -1, -1, -1, 1, 1, 1,
-1, 1, 1, 1, 1, 1, 1, -1, 1, 1, -1, -1, 1, -1, 1, -1,
1, -1, 1, -1, -1, 1, 1, 1, -1, -1, -1, -1, 1, 1, 1, 1,
-1, 1, -1, 1, -1, -1, -1, 1, -1, 1, -1, 1, -1, -1, 1, -1,
1, 1, 1, -1, -1, -1, -1, 1, 1, 1, -1, -1, -1, -1, -1, -1,
1, -1, -1, -1, -1, -1, -1, 1, -1, -1, -1, 1, -1, 1, 1, 1,
-1, 1, 1, 1, 1, -1, 1, 1, -1, 1, -1, 1, -1, -1, 1, 1,
-1, -1, -1, -1, -1, 1, 1, 1, 1, -1, 1, -1, 1, 1, 1, -1,
1, 1, -1, -1, 1, 1, 1, 1, -1, -1, 1, 1, 1, 1, 1, -1,
1, -1, 1, -1, 1, 1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1,
1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, 1,
1, -1, -1, -1, 1, 1, 1, -1, 1, -1, 1, 1, -1, -1, -1, 1,
-1, 1, -1, 1, 1, -1, 1, -1, 1, 1, 1, -1, -1, -1, -1, 1,
1, -1, 1, -1, -1, 1, 1, -1, 1, 1, 1, -1, -1, 1, -1, 1,
1, 1, 1, 1, -1, -1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1,
-1, 1, -1, 1, -1, 1, 1, 1, -1, 1, 1, 1, -1, 1, -1, -1,
1, -1, 1, -1, 1, 1, -1, -1, 1, -1, 1, 1, 1, 1, -1, -1,
-1, -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, 1, -1, 1, 1, 1,
1, -1, -1, 1, -1, -1, 1, 1, -1, 1, -1, 1, -1, 1, 1, 1,
1, -1, 1, -1, 1, 1, -1, 1, -1, -1, 1, 1, -1, -1, -1, -1,
1, 1, -1, 1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, 1,
1, 1, -1, 1, 1, 1, 1, -1, 1, 1, 1, -1, -1, 1, 1, 1,
1, 1, 1, 1, -1, -1, 1, 1, 1, -1, -1, 1, 1, -1, -1, 1,
1, 1, -1, -1, 1, 1, 1, -1, -1, 1, -1, -1, 1, 1, -1, 1,
1, -1, -1, 1, -1, -1, 1, -1, -1, 1, 1, 1, 1, 1, -1, 1,
-1, -1, -1, -1, 1, -1, 1, -1, 1, -1, 1, 1, -1, 1, 1, 1,
-1, -1, -1, -1, 1, 1, 1, 1, 1, -1, -1, 1, 1, 1, -1, 1,
1, -1, -1, 1, 1, -1, 1, 1, -1, -1, 1, -1, 1, -1, -1, -1,
-1, -1, 1, 1, -1, 1, 1, -1, 1, -1, -1, -1, 1, -1, -1, 1,
1, 1, 1, 1, -1, -1, -1, 1, -1, 1, 1, 1, 1, 1, 1, 1,
1, -1, 1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, 1, 1, -1,
-1, -1, -1, 1, 1, 1, 1, -1, -1, -1, -1, -1, 1, -1, -1, -1,
-1, -1, -1, -1, 1, -1, -1, -1, 1, 1, 1, -1, 1, 1, 1, -1,
1, -1, -1, -1, 1, 1, -1, -1, -1, -1, -1, -1, 1, 1, 1, -1,
1, -1, 1, 1, 1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1,
-1, -1, 1, 1, -1, -1, 1, 1, -1, 1, 1, 1, -1, 1, 1, -1,
-1, -1, 1, 1, -1, 1, -1, 1, -1, -1, 1, 1, 1, -1, 1, -1,
1, 1, -1, 1, -1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, -1,
-1, -1, 1, -1, -1, -1, 1, 1, -1, -1, 1, 1, 1, -1, 1, -1,
-1, -1, 1, 1, -1, 1, 1, -1, -1, 1, -1, 1, 1, 1, -1, -1,
1, -1, 1, -1, -1, -1, -1, 1, 1, -1, -1, -1, 1, 1, 1, 1,
-1, 1, 1, 1, 1, 1, -1, 1, 1, -1, -1, 1, 1, 1, 1, 1,
1, 1, 1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, 1, -1, -1,
-1, 1, 1, 1, -1, -1, 1, -1, -1, 1, 1, 1, -1, 1, -1, 1,
-1, -1, -1, -1, 1, -1, 1, -1, 1, 1, -1, 1, -1, -1, -1, 1,
-1, 1, 1, -1, 1, -1, 1, 1, -1, 1, 1, 1, 1, -1, -1, 1,
-1, 1, 1, -1, -1, -1, -1, -1, -1, -1, 1, -1, 1, 1, -1, -1,
-1, 1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1, 1, -1, 1, 1,
1, -1, -1, -1, -1, -1, 1, -1, -1, 1, -1, 1, -1, 1, 1, 1,
-1, 1, 1, -1, 1, 1, -1, 1, -1, -1, 1, 1, -1, 1, -1, 1,
1, -1, -1, -1, -1, 1, -1, 1, -1, -1, 1, -1, 1, -1, -1, -1,
1, -1, 1, 1, -1, 1, 1, -1, 1, -1, -1, -1, 1, 1, 1, 1,
1, -1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1, -1, -1, -1, 1,
1, 1, 1, 1, 1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1, -1,
-1, 1, -1, 1, 1, -1, -1, 1, -1, -1, 1, 1, 1, -1, -1, -1,
-1, -1, 1, -1, 1, 1, 1, 1, 1, 1, -1, 1, 1, 1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, 1, 1, -1, -1, -1, 1, -1, -1, -1,
1, -1, -1, -1, -1, -1, 1, -1, -1, 1, -1, -1, -1, -1, 1, -1,
-1, -1, 1, 1, 1, -1, -1, -1, 1, -1, -1, 1, -1, -1, -1, -1,
-1, -1, 1, -1, -1, 1, -1, -1, 1, -1, -1, -1, 1, -1, -1, 1,
-1, -1, 1, -1, -1, 1, -1, -1, -1, -1, 1, -1, 1, 1, 1, 1,
-1, 1, -1, -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, 1, 1, -1,
1, 1, -1, 1, -1, 1, 1, 1, 1, 1, 1, 1, -1, -1, 1, -1,
1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1,
-1, -1, -1, 1, -1, 1, 1, 1, 1, -1, -1, -1, 1, -1, -1, -1,
-1, 1, 1, -1, -1, 1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1,
1, -1, 1, 1, 1, -1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1,
-1, 1, 1, -1, 1, 1, 1, 1, -1, 1, -1, -1, 1, -1, 1, -1,
-1, 1, 1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, 1, -1, -1,
-1, 1, -1, -1, 1, -1, 1, -1, -1, 1, 1, 1, -1, 1, -1, -1,
-1, -1, 1, 1, 1, -1, -1, 1, 1, 1, 1, -1, 1, 1, 1, 1,
1, 1, -1, 1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, -1, -1,
-1, 1, 1, 1, 1, 1, -1, 1, -1, 1, 1, 1, -1, 1, -1, 1,
-1, 1, -1, -1, 1, -1, 1, -1, 1, 1, -1, 1, -1, -1, 1, -1,
-1, 1, -1, 1, 1, -1, -1, -1, -1, 1, -1, 1, 1, 1, 1, -1,
-1, 1, -1, -1, -1, 1, 1, 1, 1, 1, 1, 1, -1, 1, -1, -1,
-1, 1, 1, -1, -1, 1, 1, -1, -1, -1, -1, 1, -1, -1, 1, 1,
1, -1, -1, 1, -1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1,
-1, -1, 1, -1, 1, -1, 1, 1, 1, 1, 1, 1, -1, 1, 1, -1,
1, -1, -1, 1, 1, -1, -1, 1, 1, 1, 1, 1, -1, -1, -1, 1,
1, -1, 1, 1, -1, -1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1,
1, -1, -1, 1, 1, 1, 1, 1, 1, 1, 1, -1, 1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, 1, 1, 1, -1, 1, 1, 1, -1,
1, 1, 1, -1, 1, -1, 1, -1, -1, 1, 1, -1, 1, -1, 1, -1,
-1, 1, -1, 1, 1, 1, 1, 1, -1, -1, -1, -1, 1, -1, 1, 1,
-1, -1, 1, 1, -1, 1, 1, 1, -1, -1, -1, -1, -1, 1, -1, 1,
-1, -1, 1, 1, -1, 1, 1, 1, -1, 1, -1, -1, 1, -1, -1, 1,
1, 1, 1, 1, 1, -1, -1, -1, 1, 1, 1, -1, -1, 1, 1, -1,
-1, 1, -1, -1, -1, -1, -1, -1, 1, -1, 1, -1, -1, -1, -1, 1,
-1, -1, -1, 1, -1, 1, 1, -1, 1, 1, 1, -1, -1, -1, 1, -1,
1, 1, -1, -1, -1, -1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1,
-1, 1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, 1, 1, 1, -1,
1, 1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1, 1, 1, -1,
-1, -1, 1, 1, 1, 1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1,
1, 1, 1, -1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
}

// GF(2^8) tables with primitive polynomial 0x11d.
var gfExp = [512]byte{1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152, 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78, 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5, 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97, 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211, 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175, 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31, 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, 23, 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, 154, 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, 191, 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, 219, 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, 25, 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, 162, 89, 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, 36, 72, 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, 88, 176, 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142, 1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152, 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78, 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5, 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97, 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211, 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175, 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31, 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, 23, 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, 154, 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, 191, 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, 219, 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, 25, 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, 162, 89, 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, 36, 72, 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, 88, 176, 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142, 1, 2}
var gfLog = [256]byte{0, 0, 1, 25, 2, 50, 26, 198, 3, 223, 51, 238, 27, 104, 199, 75, 4, 100, 224, 14, 52, 141, 239, 129, 28, 193, 105, 248, 200, 8, 76, 113, 5, 138, 101, 47, 225, 36, 15, 33, 53, 147, 142, 218, 240, 18, 130, 69, 29, 181, 194, 125, 106, 39, 249, 185, 201, 154, 9, 120, 77, 228, 114, 166, 6, 191, 139, 98, 102, 221, 48, 253, 226, 152, 37, 179, 16, 145, 34, 136, 54, 208, 148, 206, 143, 150, 219, 189, 241, 210, 19, 92, 131, 56, 70, 64, 30, 66, 182, 163, 195, 72, 126, 110, 107, 58, 40, 84, 250, 133, 186, 61, 202, 94, 155, 159, 10, 21, 121, 43, 78, 212, 229, 172, 115, 243, 167, 87, 7, 112, 192, 247, 140, 128, 99, 13, 103, 74, 222, 237, 49, 197, 254, 24, 227, 165, 153, 119, 38, 184, 180, 124, 17, 68, 146, 217, 35, 32, 137, 46, 55, 63, 209, 91, 149, 188, 207, 205, 144, 135, 151, 178, 220, 252, 190, 97, 242, 86, 211, 171, 20, 42, 93, 158, 132, 60, 57, 83, 71, 109, 65, 162, 31, 45, 67, 216, 183, 123, 164, 118, 196, 23, 73, 236, 127, 12, 111, 246, 108, 161, 59, 82, 41, 157, 85, 170, 251, 96, 134, 177, 187, 204, 62, 90, 203, 89, 95, 176, 156, 169, 160, 81, 11, 245, 22, 235, 122, 117, 44, 215, 79, 174, 213, 233, 230, 231, 173, 232, 116, 214, 244, 234, 168, 80, 88, 175}
var rsGen = [9]byte{1, 255, 11, 81, 54, 239, 173, 200, 24}


// rsGen is the RS(16,8) generator polynomial coefficients (fcr=0).

+ 188
- 0
internal/watermark/watermark_roundtrip_test.go View File

@@ -0,0 +1,188 @@
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)
}

Loading…
Cancel
Save