From 822ff6d357c0473bb30449a6dd9cf26c48fbcc00 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 2 Apr 2026 22:32:46 +0200 Subject: [PATCH] feat: add simulated transmit path to main cli --- cmd/fmrtx/main.go | 14 ++++++++++ docs/README.md | 6 +++++ internal/app/sim.go | 56 ++++++++++++++++++++++++++++++++++++++++ internal/app/sim_test.go | 26 +++++++++++++++++++ scripts/check.ps1 | 1 + 5 files changed, 103 insertions(+) create mode 100644 internal/app/sim.go create mode 100644 internal/app/sim_test.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index f53fcfd..52419a9 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -6,7 +6,9 @@ import ( "log" "net/http" "os" + "time" + apppkg "github.com/jan/fm-rds-tx/internal/app" cfgpkg "github.com/jan/fm-rds-tx/internal/config" ctrlpkg "github.com/jan/fm-rds-tx/internal/control" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" @@ -17,6 +19,9 @@ func main() { printConfig := flag.Bool("print-config", false, "print effective config and exit") dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output") dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout") + simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path") + simulateOutput := flag.String("simulate-output", "", "simulated transmit output file") + simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration") flag.Parse() cfg, err := cfgpkg.Load(*configPath) @@ -40,6 +45,15 @@ func main() { return } + if *simulate { + summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) + if err != nil { + log.Fatalf("simulate-tx failed: %v", err) + } + fmt.Println(summary) + return + } + srv := ctrlpkg.NewServer(cfg) log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress) log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) diff --git a/docs/README.md b/docs/README.md index af11eaa..9c7ad33 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/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms` - `go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32` ### Internal DSP module @@ -25,6 +26,11 @@ It is intended as a no-hardware smoke path for the CLI and config/control-adjace The HTTP control plane also exposes `GET /dry-run` for quick inspection of the currently effective no-hardware summary. +## Simulated transmit + +`--simulate-tx` runs the offline generator through the Soapy-oriented simulated backend path and writes an IQ-style artifact to disk. +This is the current closest no-hardware stand-in for the future transmit pipeline in the main application path. + ## Offline generation `cmd/offline` generates a deterministic no-hardware IQ/composite-style file using the repository's output backend path. diff --git a/internal/app/sim.go b/internal/app/sim.go new file mode 100644 index 0000000..6950fe0 --- /dev/null +++ b/internal/app/sim.go @@ -0,0 +1,56 @@ +package app + +import ( + "context" + "encoding/binary" + "fmt" + "path/filepath" + "time" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" + offpkg "github.com/jan/fm-rds-tx/internal/offline" + "github.com/jan/fm-rds-tx/internal/output" + "github.com/jan/fm-rds-tx/internal/platform" +) + +func RunSimulatedTransmit(cfg cfgpkg.Config, outPath string, duration time.Duration) (string, error) { + if outPath == "" { + outPath = filepath.Join("build", "sim", "simulated-soapy.iqf32") + } + + fileBackend, err := output.NewFileBackend(outPath, binary.LittleEndian, output.BackendInfo{ + Name: "simulated-soapy-file", + Description: "simulated soapy sink to file", + }) + if err != nil { + return "", err + } + defer fileBackend.Close(context.Background()) + + soapyCfg := platform.SoapyConfig{ + BackendConfig: output.BackendConfig{ + SampleRateHz: float64(cfg.FM.CompositeRateHz), + Channels: 2, + IQLevel: float32(cfg.FM.OutputDrive), + }, + Driver: "simulated", + Device: cfg.Backend.Device, + CenterFreqHz: cfg.FM.FrequencyMHz * 1_000_000, + Simulated: true, + SimulationPath: outPath, + } + backend := platform.NewSoapyBackend(soapyCfg, platform.NewSimulatedDriver(fileBackend)) + if err := backend.Configure(context.Background(), soapyCfg.BackendConfig); err != nil { + return "", err + } + + gen := offpkg.NewGenerator(cfg) + frame := gen.GenerateFrame(duration) + if _, err := backend.Write(context.Background(), frame); err != nil { + return "", err + } + if err := backend.Flush(context.Background()); err != nil { + return "", err + } + return fmt.Sprintf("simulated transmit: backend=%s output=%s duration=%s", backend.Info().Name, outPath, duration), nil +} diff --git a/internal/app/sim_test.go b/internal/app/sim_test.go new file mode 100644 index 0000000..fe86b5c --- /dev/null +++ b/internal/app/sim_test.go @@ -0,0 +1,26 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" +) + +func TestRunSimulatedTransmit(t *testing.T) { + cfg := cfgpkg.Default() + out := filepath.Join(t.TempDir(), "simulated.iqf32") + summary, err := RunSimulatedTransmit(cfg, out, 20*time.Millisecond) + if err != nil { + t.Fatalf("RunSimulatedTransmit failed: %v", err) + } + if !strings.Contains(summary, "simulated transmit") { + t.Fatalf("unexpected summary: %s", summary) + } + if info, err := os.Stat(out); err != nil || info.Size() == 0 { + t.Fatalf("expected non-empty output file, err=%v", err) + } +} diff --git a/scripts/check.ps1 b/scripts/check.ps1 index d165dca..f1d63d2 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/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 Push-Location internal go test ./...