| @@ -5,14 +5,18 @@ import ( | |||||
| "fmt" | "fmt" | ||||
| "log" | "log" | ||||
| "net/http" | "net/http" | ||||
| "os" | |||||
| 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" | |||||
| ) | ) | ||||
| func main() { | func main() { | ||||
| configPath := flag.String("config", "", "path to JSON config") | configPath := flag.String("config", "", "path to JSON config") | ||||
| 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") | |||||
| dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout") | |||||
| flag.Parse() | flag.Parse() | ||||
| cfg, err := cfgpkg.Load(*configPath) | cfg, err := cfgpkg.Load(*configPath) | ||||
| @@ -25,6 +29,17 @@ func main() { | |||||
| return | 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) | 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())) | ||||
| @@ -6,6 +6,7 @@ | |||||
| - `go test ./...` | - `go test ./...` | ||||
| - `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` | |||||
| ### Internal DSP module | ### Internal DSP module | ||||
| - `cd internal` | - `cd internal` | ||||
| @@ -15,3 +16,8 @@ | |||||
| - `cd examples` | - `cd examples` | ||||
| - `go test ./...` | - `go test ./...` | ||||
| - `go run ./soapy_simulated` | - `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. | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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"]) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -1,5 +1,6 @@ | |||||
| $ErrorActionPreference = "Stop" | $ErrorActionPreference = "Stop" | ||||
| go test ./... | go test ./... | ||||
| go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json | |||||
| Push-Location internal | Push-Location internal | ||||
| go test ./... | go test ./... | ||||
| Pop-Location | Pop-Location | ||||
| @@ -1,2 +1,2 @@ | |||||
| $ErrorActionPreference = "Stop" | $ErrorActionPreference = "Stop" | ||||
| go run ./cmd/fmrtx -config docs/config.sample.json | |||||
| go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json | |||||