| @@ -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 -print-config` | ||||
| - `go run ./cmd/fmrtx -config docs/config.sample.json` | - `go run ./cmd/fmrtx -config docs/config.sample.json` | ||||
| - `go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.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 | ### Internal DSP module | ||||
| - `cd internal` | - `cd internal` | ||||
| @@ -21,3 +22,8 @@ | |||||
| The dry-run mode generates a synthetic, hardware-free frame summary based on the current config. | 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. | 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" | "fmt" | ||||
| "math" | "math" | ||||
| "os" | "os" | ||||
| "path/filepath" | |||||
| "sync" | "sync" | ||||
| ) | ) | ||||
| @@ -21,6 +22,9 @@ type FileBackend struct { | |||||
| // NewFileBackend creates a writer that appends float32 interleaved I/Q pairs to the named file. | // 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) { | 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) | f, err := os.Create(path) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("open output file: %w", err) | return nil, fmt.Errorf("open output file: %w", err) | ||||
| @@ -1,6 +1,7 @@ | |||||
| $ErrorActionPreference = "Stop" | $ErrorActionPreference = "Stop" | ||||
| go test ./... | go test ./... | ||||
| go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json | 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 | Push-Location internal | ||||
| go test ./... | go test ./... | ||||
| Pop-Location | Pop-Location | ||||