package config import ( "encoding/json" "fmt" "os" "strconv" "strings" ) 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"` Gain float64 `json:"gain"` ToneLeftHz float64 `json:"toneLeftHz"` ToneRightHz float64 `json:"toneRightHz"` ToneAmplitude float64 `json:"toneAmplitude"` } 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"` // linear injection level in composite (e.g. 0.1 = 10%) RDSInjection float64 `json:"rdsInjection"` // linear injection level in composite (e.g. 0.05 = 5%) PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off OutputDrive float64 `json:"outputDrive"` CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate MaxDeviationHz float64 `json:"maxDeviationHz"` LimiterEnabled bool `json:"limiterEnabled"` LimiterCeiling float64 `json:"limiterCeiling"` FMModulationEnabled bool `json:"fmModulationEnabled"` } type BackendConfig struct { Kind string `json:"kind"` Device string `json:"device"` OutputPath string `json:"outputPath"` DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz } type ControlConfig struct { ListenAddress string `json:"listenAddress"` } func Default() Config { return Config{ Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, 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.05, PreEmphasisTauUS: 50, OutputDrive: 0.5, CompositeRateHz: 228000, MaxDeviationHz: 75000, LimiterEnabled: true, LimiterCeiling: 1.0, FMModulationEnabled: true, }, Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, } } // ParsePI parses a hex PI code string. Returns an error for invalid input. func ParsePI(pi string) (uint16, error) { trimmed := strings.TrimSpace(pi) if trimmed == "" { return 0, fmt.Errorf("rds.pi is required") } trimmed = strings.TrimPrefix(trimmed, "0x") trimmed = strings.TrimPrefix(trimmed, "0X") v, err := strconv.ParseUint(trimmed, 16, 16) if err != nil { return 0, fmt.Errorf("invalid rds.pi: %q", pi) } return uint16(v), nil } 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.Gain < 0 || c.Audio.Gain > 4 { return fmt.Errorf("audio.gain out of range") } if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 { return fmt.Errorf("audio tone frequencies must be positive") } if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 { return fmt.Errorf("audio.toneAmplitude 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.15 { return fmt.Errorf("fm.rdsInjection out of range") } if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 3 { return fmt.Errorf("fm.outputDrive out of range (0..3)") } if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { return fmt.Errorf("fm.compositeRateHz out of range") } if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 { return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)") } if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 { return fmt.Errorf("fm.maxDeviationHz out of range") } if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { return fmt.Errorf("fm.limiterCeiling out of range") } if c.Backend.Kind == "" { return fmt.Errorf("backend.kind is required") } if c.Backend.DeviceSampleRateHz < 0 { return fmt.Errorf("backend.deviceSampleRateHz must be >= 0") } if c.Control.ListenAddress == "" { return fmt.Errorf("control.listenAddress is required") } // Fail-loud PI validation if c.RDS.Enabled { if _, err := ParsePI(c.RDS.PI); err != nil { return fmt.Errorf("rds config: %w", err) } } if c.RDS.PTY < 0 || c.RDS.PTY > 31 { return fmt.Errorf("rds.pty out of range (0-31)") } return nil } // EffectiveDeviceRate returns the device sample rate, falling back to composite rate. func (c Config) EffectiveDeviceRate() float64 { if c.Backend.DeviceSampleRateHz > 0 { return c.Backend.DeviceSampleRateHz } return float64(c.FM.CompositeRateHz) }