| @@ -6,7 +6,9 @@ import ( | |||||
| "log" | "log" | ||||
| "net/http" | "net/http" | ||||
| "os" | "os" | ||||
| "time" | |||||
| apppkg "github.com/jan/fm-rds-tx/internal/app" | |||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ||||
| drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | 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") | printConfig := flag.Bool("print-config", false, "print effective config and exit") | ||||
| dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output") | dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output") | ||||
| dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout") | 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() | flag.Parse() | ||||
| cfg, err := cfgpkg.Load(*configPath) | cfg, err := cfgpkg.Load(*configPath) | ||||
| @@ -40,6 +45,15 @@ func main() { | |||||
| return | 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) | srv := ctrlpkg.NewServer(cfg) | ||||
| log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress) | log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress) | ||||
| log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) | log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) | ||||
| @@ -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/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms` | |||||
| - `go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32` | - `go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32` | ||||
| ### Internal DSP module | ### 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. | 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 | ## Offline generation | ||||
| `cmd/offline` generates a deterministic no-hardware IQ/composite-style file using the repository's output backend path. | `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" | $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/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms | |||||
| go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 | go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 | ||||
| Push-Location internal | Push-Location internal | ||||
| go test ./... | go test ./... | ||||