From e1329a864c2437b27e4313fd98e7bde962c9f78a Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 9 Apr 2026 21:40:32 +0200 Subject: [PATCH] feat: add license watermarking and harden restart paths --- cmd/fmrtx/main.go | 33 +- cmd/keygen/main.go | 41 ++ cmd/wmdecode/main.go | 314 +++++++++++ cmd/wmtest/main.go | 106 ++++ internal/app/engine.go | 16 +- internal/control/control.go | 6 +- internal/ingest/adapters/aoip/source.go | 4 + internal/ingest/adapters/icecast/source.go | 5 + internal/ingest/adapters/srt/source.go | 5 + internal/ingest/adapters/stdinpcm/source.go | 3 + internal/license/embed.go | 13 + internal/license/license.go | 245 ++++++++ internal/offline/generator.go | 64 ++- internal/output/frame_queue.go | 26 +- internal/watermark/watermark.go | 523 ++++++++++++++++++ .../watermark/watermark_roundtrip_test.go | 188 +++++++ 16 files changed, 1571 insertions(+), 21 deletions(-) create mode 100644 cmd/keygen/main.go create mode 100644 cmd/wmdecode/main.go create mode 100644 cmd/wmtest/main.go create mode 100644 internal/license/embed.go create mode 100644 internal/license/license.go create mode 100644 internal/watermark/watermark.go create mode 100644 internal/watermark/watermark_roundtrip_test.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index c3e371d..810894c 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -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() + } }) } diff --git a/cmd/keygen/main.go b/cmd/keygen/main.go new file mode 100644 index 0000000..b88ae40 --- /dev/null +++ b/cmd/keygen/main.go @@ -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 \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) + } +} diff --git a/cmd/wmdecode/main.go b/cmd/wmdecode/main.go new file mode 100644 index 0000000..e29c2ff --- /dev/null +++ b/cmd/wmdecode/main.go @@ -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 [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 [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 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 +} diff --git a/cmd/wmtest/main.go b/cmd/wmtest/main.go new file mode 100644 index 0000000..1020c9c --- /dev/null +++ b/cmd/wmtest/main.go @@ -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 --output test.wav --duration 30s +// wmdecode test.wav +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 +} diff --git a/internal/app/engine.go b/internal/app/engine.go index 12951f1..7722dc8 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -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() diff --git a/internal/control/control.go b/internal/control/control.go index 4f021c8..81b012e 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -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 diff --git a/internal/ingest/adapters/aoip/source.go b/internal/ingest/adapters/aoip/source.go index 24e30f1..813dc3b 100644 --- a/internal/ingest/adapters/aoip/source.go +++ b/internal/ingest/adapters/aoip/source.go @@ -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 { diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 52c825f..6c08356 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -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("") diff --git a/internal/ingest/adapters/srt/source.go b/internal/ingest/adapters/srt/source.go index af3685d..fdff063 100644 --- a/internal/ingest/adapters/srt/source.go +++ b/internal/ingest/adapters/srt/source.go @@ -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 diff --git a/internal/ingest/adapters/stdinpcm/source.go b/internal/ingest/adapters/stdinpcm/source.go index 104b66b..159df9b 100644 --- a/internal/ingest/adapters/stdinpcm/source.go +++ b/internal/ingest/adapters/stdinpcm/source.go @@ -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") diff --git a/internal/license/embed.go b/internal/license/embed.go new file mode 100644 index 0000000..3ec8e82 --- /dev/null +++ b/internal/license/embed.go @@ -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 } diff --git a/internal/license/license.go b/internal/license/license.go new file mode 100644 index 0000000..5beca35 --- /dev/null +++ b/internal/license/license.go @@ -0,0 +1,245 @@ +// Package license handles fm-rds-tx key validation and jingle injection. +// +// Key format: FMRTX- +// 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) +} diff --git a/internal/offline/generator.go b/internal/offline/generator.go index b55c63a..54850f2 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -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)} diff --git a/internal/output/frame_queue.go b/internal/output/frame_queue.go index 223714c..13b7bbf 100644 --- a/internal/output/frame_queue.go +++ b/internal/output/frame_queue.go @@ -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() { diff --git a/internal/watermark/watermark.go b/internal/watermark/watermark.go new file mode 100644 index 0000000..75050e1 --- /dev/null +++ b/internal/watermark/watermark.go @@ -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). diff --git a/internal/watermark/watermark_roundtrip_test.go b/internal/watermark/watermark_roundtrip_test.go new file mode 100644 index 0000000..8a8b72f --- /dev/null +++ b/internal/watermark/watermark_roundtrip_test.go @@ -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) +}