Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

473 rindas
17KB

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