|
- 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 > 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.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)
- }
|