| @@ -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 | |||