Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

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