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