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