From 1bb5692417e4af51ede1dcd0fea686fa43c2481a Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 2 Apr 2026 17:18:28 +0200 Subject: [PATCH] feat: integrate cli config and control scaffolding --- cmd/fmrtx/main.go | 31 +++++++++++ docs/README.md | 17 ++++++ docs/config.sample.json | 31 +++++++++++ go.mod | 7 +++ internal/config/config.go | 105 ++++++++++++++++++++++++++++++++++++ internal/control/control.go | 37 +++++++++++++ scripts/check.ps1 | 8 +++ scripts/run.ps1 | 2 + 8 files changed, 238 insertions(+) create mode 100644 cmd/fmrtx/main.go create mode 100644 docs/README.md create mode 100644 docs/config.sample.json create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/control/control.go create mode 100644 scripts/check.ps1 create mode 100644 scripts/run.ps1 diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go new file mode 100644 index 0000000..07a6ba3 --- /dev/null +++ b/cmd/fmrtx/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" + ctrlpkg "github.com/jan/fm-rds-tx/internal/control" +) + +func main() { + configPath := flag.String("config", "", "path to JSON config") + printConfig := flag.Bool("print-config", false, "print effective config and exit") + flag.Parse() + + cfg, err := cfgpkg.Load(*configPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + + if *printConfig { + fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t listen=%s\n", cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, cfg.Control.ListenAddress) + 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 new file mode 100644 index 0000000..60aa5d4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# fm-rds-tx docs + +## Build & Test + +### Root CLI +- `go test ./...` +- `go run ./cmd/fmrtx -print-config` +- `go run ./cmd/fmrtx -config docs/config.sample.json` + +### Internal DSP module +- `cd internal` +- `go test ./...` + +### Examples module +- `cd examples` +- `go test ./...` +- `go run ./soapy_simulated` diff --git a/docs/config.sample.json b/docs/config.sample.json new file mode 100644 index 0000000..4397599 --- /dev/null +++ b/docs/config.sample.json @@ -0,0 +1,31 @@ +{ + "audio": { + "inputPath": "", + "sampleRate": 48000, + "gain": 1.0 + }, + "rds": { + "enabled": true, + "pi": "1234", + "ps": "FMRTX", + "radioText": "fm-rds-tx example config", + "pty": 0 + }, + "fm": { + "frequencyMHz": 100.0, + "stereoEnabled": true, + "pilotLevel": 0.1, + "rdsInjection": 0.03, + "preEmphasisUS": false, + "outputDrive": 0.5, + "compositeRateHz": 228000 + }, + "backend": { + "kind": "file", + "device": "", + "outputPath": "build/out/composite.f32" + }, + "control": { + "listenAddress": "127.0.0.1:8088" + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..568d50e --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/jan/fm-rds-tx + +go 1.24.0 + +require github.com/jan/fm-rds-tx/internal v0.0.0 + +replace github.com/jan/fm-rds-tx/internal => ./internal diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ff3b799 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,105 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" +) + +type Config struct { + Audio AudioConfig `json:"audio"` + RDS RDSConfig `json:"rds"` + FM FMConfig `json:"fm"` + Backend BackendConfig `json:"backend"` + Control ControlConfig `json:"control"` +} + +type AudioConfig struct { + InputPath string `json:"inputPath"` + SampleRate int `json:"sampleRate"` + Gain float64 `json:"gain"` +} + +type RDSConfig struct { + Enabled bool `json:"enabled"` + PI string `json:"pi"` + PS string `json:"ps"` + RadioText string `json:"radioText"` + PTY int `json:"pty"` +} + +type FMConfig struct { + FrequencyMHz float64 `json:"frequencyMHz"` + StereoEnabled bool `json:"stereoEnabled"` + PilotLevel float64 `json:"pilotLevel"` + RDSInjection float64 `json:"rdsInjection"` + PreEmphasisUS bool `json:"preEmphasisUS"` + OutputDrive float64 `json:"outputDrive"` + CompositeRateHz int `json:"compositeRateHz"` +} + +type BackendConfig struct { + Kind string `json:"kind"` + Device string `json:"device"` + OutputPath string `json:"outputPath"` +} + +type ControlConfig struct { + ListenAddress string `json:"listenAddress"` +} + +func Default() Config { + return Config{ + Audio: AudioConfig{SampleRate: 48000, Gain: 1.0}, + RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0}, + FM: FMConfig{FrequencyMHz: 100.0, StereoEnabled: true, PilotLevel: 0.1, RDSInjection: 0.03, OutputDrive: 0.5, CompositeRateHz: 228000}, + Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, + Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, + } +} + +func Load(path string) (Config, error) { + cfg := Default() + if path == "" { + return cfg, cfg.Validate() + } + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + return cfg, cfg.Validate() +} + +func (c Config) Validate() error { + if c.Audio.SampleRate < 8000 || c.Audio.SampleRate > 384000 { + return fmt.Errorf("audio.sampleRate out of range") + } + if c.Audio.Gain < 0 || c.Audio.Gain > 4 { + return fmt.Errorf("audio.gain out of range") + } + if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 { + return fmt.Errorf("fm.frequencyMHz out of range") + } + if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 { + return fmt.Errorf("fm.pilotLevel out of range") + } + if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.1 { + return fmt.Errorf("fm.rdsInjection out of range") + } + if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 1 { + return fmt.Errorf("fm.outputDrive out of range") + } + if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { + return fmt.Errorf("fm.compositeRateHz out of range") + } + if c.Backend.Kind == "" { + return fmt.Errorf("backend.kind is required") + } + if c.Control.ListenAddress == "" { + return fmt.Errorf("control.listenAddress is required") + } + return nil +} diff --git a/internal/control/control.go b/internal/control/control.go new file mode 100644 index 0000000..1e79adf --- /dev/null +++ b/internal/control/control.go @@ -0,0 +1,37 @@ +package control + +import ( + "encoding/json" + "net/http" + + "github.com/jan/fm-rds-tx/internal/config" +) + +type Server struct { + cfg config.Config +} + +func NewServer(cfg config.Config) *Server { return &Server{cfg: cfg} } + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleHealth) + mux.HandleFunc("/status", s.handleStatus) + return mux +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} + +func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "service": "fm-rds-tx", + "backend": s.cfg.Backend.Kind, + "frequencyMHz": s.cfg.FM.FrequencyMHz, + "stereoEnabled": s.cfg.FM.StereoEnabled, + "rdsEnabled": s.cfg.RDS.Enabled, + }) +} diff --git a/scripts/check.ps1 b/scripts/check.ps1 new file mode 100644 index 0000000..499f049 --- /dev/null +++ b/scripts/check.ps1 @@ -0,0 +1,8 @@ +$ErrorActionPreference = "Stop" +go test ./... +Push-Location internal +go test ./... +Pop-Location +Push-Location examples +go test ./... +Pop-Location diff --git a/scripts/run.ps1 b/scripts/run.ps1 new file mode 100644 index 0000000..9724b1b --- /dev/null +++ b/scripts/run.ps1 @@ -0,0 +1,2 @@ +$ErrorActionPreference = "Stop" +go run ./cmd/fmrtx -config docs/config.sample.json