Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

504 řádky
18KB

  1. package config
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "os"
  6. "path/filepath"
  7. "strconv"
  8. "strings"
  9. )
  10. type Config struct {
  11. Audio AudioConfig `json:"audio"`
  12. RDS RDSConfig `json:"rds"`
  13. FM FMConfig `json:"fm"`
  14. Backend BackendConfig `json:"backend"`
  15. Control ControlConfig `json:"control"`
  16. Runtime RuntimeConfig `json:"runtime"`
  17. Ingest IngestConfig `json:"ingest"`
  18. }
  19. type AudioConfig struct {
  20. InputPath string `json:"inputPath"`
  21. Gain float64 `json:"gain"`
  22. ToneLeftHz float64 `json:"toneLeftHz"`
  23. ToneRightHz float64 `json:"toneRightHz"`
  24. ToneAmplitude float64 `json:"toneAmplitude"`
  25. }
  26. type RDSConfig struct {
  27. Enabled bool `json:"enabled"`
  28. PI string `json:"pi"`
  29. PS string `json:"ps"`
  30. RadioText string `json:"radioText"`
  31. PTY int `json:"pty"`
  32. }
  33. type FMConfig struct {
  34. FrequencyMHz float64 `json:"frequencyMHz"`
  35. StereoEnabled bool `json:"stereoEnabled"`
  36. StereoMode string `json:"stereoMode"` // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial)
  37. PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard)
  38. RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical)
  39. PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off
  40. OutputDrive float64 `json:"outputDrive"`
  41. CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate
  42. MaxDeviationHz float64 `json:"maxDeviationHz"`
  43. LimiterEnabled bool `json:"limiterEnabled"`
  44. LimiterCeiling float64 `json:"limiterCeiling"`
  45. FMModulationEnabled bool `json:"fmModulationEnabled"`
  46. MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0)
  47. BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement)
  48. BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed)
  49. CompositeClipper CompositeClipperConfig `json:"compositeClipper"` // ITU-R SM.1268 iterative composite clipper
  50. }
  51. // CompositeClipperConfig controls the broadcast-grade composite MPX clipper.
  52. // When enabled, replaces the simple clip→notch chain with an iterative
  53. // clip-filter-clip processor, optionally with soft-knee clipping and
  54. // look-ahead peak limiting.
  55. type CompositeClipperConfig struct {
  56. Enabled bool `json:"enabled"` // false = legacy single-pass clip
  57. Iterations int `json:"iterations"` // clip-filter-clip passes (1-5, default 3)
  58. SoftKnee float64 `json:"softKnee"` // soft-clip transition zone (0=hard, 0.15=moderate, 0.3=gentle)
  59. LookaheadMs float64 `json:"lookaheadMs"` // look-ahead delay (0=off, 1.0=typical)
  60. }
  61. type BackendConfig struct {
  62. Kind string `json:"kind"`
  63. Driver string `json:"driver,omitempty"`
  64. Device string `json:"device"`
  65. URI string `json:"uri,omitempty"`
  66. DeviceArgs map[string]string `json:"deviceArgs,omitempty"`
  67. OutputPath string `json:"outputPath"`
  68. DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz
  69. }
  70. type ControlConfig struct {
  71. ListenAddress string `json:"listenAddress"`
  72. }
  73. type RuntimeConfig struct {
  74. FrameQueueCapacity int `json:"frameQueueCapacity"`
  75. }
  76. type IngestConfig struct {
  77. Kind string `json:"kind"`
  78. PrebufferMs int `json:"prebufferMs"`
  79. StallTimeoutMs int `json:"stallTimeoutMs"`
  80. Reconnect IngestReconnectConfig `json:"reconnect"`
  81. Stdin IngestPCMConfig `json:"stdin"`
  82. HTTPRaw IngestPCMConfig `json:"httpRaw"`
  83. Icecast IngestIcecastConfig `json:"icecast"`
  84. SRT IngestSRTConfig `json:"srt"`
  85. AES67 IngestAES67Config `json:"aes67"`
  86. }
  87. type IngestReconnectConfig struct {
  88. Enabled bool `json:"enabled"`
  89. InitialBackoffMs int `json:"initialBackoffMs"`
  90. MaxBackoffMs int `json:"maxBackoffMs"`
  91. }
  92. type IngestPCMConfig struct {
  93. SampleRateHz int `json:"sampleRateHz"`
  94. Channels int `json:"channels"`
  95. Format string `json:"format"`
  96. }
  97. type IngestIcecastConfig struct {
  98. URL string `json:"url"`
  99. Decoder string `json:"decoder"`
  100. RadioText IngestIcecastRadioTextConfig `json:"radioText"`
  101. }
  102. type IngestIcecastRadioTextConfig struct {
  103. Enabled bool `json:"enabled"`
  104. Prefix string `json:"prefix"`
  105. MaxLen int `json:"maxLen"`
  106. OnlyOnChange bool `json:"onlyOnChange"`
  107. }
  108. type IngestSRTConfig struct {
  109. URL string `json:"url"`
  110. Mode string `json:"mode"`
  111. SampleRateHz int `json:"sampleRateHz"`
  112. Channels int `json:"channels"`
  113. }
  114. type IngestAES67Config struct {
  115. SDPPath string `json:"sdpPath"`
  116. SDP string `json:"sdp"`
  117. Discovery IngestAES67DiscoveryConfig `json:"discovery"`
  118. MulticastGroup string `json:"multicastGroup"`
  119. Port int `json:"port"`
  120. InterfaceName string `json:"interfaceName"`
  121. PayloadType int `json:"payloadType"`
  122. SampleRateHz int `json:"sampleRateHz"`
  123. Channels int `json:"channels"`
  124. Encoding string `json:"encoding"`
  125. PacketTimeMs int `json:"packetTimeMs"`
  126. JitterDepthPackets int `json:"jitterDepthPackets"`
  127. ReadBufferBytes int `json:"readBufferBytes"`
  128. }
  129. type IngestAES67DiscoveryConfig struct {
  130. Enabled bool `json:"enabled"`
  131. StreamName string `json:"streamName"`
  132. TimeoutMs int `json:"timeoutMs"`
  133. InterfaceName string `json:"interfaceName"`
  134. SAPGroup string `json:"sapGroup"`
  135. SAPPort int `json:"sapPort"`
  136. }
  137. func Default() Config {
  138. return Config{
  139. // BUG-C fix: tones off by default (was 0.4 — caused unintended audio output).
  140. Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0},
  141. RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0},
  142. FM: FMConfig{
  143. FrequencyMHz: 100.0,
  144. StereoEnabled: true,
  145. StereoMode: "DSB",
  146. PilotLevel: 0.09,
  147. RDSInjection: 0.04,
  148. PreEmphasisTauUS: 50,
  149. OutputDrive: 0.5,
  150. CompositeRateHz: 228000,
  151. MaxDeviationHz: 75000,
  152. LimiterEnabled: true,
  153. LimiterCeiling: 1.0,
  154. FMModulationEnabled: true,
  155. MpxGain: 1.0,
  156. CompositeClipper: CompositeClipperConfig{
  157. Enabled: false,
  158. Iterations: 3,
  159. SoftKnee: 0.15,
  160. LookaheadMs: 1.0,
  161. },
  162. },
  163. Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
  164. Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
  165. Runtime: RuntimeConfig{FrameQueueCapacity: 3},
  166. Ingest: IngestConfig{
  167. Kind: "none",
  168. PrebufferMs: 1500,
  169. StallTimeoutMs: 3000,
  170. Reconnect: IngestReconnectConfig{
  171. Enabled: true,
  172. InitialBackoffMs: 1000,
  173. MaxBackoffMs: 15000,
  174. },
  175. Stdin: IngestPCMConfig{
  176. SampleRateHz: 44100,
  177. Channels: 2,
  178. Format: "s16le",
  179. },
  180. HTTPRaw: IngestPCMConfig{
  181. SampleRateHz: 44100,
  182. Channels: 2,
  183. Format: "s16le",
  184. },
  185. Icecast: IngestIcecastConfig{
  186. Decoder: "auto",
  187. RadioText: IngestIcecastRadioTextConfig{
  188. Enabled: false,
  189. MaxLen: 64,
  190. OnlyOnChange: true,
  191. },
  192. },
  193. SRT: IngestSRTConfig{
  194. Mode: "listener",
  195. SampleRateHz: 48000,
  196. Channels: 2,
  197. },
  198. AES67: IngestAES67Config{
  199. Discovery: IngestAES67DiscoveryConfig{
  200. TimeoutMs: 3000,
  201. },
  202. PayloadType: 97,
  203. SampleRateHz: 48000,
  204. Channels: 2,
  205. Encoding: "L24",
  206. PacketTimeMs: 1,
  207. JitterDepthPackets: 8,
  208. ReadBufferBytes: 1 << 20,
  209. },
  210. },
  211. }
  212. }
  213. // ParsePI parses a hex PI code string. Returns an error for invalid input.
  214. func ParsePI(pi string) (uint16, error) {
  215. trimmed := strings.TrimSpace(pi)
  216. if trimmed == "" {
  217. return 0, fmt.Errorf("rds.pi is required")
  218. }
  219. trimmed = strings.TrimPrefix(trimmed, "0x")
  220. trimmed = strings.TrimPrefix(trimmed, "0X")
  221. v, err := strconv.ParseUint(trimmed, 16, 16)
  222. if err != nil {
  223. return 0, fmt.Errorf("invalid rds.pi: %q", pi)
  224. }
  225. return uint16(v), nil
  226. }
  227. func Load(path string) (Config, error) {
  228. cfg := Default()
  229. if path == "" {
  230. return cfg, cfg.Validate()
  231. }
  232. data, err := os.ReadFile(path)
  233. if err != nil {
  234. return Config{}, err
  235. }
  236. if err := json.Unmarshal(data, &cfg); err != nil {
  237. return Config{}, err
  238. }
  239. return cfg, cfg.Validate()
  240. }
  241. func Save(path string, cfg Config) error {
  242. if strings.TrimSpace(path) == "" {
  243. return fmt.Errorf("config path is required")
  244. }
  245. if err := cfg.Validate(); err != nil {
  246. return err
  247. }
  248. data, err := json.MarshalIndent(cfg, "", " ")
  249. if err != nil {
  250. return err
  251. }
  252. data = append(data, '\n')
  253. // NEW-1 fix: write to a temp file in the same directory, then rename atomically.
  254. // A direct os.WriteFile on the target leaves a corrupt file if the process
  255. // crashes mid-write. os.Rename is atomic on POSIX filesystems.
  256. dir := filepath.Dir(path)
  257. tmp, err := os.CreateTemp(dir, ".fmrtx-config-*.json.tmp")
  258. if err != nil {
  259. return fmt.Errorf("config save: create temp: %w", err)
  260. }
  261. tmpPath := tmp.Name()
  262. if _, err := tmp.Write(data); err != nil {
  263. _ = tmp.Close()
  264. _ = os.Remove(tmpPath)
  265. return fmt.Errorf("config save: write temp: %w", err)
  266. }
  267. if err := tmp.Sync(); err != nil {
  268. _ = tmp.Close()
  269. _ = os.Remove(tmpPath)
  270. return fmt.Errorf("config save: sync temp: %w", err)
  271. }
  272. if err := tmp.Close(); err != nil {
  273. _ = os.Remove(tmpPath)
  274. return fmt.Errorf("config save: close temp: %w", err)
  275. }
  276. if err := os.Rename(tmpPath, path); err != nil {
  277. _ = os.Remove(tmpPath)
  278. return fmt.Errorf("config save: rename: %w", err)
  279. }
  280. return nil
  281. }
  282. func (c Config) Validate() error {
  283. if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
  284. return fmt.Errorf("audio.gain out of range")
  285. }
  286. // BUG-B fix: only enforce positive freq when amplitude is non-zero.
  287. if c.Audio.ToneAmplitude > 0 && (c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0) {
  288. return fmt.Errorf("audio tone frequencies must be positive when toneAmplitude > 0")
  289. }
  290. if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 {
  291. return fmt.Errorf("audio.toneAmplitude out of range")
  292. }
  293. if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 {
  294. return fmt.Errorf("fm.frequencyMHz out of range")
  295. }
  296. if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 {
  297. return fmt.Errorf("fm.pilotLevel out of range")
  298. }
  299. if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 {
  300. return fmt.Errorf("fm.rdsInjection out of range")
  301. }
  302. if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 {
  303. return fmt.Errorf("fm.outputDrive out of range (0..10)")
  304. }
  305. if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 {
  306. return fmt.Errorf("fm.compositeRateHz out of range")
  307. }
  308. if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 {
  309. return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)")
  310. }
  311. if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 {
  312. return fmt.Errorf("fm.maxDeviationHz out of range")
  313. }
  314. if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 {
  315. return fmt.Errorf("fm.limiterCeiling out of range")
  316. }
  317. if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 {
  318. return fmt.Errorf("fm.mpxGain out of range (0.1..5)")
  319. }
  320. if c.FM.CompositeClipper.Enabled {
  321. if c.FM.CompositeClipper.Iterations < 1 || c.FM.CompositeClipper.Iterations > 5 {
  322. return fmt.Errorf("fm.compositeClipper.iterations out of range (1-5)")
  323. }
  324. if c.FM.CompositeClipper.SoftKnee < 0 || c.FM.CompositeClipper.SoftKnee > 0.5 {
  325. return fmt.Errorf("fm.compositeClipper.softKnee out of range (0-0.5)")
  326. }
  327. if c.FM.CompositeClipper.LookaheadMs < 0 || c.FM.CompositeClipper.LookaheadMs > 5 {
  328. return fmt.Errorf("fm.compositeClipper.lookaheadMs out of range (0-5)")
  329. }
  330. }
  331. if c.Backend.Kind == "" {
  332. return fmt.Errorf("backend.kind is required")
  333. }
  334. if c.Backend.DeviceSampleRateHz < 0 {
  335. return fmt.Errorf("backend.deviceSampleRateHz must be >= 0")
  336. }
  337. if c.Control.ListenAddress == "" {
  338. return fmt.Errorf("control.listenAddress is required")
  339. }
  340. if c.Runtime.FrameQueueCapacity <= 0 {
  341. return fmt.Errorf("runtime.frameQueueCapacity must be > 0")
  342. }
  343. if c.Ingest.Kind == "" {
  344. c.Ingest.Kind = "none"
  345. }
  346. ingestKind := strings.ToLower(strings.TrimSpace(c.Ingest.Kind))
  347. switch ingestKind {
  348. case "none", "stdin", "stdin-pcm", "http-raw", "icecast", "srt", "aes67", "aoip", "aoip-rtp":
  349. default:
  350. return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind)
  351. }
  352. if c.Ingest.PrebufferMs < 0 {
  353. return fmt.Errorf("ingest.prebufferMs must be >= 0")
  354. }
  355. if c.Ingest.StallTimeoutMs < 0 {
  356. return fmt.Errorf("ingest.stallTimeoutMs must be >= 0")
  357. }
  358. if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 {
  359. return fmt.Errorf("ingest.reconnect backoff must be >= 0")
  360. }
  361. if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.InitialBackoffMs <= 0 {
  362. return fmt.Errorf("ingest.reconnect.initialBackoffMs must be > 0 when reconnect is enabled")
  363. }
  364. if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.MaxBackoffMs <= 0 {
  365. return fmt.Errorf("ingest.reconnect.maxBackoffMs must be > 0 when reconnect is enabled")
  366. }
  367. if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs {
  368. return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs")
  369. }
  370. if c.Ingest.Stdin.SampleRateHz <= 0 || c.Ingest.HTTPRaw.SampleRateHz <= 0 {
  371. return fmt.Errorf("ingest pcm sampleRateHz must be > 0")
  372. }
  373. if (c.Ingest.Stdin.Channels != 1 && c.Ingest.Stdin.Channels != 2) || (c.Ingest.HTTPRaw.Channels != 1 && c.Ingest.HTTPRaw.Channels != 2) {
  374. return fmt.Errorf("ingest pcm channels must be 1 or 2")
  375. }
  376. if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" {
  377. return fmt.Errorf("ingest pcm format must be s16le")
  378. }
  379. if ingestKind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" {
  380. return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast")
  381. }
  382. if ingestKind == "srt" && strings.TrimSpace(c.Ingest.SRT.URL) == "" {
  383. return fmt.Errorf("ingest.srt.url is required when ingest.kind=srt")
  384. }
  385. if ingestKind == "aes67" || ingestKind == "aoip" || ingestKind == "aoip-rtp" {
  386. hasSDP := strings.TrimSpace(c.Ingest.AES67.SDP) != ""
  387. hasSDPPath := strings.TrimSpace(c.Ingest.AES67.SDPPath) != ""
  388. discoveryEnabled := c.Ingest.AES67.Discovery.Enabled || strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) != ""
  389. if hasSDP && hasSDPPath {
  390. return fmt.Errorf("ingest.aes67.sdp and ingest.aes67.sdpPath are mutually exclusive")
  391. }
  392. if !hasSDP && !hasSDPPath {
  393. if strings.TrimSpace(c.Ingest.AES67.MulticastGroup) == "" && !discoveryEnabled {
  394. return fmt.Errorf("ingest.aes67.multicastGroup is required when ingest.kind=%s", ingestKind)
  395. }
  396. if (c.Ingest.AES67.Port <= 0 || c.Ingest.AES67.Port > 65535) && !discoveryEnabled {
  397. return fmt.Errorf("ingest.aes67.port must be 1..65535")
  398. }
  399. }
  400. if c.Ingest.AES67.Discovery.TimeoutMs < 0 {
  401. return fmt.Errorf("ingest.aes67.discovery.timeoutMs must be >= 0")
  402. }
  403. if c.Ingest.AES67.Discovery.SAPPort < 0 || c.Ingest.AES67.Discovery.SAPPort > 65535 {
  404. return fmt.Errorf("ingest.aes67.discovery.sapPort must be 0..65535")
  405. }
  406. if discoveryEnabled && strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) == "" {
  407. return fmt.Errorf("ingest.aes67.discovery.streamName is required when discovery is enabled")
  408. }
  409. if discoveryEnabled && c.Ingest.AES67.Port > 65535 {
  410. return fmt.Errorf("ingest.aes67.port must be 1..65535")
  411. }
  412. if c.Ingest.AES67.PayloadType < 0 || c.Ingest.AES67.PayloadType > 127 {
  413. return fmt.Errorf("ingest.aes67.payloadType must be 0..127")
  414. }
  415. if c.Ingest.AES67.SampleRateHz <= 0 {
  416. return fmt.Errorf("ingest.aes67.sampleRateHz must be > 0")
  417. }
  418. if c.Ingest.AES67.Channels != 1 && c.Ingest.AES67.Channels != 2 {
  419. return fmt.Errorf("ingest.aes67.channels must be 1 or 2")
  420. }
  421. if strings.ToUpper(strings.TrimSpace(c.Ingest.AES67.Encoding)) != "L24" {
  422. return fmt.Errorf("ingest.aes67.encoding must be L24")
  423. }
  424. if c.Ingest.AES67.PacketTimeMs <= 0 {
  425. return fmt.Errorf("ingest.aes67.packetTimeMs must be > 0")
  426. }
  427. if c.Ingest.AES67.JitterDepthPackets < 1 {
  428. return fmt.Errorf("ingest.aes67.jitterDepthPackets must be >= 1")
  429. }
  430. if c.Ingest.AES67.ReadBufferBytes < 0 {
  431. return fmt.Errorf("ingest.aes67.readBufferBytes must be >= 0")
  432. }
  433. }
  434. switch strings.ToLower(strings.TrimSpace(c.Ingest.SRT.Mode)) {
  435. case "", "listener", "caller", "rendezvous":
  436. default:
  437. return fmt.Errorf("ingest.srt.mode unsupported: %s", c.Ingest.SRT.Mode)
  438. }
  439. if c.Ingest.SRT.SampleRateHz <= 0 {
  440. return fmt.Errorf("ingest.srt.sampleRateHz must be > 0")
  441. }
  442. if c.Ingest.SRT.Channels != 1 && c.Ingest.SRT.Channels != 2 {
  443. return fmt.Errorf("ingest.srt.channels must be 1 or 2")
  444. }
  445. switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) {
  446. case "", "auto", "native", "ffmpeg", "fallback":
  447. default:
  448. return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder)
  449. }
  450. if c.Ingest.Icecast.RadioText.MaxLen < 0 || c.Ingest.Icecast.RadioText.MaxLen > 64 {
  451. return fmt.Errorf("ingest.icecast.radioText.maxLen out of range (0-64)")
  452. }
  453. // BUG-D fix: validate PI whenever non-empty, not only when RDS is enabled.
  454. // An invalid PI stored in config causes a silent failure when RDS is later
  455. // enabled via live-patch.
  456. if strings.TrimSpace(c.RDS.PI) != "" {
  457. if _, err := ParsePI(c.RDS.PI); err != nil {
  458. return fmt.Errorf("rds config: %w", err)
  459. }
  460. } else if c.RDS.Enabled {
  461. return fmt.Errorf("rds.pi is required when rds.enabled is true")
  462. }
  463. if c.RDS.PTY < 0 || c.RDS.PTY > 31 {
  464. return fmt.Errorf("rds.pty out of range (0-31)")
  465. }
  466. if len(c.RDS.PS) > 8 {
  467. return fmt.Errorf("rds.ps must be <= 8 characters")
  468. }
  469. if len(c.RDS.RadioText) > 64 {
  470. return fmt.Errorf("rds.radioText must be <= 64 characters")
  471. }
  472. return nil
  473. }
  474. // EffectiveDeviceRate returns the device sample rate, falling back to composite rate.
  475. func (c Config) EffectiveDeviceRate() float64 {
  476. if c.Backend.DeviceSampleRateHz > 0 {
  477. return c.Backend.DeviceSampleRateHz
  478. }
  479. return float64(c.FM.CompositeRateHz)
  480. }