Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

562 line
20KB

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