| @@ -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())) | |||
| @@ -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. | |||
| @@ -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" | |||
| go test ./... | |||
| go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json | |||
| Push-Location internal | |||
| go test ./... | |||
| Pop-Location | |||
| @@ -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 | |||