diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 07a6ba3..f53fcfd 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -5,14 +5,18 @@ import ( "fmt" "log" "net/http" + "os" 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" ) func main() { configPath := flag.String("config", "", "path to JSON config") 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") flag.Parse() cfg, err := cfgpkg.Load(*configPath) @@ -25,6 +29,17 @@ func main() { return } + if *dryRun { + frame := drypkg.Generate(cfg) + if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { + log.Fatalf("dry-run failed: %v", err) + } + if *dryOutput != "" && *dryOutput != "-" { + fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput) + } + 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 60aa5d4..cc7c87f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - `go test ./...` - `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` ### Internal DSP module - `cd internal` @@ -15,3 +16,8 @@ - `cd examples` - `go test ./...` - `go run ./soapy_simulated` + +## Dry run + +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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..aae9e11 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultValidate(t *testing.T) { + cfg := Default() + if err := cfg.Validate(); err != nil { + t.Fatalf("default config invalid: %v", err) + } +} + +func TestLoadAndValidate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"fm":{"frequencyMHz":99.9},"backend":{"kind":"file","outputPath":"out.f32"},"control":{"listenAddress":"127.0.0.1:8088"}}`), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.FM.FrequencyMHz != 99.9 { + t.Fatalf("unexpected frequency: %v", cfg.FM.FrequencyMHz) + } +} + +func TestValidateRejectsBadFrequency(t *testing.T) { + cfg := Default() + cfg.FM.FrequencyMHz = 200 + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error") + } +} diff --git a/internal/control/control_test.go b/internal/control/control_test.go new file mode 100644 index 0000000..92c77bd --- /dev/null +++ b/internal/control/control_test.go @@ -0,0 +1,37 @@ +package control + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" +) + +func TestHealthz(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } +} + +func TestStatus(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["service"] != "fm-rds-tx" { + t.Fatalf("unexpected service: %v", body["service"]) + } +} diff --git a/internal/dryrun/dryrun.go b/internal/dryrun/dryrun.go new file mode 100644 index 0000000..c509e20 --- /dev/null +++ b/internal/dryrun/dryrun.go @@ -0,0 +1,65 @@ +package dryrun + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" +) + +type FrameSummary struct { + Mode string `json:"mode"` + FrequencyMHz float64 `json:"frequencyMHz"` + StereoEnabled bool `json:"stereoEnabled"` + RDSEnabled bool `json:"rdsEnabled"` + SampleRateHz int `json:"sampleRateHz"` + CompositeRate int `json:"compositeRateHz"` + PilotLevel float64 `json:"pilotLevel"` + RDSInjection float64 `json:"rdsInjection"` + OutputDrive float64 `json:"outputDrive"` + PreviewSamples []float64 `json:"previewSamples"` +} + +func Generate(cfg cfgpkg.Config) FrameSummary { + preview := make([]float64, 16) + base := cfg.Audio.Gain * cfg.FM.OutputDrive + for i := range preview { + sign := 1.0 + if i%2 == 1 { + sign = -1.0 + } + preview[i] = sign * base * float64(i+1) / 16.0 + } + return FrameSummary{ + Mode: "dry-run", + FrequencyMHz: cfg.FM.FrequencyMHz, + StereoEnabled: cfg.FM.StereoEnabled, + RDSEnabled: cfg.RDS.Enabled, + SampleRateHz: cfg.Audio.SampleRate, + CompositeRate: cfg.FM.CompositeRateHz, + PilotLevel: cfg.FM.PilotLevel, + RDSInjection: cfg.FM.RDSInjection, + OutputDrive: cfg.FM.OutputDrive, + PreviewSamples: preview, + } +} + +func WriteJSON(path string, frame FrameSummary) error { + data, err := json.MarshalIndent(frame, "", " ") + if err != nil { + return fmt.Errorf("marshal dry-run frame: %w", err) + } + if path == "" || path == "-" { + _, err = os.Stdout.Write(append(data, '\n')) + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("write dry-run frame: %w", err) + } + return nil +} diff --git a/internal/dryrun/dryrun_test.go b/internal/dryrun/dryrun_test.go new file mode 100644 index 0000000..4361f81 --- /dev/null +++ b/internal/dryrun/dryrun_test.go @@ -0,0 +1,31 @@ +package dryrun + +import ( + "os" + "path/filepath" + "testing" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" +) + +func TestGenerate(t *testing.T) { + cfg := cfgpkg.Default() + frame := Generate(cfg) + if frame.Mode != "dry-run" { + t.Fatalf("unexpected mode: %s", frame.Mode) + } + if len(frame.PreviewSamples) != 16 { + t.Fatalf("unexpected preview length: %d", len(frame.PreviewSamples)) + } +} + +func TestWriteJSONFile(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "frame.json") + if err := WriteJSON(out, Generate(cfgpkg.Default())); err != nil { + t.Fatalf("WriteJSON failed: %v", err) + } + if _, err := os.Stat(out); err != nil { + t.Fatalf("expected output file: %v", err) + } +} diff --git a/scripts/check.ps1 b/scripts/check.ps1 index 499f049..0e38dcf 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -1,5 +1,6 @@ $ErrorActionPreference = "Stop" go test ./... +go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json Push-Location internal go test ./... Pop-Location diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 9724b1b..739f54a 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -1,2 +1,2 @@ $ErrorActionPreference = "Stop" -go run ./cmd/fmrtx -config docs/config.sample.json +go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json