package config import ( "encoding/json" "fmt" "os" "path/filepath" "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"` Runtime RuntimeConfig `json:"runtime"` Ingest IngestConfig `json:"ingest"` } 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"` // Traffic TP bool `json:"tp"` // Traffic Program — station carries traffic info TA bool `json:"ta"` // Traffic Announcement — currently on air // Music/Speech & Decoder Info MS bool `json:"ms"` // true=music, false=speech DI uint8 `json:"di"` // Decoder Info: bit0=stereo, bit1=artificial head, bit2=compressed, bit3=dynamic PTY // Alternative Frequencies (MHz, e.g. [93.3, 95.7]) AF []float64 `json:"af,omitempty"` // Clock-Time (Group 4A) CTEnabled bool `json:"ctEnabled"` CTOffsetHalfHours int8 `json:"ctOffsetHalfHours,omitempty"` // 0 = auto from OS // Program Type Name (Group 10A) — 8-char custom label PTYN string `json:"ptyn,omitempty"` // Long Programme Service name (Group 15A) — up to 32 bytes UTF-8. // Static station name, complements PS. Receivers may display instead of PS. LPS string `json:"lps,omitempty"` // RT+ (Groups 3A + 11A) — auto-parse artist/title from RadioText RTPlusEnabled bool `json:"rtPlusEnabled"` RTPlusSeparator string `json:"rtPlusSeparator,omitempty"` // default " - " // eRT — Enhanced RadioText (ODA, UTF-8, 128 bytes) ERTEnabled bool `json:"ertEnabled"` ERT string `json:"ert,omitempty"` // RDS2 — additional subcarriers RDS2Enabled bool `json:"rds2Enabled"` StationLogoPath string `json:"stationLogoPath,omitempty"` // EON — Enhanced Other Networks (Group 14A) EON []EONEntryConfig `json:"eon,omitempty"` } // EONEntryConfig describes another station for EON transmission. type EONEntryConfig struct { PI string `json:"pi"` // hex PI code PS string `json:"ps"` // 8-char station name PTY int `json:"pty"` TP bool `json:"tp"` TA bool `json:"ta"` AF []float64 `json:"af,omitempty"` } type FMConfig struct { FrequencyMHz float64 `json:"frequencyMHz"` StereoEnabled bool `json:"stereoEnabled"` StereoMode string `json:"stereoMode"` // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial) PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard) RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical) 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"` WatermarkEnabled bool `json:"watermarkEnabled"` // explicit opt-in for STFT program-audio watermarking MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement) BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed) CompositeClipper CompositeClipperConfig `json:"compositeClipper"` // ITU-R SM.1268 iterative composite clipper } // CompositeClipperConfig controls the broadcast-grade composite MPX clipper. // When enabled, replaces the simple clip→notch chain with an iterative // clip-filter-clip processor, optionally with soft-knee clipping and // look-ahead peak limiting. type CompositeClipperConfig struct { Enabled bool `json:"enabled"` // false = legacy single-pass clip Iterations int `json:"iterations"` // clip-filter-clip passes (1-5, default 3) SoftKnee float64 `json:"softKnee"` // soft-clip transition zone (0=hard, 0.15=moderate, 0.3=gentle) LookaheadMs float64 `json:"lookaheadMs"` // look-ahead delay (0=off, 1.0=typical) } type BackendConfig struct { Kind string `json:"kind"` Driver string `json:"driver,omitempty"` Device string `json:"device"` URI string `json:"uri,omitempty"` DeviceArgs map[string]string `json:"deviceArgs,omitempty"` OutputPath string `json:"outputPath"` DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz } type ControlConfig struct { ListenAddress string `json:"listenAddress"` } type RuntimeConfig struct { FrameQueueCapacity int `json:"frameQueueCapacity"` } type IngestConfig struct { Kind string `json:"kind"` PrebufferMs int `json:"prebufferMs"` StallTimeoutMs int `json:"stallTimeoutMs"` Reconnect IngestReconnectConfig `json:"reconnect"` Stdin IngestPCMConfig `json:"stdin"` HTTPRaw IngestPCMConfig `json:"httpRaw"` Icecast IngestIcecastConfig `json:"icecast"` SRT IngestSRTConfig `json:"srt"` AES67 IngestAES67Config `json:"aes67"` } type IngestReconnectConfig struct { Enabled bool `json:"enabled"` InitialBackoffMs int `json:"initialBackoffMs"` MaxBackoffMs int `json:"maxBackoffMs"` } type IngestPCMConfig struct { SampleRateHz int `json:"sampleRateHz"` Channels int `json:"channels"` Format string `json:"format"` } type IngestIcecastConfig struct { URL string `json:"url"` Decoder string `json:"decoder"` RadioText IngestIcecastRadioTextConfig `json:"radioText"` } type IngestIcecastRadioTextConfig struct { Enabled bool `json:"enabled"` Prefix string `json:"prefix"` MaxLen int `json:"maxLen"` OnlyOnChange bool `json:"onlyOnChange"` } type IngestSRTConfig struct { URL string `json:"url"` Mode string `json:"mode"` SampleRateHz int `json:"sampleRateHz"` Channels int `json:"channels"` } type IngestAES67Config struct { SDPPath string `json:"sdpPath"` SDP string `json:"sdp"` Discovery IngestAES67DiscoveryConfig `json:"discovery"` MulticastGroup string `json:"multicastGroup"` Port int `json:"port"` InterfaceName string `json:"interfaceName"` PayloadType int `json:"payloadType"` SampleRateHz int `json:"sampleRateHz"` Channels int `json:"channels"` Encoding string `json:"encoding"` PacketTimeMs int `json:"packetTimeMs"` JitterDepthPackets int `json:"jitterDepthPackets"` ReadBufferBytes int `json:"readBufferBytes"` } type IngestAES67DiscoveryConfig struct { Enabled bool `json:"enabled"` StreamName string `json:"streamName"` TimeoutMs int `json:"timeoutMs"` InterfaceName string `json:"interfaceName"` SAPGroup string `json:"sapGroup"` SAPPort int `json:"sapPort"` } func Default() Config { return Config{ // BUG-C fix: tones off by default (was 0.4 — caused unintended audio output). Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0}, RDS: RDSConfig{ Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0, MS: true, DI: 0x01, // stereo CTEnabled: true, RTPlusEnabled: true, RTPlusSeparator: " - ", }, FM: FMConfig{ FrequencyMHz: 100.0, StereoEnabled: true, StereoMode: "DSB", PilotLevel: 0.09, RDSInjection: 0.04, PreEmphasisTauUS: 50, OutputDrive: 0.5, CompositeRateHz: 228000, MaxDeviationHz: 75000, LimiterEnabled: true, LimiterCeiling: 1.0, FMModulationEnabled: true, MpxGain: 1.0, CompositeClipper: CompositeClipperConfig{ Enabled: false, Iterations: 3, SoftKnee: 0.15, LookaheadMs: 1.0, }, }, Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, Runtime: RuntimeConfig{FrameQueueCapacity: 3}, Ingest: IngestConfig{ Kind: "none", PrebufferMs: 1500, StallTimeoutMs: 3000, Reconnect: IngestReconnectConfig{ Enabled: true, InitialBackoffMs: 1000, MaxBackoffMs: 15000, }, Stdin: IngestPCMConfig{ SampleRateHz: 44100, Channels: 2, Format: "s16le", }, HTTPRaw: IngestPCMConfig{ SampleRateHz: 44100, Channels: 2, Format: "s16le", }, Icecast: IngestIcecastConfig{ Decoder: "auto", RadioText: IngestIcecastRadioTextConfig{ Enabled: false, MaxLen: 64, OnlyOnChange: true, }, }, SRT: IngestSRTConfig{ Mode: "listener", SampleRateHz: 48000, Channels: 2, }, AES67: IngestAES67Config{ Discovery: IngestAES67DiscoveryConfig{ TimeoutMs: 3000, }, PayloadType: 97, SampleRateHz: 48000, Channels: 2, Encoding: "L24", PacketTimeMs: 1, JitterDepthPackets: 8, ReadBufferBytes: 1 << 20, }, }, } } // 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 Save(path string, cfg Config) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("config path is required") } if err := cfg.Validate(); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } data = append(data, '\n') // NEW-1 fix: write to a temp file in the same directory, then rename atomically. // A direct os.WriteFile on the target leaves a corrupt file if the process // crashes mid-write. os.Rename is atomic on POSIX filesystems. dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".fmrtx-config-*.json.tmp") if err != nil { return fmt.Errorf("config save: create temp: %w", err) } tmpPath := tmp.Name() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() _ = os.Remove(tmpPath) return fmt.Errorf("config save: write temp: %w", err) } if err := tmp.Sync(); err != nil { _ = tmp.Close() _ = os.Remove(tmpPath) return fmt.Errorf("config save: sync temp: %w", err) } if err := tmp.Close(); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("config save: close temp: %w", err) } if err := os.Rename(tmpPath, path); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("config save: rename: %w", err) } return nil } func (c Config) Validate() error { if c.Audio.Gain < 0 || c.Audio.Gain > 4 { return fmt.Errorf("audio.gain out of range") } // BUG-B fix: only enforce positive freq when amplitude is non-zero. if c.Audio.ToneAmplitude > 0 && (c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0) { return fmt.Errorf("audio tone frequencies must be positive when toneAmplitude > 0") } 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 > 10 { return fmt.Errorf("fm.outputDrive out of range (0..10)") } 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)") } switch mode := strings.ToUpper(strings.TrimSpace(c.FM.StereoMode)); mode { case "DSB", "SSB", "VSB": default: return fmt.Errorf("fm.stereoMode invalid: %q (expected DSB, SSB, or VSB)", c.FM.StereoMode) } 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.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { return fmt.Errorf("fm.mpxGain out of range (0.1..5)") } if c.FM.CompositeClipper.Enabled { if c.FM.CompositeClipper.Iterations < 1 || c.FM.CompositeClipper.Iterations > 5 { return fmt.Errorf("fm.compositeClipper.iterations out of range (1-5)") } if c.FM.CompositeClipper.SoftKnee < 0 || c.FM.CompositeClipper.SoftKnee > 0.5 { return fmt.Errorf("fm.compositeClipper.softKnee out of range (0-0.5)") } if c.FM.CompositeClipper.LookaheadMs < 0 || c.FM.CompositeClipper.LookaheadMs > 5 { return fmt.Errorf("fm.compositeClipper.lookaheadMs out of range (0-5)") } } 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") } if c.Runtime.FrameQueueCapacity <= 0 { return fmt.Errorf("runtime.frameQueueCapacity must be > 0") } if c.Ingest.Kind == "" { c.Ingest.Kind = "none" } ingestKind := strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) switch ingestKind { case "none", "stdin", "stdin-pcm", "http-raw", "icecast", "srt", "aes67", "aoip", "aoip-rtp": default: return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind) } if c.Ingest.PrebufferMs < 0 { return fmt.Errorf("ingest.prebufferMs must be >= 0") } if c.Ingest.StallTimeoutMs < 0 { return fmt.Errorf("ingest.stallTimeoutMs must be >= 0") } if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 { return fmt.Errorf("ingest.reconnect backoff must be >= 0") } if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.InitialBackoffMs <= 0 { return fmt.Errorf("ingest.reconnect.initialBackoffMs must be > 0 when reconnect is enabled") } if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.MaxBackoffMs <= 0 { return fmt.Errorf("ingest.reconnect.maxBackoffMs must be > 0 when reconnect is enabled") } if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs { return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs") } if c.Ingest.Stdin.SampleRateHz <= 0 || c.Ingest.HTTPRaw.SampleRateHz <= 0 { return fmt.Errorf("ingest pcm sampleRateHz must be > 0") } if (c.Ingest.Stdin.Channels != 1 && c.Ingest.Stdin.Channels != 2) || (c.Ingest.HTTPRaw.Channels != 1 && c.Ingest.HTTPRaw.Channels != 2) { return fmt.Errorf("ingest pcm channels must be 1 or 2") } if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" { return fmt.Errorf("ingest pcm format must be s16le") } if ingestKind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" { return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast") } if ingestKind == "srt" && strings.TrimSpace(c.Ingest.SRT.URL) == "" { return fmt.Errorf("ingest.srt.url is required when ingest.kind=srt") } if ingestKind == "aes67" || ingestKind == "aoip" || ingestKind == "aoip-rtp" { hasSDP := strings.TrimSpace(c.Ingest.AES67.SDP) != "" hasSDPPath := strings.TrimSpace(c.Ingest.AES67.SDPPath) != "" discoveryEnabled := c.Ingest.AES67.Discovery.Enabled || strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) != "" if hasSDP && hasSDPPath { return fmt.Errorf("ingest.aes67.sdp and ingest.aes67.sdpPath are mutually exclusive") } if !hasSDP && !hasSDPPath { if strings.TrimSpace(c.Ingest.AES67.MulticastGroup) == "" && !discoveryEnabled { return fmt.Errorf("ingest.aes67.multicastGroup is required when ingest.kind=%s", ingestKind) } if (c.Ingest.AES67.Port <= 0 || c.Ingest.AES67.Port > 65535) && !discoveryEnabled { return fmt.Errorf("ingest.aes67.port must be 1..65535") } } if c.Ingest.AES67.Discovery.TimeoutMs < 0 { return fmt.Errorf("ingest.aes67.discovery.timeoutMs must be >= 0") } if c.Ingest.AES67.Discovery.SAPPort < 0 || c.Ingest.AES67.Discovery.SAPPort > 65535 { return fmt.Errorf("ingest.aes67.discovery.sapPort must be 0..65535") } if discoveryEnabled && strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) == "" { return fmt.Errorf("ingest.aes67.discovery.streamName is required when discovery is enabled") } if discoveryEnabled && c.Ingest.AES67.Port > 65535 { return fmt.Errorf("ingest.aes67.port must be 1..65535") } if c.Ingest.AES67.PayloadType < 0 || c.Ingest.AES67.PayloadType > 127 { return fmt.Errorf("ingest.aes67.payloadType must be 0..127") } if c.Ingest.AES67.SampleRateHz <= 0 { return fmt.Errorf("ingest.aes67.sampleRateHz must be > 0") } if c.Ingest.AES67.Channels != 1 && c.Ingest.AES67.Channels != 2 { return fmt.Errorf("ingest.aes67.channels must be 1 or 2") } if strings.ToUpper(strings.TrimSpace(c.Ingest.AES67.Encoding)) != "L24" { return fmt.Errorf("ingest.aes67.encoding must be L24") } if c.Ingest.AES67.PacketTimeMs <= 0 { return fmt.Errorf("ingest.aes67.packetTimeMs must be > 0") } if c.Ingest.AES67.JitterDepthPackets < 1 { return fmt.Errorf("ingest.aes67.jitterDepthPackets must be >= 1") } if c.Ingest.AES67.ReadBufferBytes < 0 { return fmt.Errorf("ingest.aes67.readBufferBytes must be >= 0") } } switch strings.ToLower(strings.TrimSpace(c.Ingest.SRT.Mode)) { case "", "listener", "caller", "rendezvous": default: return fmt.Errorf("ingest.srt.mode unsupported: %s", c.Ingest.SRT.Mode) } if c.Ingest.SRT.SampleRateHz <= 0 { return fmt.Errorf("ingest.srt.sampleRateHz must be > 0") } if c.Ingest.SRT.Channels != 1 && c.Ingest.SRT.Channels != 2 { return fmt.Errorf("ingest.srt.channels must be 1 or 2") } switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) { case "", "auto", "native", "ffmpeg", "fallback": default: return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder) } if c.Ingest.Icecast.RadioText.MaxLen < 0 || c.Ingest.Icecast.RadioText.MaxLen > 64 { return fmt.Errorf("ingest.icecast.radioText.maxLen out of range (0-64)") } // BUG-D fix: validate PI whenever non-empty, not only when RDS is enabled. // An invalid PI stored in config causes a silent failure when RDS is later // enabled via live-patch. if strings.TrimSpace(c.RDS.PI) != "" { if _, err := ParsePI(c.RDS.PI); err != nil { return fmt.Errorf("rds config: %w", err) } } else if c.RDS.Enabled { return fmt.Errorf("rds.pi is required when rds.enabled is true") } if c.RDS.PTY < 0 || c.RDS.PTY > 31 { return fmt.Errorf("rds.pty out of range (0-31)") } if len(c.RDS.PS) > 8 { return fmt.Errorf("rds.ps must be <= 8 characters") } if len(c.RDS.RadioText) > 64 { return fmt.Errorf("rds.radioText must be <= 64 characters") } 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) }