// cmd/wmdump — generates a composite WAV with watermark for offline verification. // // Usage: // // wmdump --key FMRTX-XXX --config config.json --output composite.wav --duration 60s // wmdecode composite.wav FMRTX-XXX // // If wmdecode succeeds on the composite.wav, the watermark code is working. // If it fails on an air recording, the issue is in the PlutoSDR/air/receiver path. package main import ( "encoding/binary" "flag" "fmt" "log" "math" "os" "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/license" offpkg "github.com/jan/fm-rds-tx/internal/offline" "github.com/jan/fm-rds-tx/internal/watermark" ) func main() { configPath := flag.String("config", "", "path to JSON config (uses same as fmrtx)") key := flag.String("key", "free", "license key to embed") output := flag.String("output", "wmdump.wav", "output WAV path") duration := flag.Duration("duration", 60*time.Second, "generation duration") rate := flag.Int("rate", 192000, "output WAV sample rate (resampled from composite)") flag.Parse() cfg, err := cfgpkg.Load(*configPath) if err != nil { log.Fatalf("load config: %v", err) } // Match real TX: split-rate mode means FMModulationEnabled=false cfg.FM.FMModulationEnabled = false gen := offpkg.NewGenerator(cfg) licState := license.NewState(*key) gen.SetLicense(licState, *key) fmt.Printf("Generating composite with watermark...\n") fmt.Printf(" Key: %s (licensed=%v)\n", *key, licState.Licensed()) fmt.Printf(" Config: %s\n", *configPath) fmt.Printf(" Duration: %s\n", *duration) fmt.Printf(" Composite: %d Hz\n", cfg.FM.CompositeRateHz) fmt.Printf(" Output: %s @ %d Hz\n", *output, *rate) fmt.Printf(" ChipRate: %d Hz (PN bandwidth 0-%d Hz)\n", watermark.ChipRate, watermark.ChipRate/2) fmt.Println() frame := gen.GenerateFrame(*duration) if frame == nil { log.Fatal("GenerateFrame returned nil") } fmt.Printf("Generated %d composite samples @ %.0f Hz\n", len(frame.Samples), frame.SampleRateHz) // Extract composite (I channel in non-FM mode) compRate := frame.SampleRateHz nComp := len(frame.Samples) // RMS check var rmsAcc float64 for _, s := range frame.Samples { rmsAcc += float64(s.I) * float64(s.I) } compRMS := math.Sqrt(rmsAcc / float64(nComp)) fmt.Printf("Composite RMS: %.1f dBFS\n", 20*math.Log10(compRMS+1e-12)) // Resample composite to output rate (linear interpolation) outRate := float64(*rate) ratio := outRate / compRate nOut := int(float64(nComp) * ratio) samples := make([]float64, nOut) for i := range samples { pos := float64(i) / ratio idx := int(pos) frac := pos - float64(idx) if idx+1 < nComp { samples[i] = float64(frame.Samples[idx].I)*(1-frac) + float64(frame.Samples[idx+1].I)*frac } else if idx < nComp { samples[i] = float64(frame.Samples[idx].I) } } // RMS after resample var rms2 float64 for _, s := range samples { rms2 += s * s } outRMS := math.Sqrt(rms2 / float64(nOut)) fmt.Printf("Output RMS: %.1f dBFS (%d samples @ %.0f Hz)\n", 20*math.Log10(outRMS+1e-12), nOut, outRate) // Write WAV if err := writeWAV(*output, samples, *rate); err != nil { log.Fatalf("write WAV: %v", err) } fmt.Printf("\nWritten: %s\n", *output) fmt.Printf("\nDecode with:\n") fmt.Printf(" .\\wmdecode.exe %s %q\n", *output, *key) } func writeWAV(path string, samples []float64, rate int) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() le := binary.LittleEndian dataSz := uint32(len(samples) * 2) f.Write([]byte("RIFF")) binary.Write(f, le, 36+dataSz) f.Write([]byte("WAVE")) f.Write([]byte("fmt ")) binary.Write(f, le, uint32(16)) binary.Write(f, le, uint16(1)) binary.Write(f, le, uint16(1)) binary.Write(f, le, uint32(rate)) binary.Write(f, le, uint32(rate*2)) binary.Write(f, le, uint16(2)) binary.Write(f, le, uint16(16)) f.Write([]byte("data")) binary.Write(f, le, dataSz) for _, s := range samples { v := s * 32767.0 if v > 32767 { v = 32767 } if v < -32768 { v = -32768 } binary.Write(f, le, int16(v)) } return nil }