diff --git a/cmd/offline/main.go b/cmd/offline/main.go new file mode 100644 index 0000000..ef3d5c4 --- /dev/null +++ b/cmd/offline/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "flag" + "fmt" + "log" + "time" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" + offpkg "github.com/jan/fm-rds-tx/internal/offline" +) + +func main() { + configPath := flag.String("config", "", "path to JSON config") + out := flag.String("output", "", "output IQ file path") + duration := flag.Duration("duration", 2*time.Second, "generation duration") + flag.Parse() + + cfg, err := cfgpkg.Load(*configPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + + gen := offpkg.NewGenerator(cfg) + if err := gen.WriteFile(*out, *duration); err != nil { + log.Fatalf("offline generation failed: %v", err) + } + fmt.Println(gen.Summary(*duration)) +} diff --git a/docs/README.md b/docs/README.md index cc7c87f..7763c11 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ - `go run ./cmd/fmrtx -print-config` - `go run ./cmd/fmrtx -config docs/config.sample.json` - `go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json` +- `go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32` ### Internal DSP module - `cd internal` @@ -21,3 +22,8 @@ The dry-run mode generates a synthetic, hardware-free frame summary based on the current config. It is intended as a no-hardware smoke path for the CLI and config/control-adjacent logic. + +## Offline generation + +`cmd/offline` generates a deterministic no-hardware IQ/composite-style file using the repository's output backend path. +This is still an MVP path, but it is a more realistic offline artifact than the JSON-only dry-run. diff --git a/internal/offline/generator.go b/internal/offline/generator.go new file mode 100644 index 0000000..bfad59b --- /dev/null +++ b/internal/offline/generator.go @@ -0,0 +1,96 @@ +package offline + +import ( + "context" + "encoding/binary" + "fmt" + "math" + "path/filepath" + "time" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/output" +) + +type Generator struct { + cfg cfgpkg.Config +} + +func NewGenerator(cfg cfgpkg.Config) *Generator { + return &Generator{cfg: cfg} +} + +func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { + sampleRate := float64(g.cfg.FM.CompositeRateHz) + if sampleRate <= 0 { + sampleRate = 228000 + } + samples := int(duration.Seconds() * sampleRate) + if samples <= 0 { + samples = int(sampleRate / 10) + } + + frame := &output.CompositeFrame{ + Samples: make([]output.IQSample, samples), + SampleRateHz: sampleRate, + Timestamp: time.Now().UTC(), + Sequence: 1, + } + + leftFreq := 1000.0 + rightFreq := 1600.0 + pilotFreq := 19000.0 + rdsFreq := 57000.0 + + for i := 0; i < samples; i++ { + t := float64(i) / sampleRate + left := 0.4 * math.Sin(2*math.Pi*leftFreq*t) + right := 0.4 * math.Sin(2*math.Pi*rightFreq*t+math.Pi/3) + mono := (left + right) / 2 + stereo := (left - right) / 2 * 0.8 * math.Sin(2*math.Pi*38000*t) + pilot := g.cfg.FM.PilotLevel * math.Sin(2*math.Pi*pilotFreq*t) + rds := g.cfg.FM.RDSInjection * math.Sin(2*math.Pi*rdsFreq*t) + composite := (mono + stereo + pilot + rds) * g.cfg.FM.OutputDrive + frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0} + } + + return frame +} + +func (g *Generator) WriteFile(path string, duration time.Duration) error { + if path == "" { + path = g.cfg.Backend.OutputPath + } + if path == "" { + path = filepath.Join("build", "offline", "composite.iqf32") + } + backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{ + Name: "offline-file", + Description: "offline composite file backend", + }) + if err != nil { + return err + } + defer backend.Close(context.Background()) + + if err := backend.Configure(context.Background(), output.BackendConfig{ + SampleRateHz: float64(g.cfg.FM.CompositeRateHz), + Channels: 2, + IQLevel: float32(g.cfg.FM.OutputDrive), + }); err != nil { + return err + } + + frame := g.GenerateFrame(duration) + if _, err := backend.Write(context.Background(), frame); err != nil { + return err + } + if err := backend.Flush(context.Background()); err != nil { + return err + } + return nil +} + +func (g *Generator) Summary(duration time.Duration) string { + return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive) +} diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go new file mode 100644 index 0000000..c273b86 --- /dev/null +++ b/internal/offline/generator_test.go @@ -0,0 +1,38 @@ +package offline + +import ( + "os" + "path/filepath" + "testing" + "time" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" +) + +func TestGenerateFrame(t *testing.T) { + g := NewGenerator(cfgpkg.Default()) + frame := g.GenerateFrame(50 * time.Millisecond) + if frame == nil { + t.Fatal("expected frame") + } + if len(frame.Samples) == 0 { + t.Fatal("expected samples") + } +} + +func TestWriteFile(t *testing.T) { + cfg := cfgpkg.Default() + out := filepath.Join(t.TempDir(), "test.iqf32") + cfg.Backend.OutputPath = out + g := NewGenerator(cfg) + if err := g.WriteFile(out, 20*time.Millisecond); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + info, err := os.Stat(out) + if err != nil { + t.Fatalf("expected output file: %v", err) + } + if info.Size() == 0 { + t.Fatal("expected non-empty file") + } +} diff --git a/internal/output/file.go b/internal/output/file.go index 9d19f40..f79c8bb 100644 --- a/internal/output/file.go +++ b/internal/output/file.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "os" + "path/filepath" "sync" ) @@ -21,6 +22,9 @@ type FileBackend struct { // NewFileBackend creates a writer that appends float32 interleaved I/Q pairs to the named file. func NewFileBackend(path string, order binary.ByteOrder, info BackendInfo) (*FileBackend, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create output dir: %w", err) + } f, err := os.Create(path) if err != nil { return nil, fmt.Errorf("open output file: %w", err) diff --git a/scripts/check.ps1 b/scripts/check.ps1 index 0e38dcf..d165dca 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -1,6 +1,7 @@ $ErrorActionPreference = "Stop" go test ./... go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json +go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 Push-Location internal go test ./... Pop-Location