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