| @@ -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() | |||
| } | |||
| }) | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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() | |||
| @@ -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 | |||
| @@ -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 { | |||
| @@ -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("") | |||
| @@ -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 | |||
| @@ -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") | |||
| @@ -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 } | |||
| @@ -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) | |||
| } | |||
| @@ -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)} | |||
| @@ -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() { | |||
| @@ -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). | |||
| @@ -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) | |||
| } | |||