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.

445 lines
16KB

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