| @@ -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())) | |||||
| } | |||||
| @@ -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` | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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, | |||||
| }) | |||||
| } | |||||
| @@ -0,0 +1,8 @@ | |||||
| $ErrorActionPreference = "Stop" | |||||
| go test ./... | |||||
| Push-Location internal | |||||
| go test ./... | |||||
| Pop-Location | |||||
| Push-Location examples | |||||
| go test ./... | |||||
| Pop-Location | |||||
| @@ -0,0 +1,2 @@ | |||||
| $ErrorActionPreference = "Stop" | |||||
| go run ./cmd/fmrtx -config docs/config.sample.json | |||||