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

293 строки
9.8KB

  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. }
  72. type IngestReconnectConfig struct {
  73. Enabled bool `json:"enabled"`
  74. InitialBackoffMs int `json:"initialBackoffMs"`
  75. MaxBackoffMs int `json:"maxBackoffMs"`
  76. }
  77. type IngestPCMConfig struct {
  78. SampleRateHz int `json:"sampleRateHz"`
  79. Channels int `json:"channels"`
  80. Format string `json:"format"`
  81. }
  82. type IngestIcecastConfig struct {
  83. URL string `json:"url"`
  84. Decoder string `json:"decoder"`
  85. }
  86. func Default() Config {
  87. return Config{
  88. Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4},
  89. RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0},
  90. FM: FMConfig{
  91. FrequencyMHz: 100.0,
  92. StereoEnabled: true,
  93. PilotLevel: 0.09,
  94. RDSInjection: 0.04,
  95. PreEmphasisTauUS: 50,
  96. OutputDrive: 0.5,
  97. CompositeRateHz: 228000,
  98. MaxDeviationHz: 75000,
  99. LimiterEnabled: true,
  100. LimiterCeiling: 1.0,
  101. FMModulationEnabled: true,
  102. MpxGain: 1.0,
  103. },
  104. Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
  105. Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
  106. Runtime: RuntimeConfig{FrameQueueCapacity: 3},
  107. Ingest: IngestConfig{
  108. Kind: "none",
  109. PrebufferMs: 1500,
  110. StallTimeoutMs: 3000,
  111. Reconnect: IngestReconnectConfig{
  112. Enabled: true,
  113. InitialBackoffMs: 1000,
  114. MaxBackoffMs: 15000,
  115. },
  116. Stdin: IngestPCMConfig{
  117. SampleRateHz: 44100,
  118. Channels: 2,
  119. Format: "s16le",
  120. },
  121. HTTPRaw: IngestPCMConfig{
  122. SampleRateHz: 44100,
  123. Channels: 2,
  124. Format: "s16le",
  125. },
  126. Icecast: IngestIcecastConfig{
  127. Decoder: "auto",
  128. },
  129. },
  130. }
  131. }
  132. // ParsePI parses a hex PI code string. Returns an error for invalid input.
  133. func ParsePI(pi string) (uint16, error) {
  134. trimmed := strings.TrimSpace(pi)
  135. if trimmed == "" {
  136. return 0, fmt.Errorf("rds.pi is required")
  137. }
  138. trimmed = strings.TrimPrefix(trimmed, "0x")
  139. trimmed = strings.TrimPrefix(trimmed, "0X")
  140. v, err := strconv.ParseUint(trimmed, 16, 16)
  141. if err != nil {
  142. return 0, fmt.Errorf("invalid rds.pi: %q", pi)
  143. }
  144. return uint16(v), nil
  145. }
  146. func Load(path string) (Config, error) {
  147. cfg := Default()
  148. if path == "" {
  149. return cfg, cfg.Validate()
  150. }
  151. data, err := os.ReadFile(path)
  152. if err != nil {
  153. return Config{}, err
  154. }
  155. if err := json.Unmarshal(data, &cfg); err != nil {
  156. return Config{}, err
  157. }
  158. return cfg, cfg.Validate()
  159. }
  160. func (c Config) Validate() error {
  161. if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
  162. return fmt.Errorf("audio.gain out of range")
  163. }
  164. if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 {
  165. return fmt.Errorf("audio tone frequencies must be positive")
  166. }
  167. if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 {
  168. return fmt.Errorf("audio.toneAmplitude out of range")
  169. }
  170. if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 {
  171. return fmt.Errorf("fm.frequencyMHz out of range")
  172. }
  173. if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 {
  174. return fmt.Errorf("fm.pilotLevel out of range")
  175. }
  176. if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 {
  177. return fmt.Errorf("fm.rdsInjection out of range")
  178. }
  179. if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 {
  180. return fmt.Errorf("fm.outputDrive out of range (0..10)")
  181. }
  182. if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 {
  183. return fmt.Errorf("fm.compositeRateHz out of range")
  184. }
  185. if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 {
  186. return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)")
  187. }
  188. if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 {
  189. return fmt.Errorf("fm.maxDeviationHz out of range")
  190. }
  191. if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 {
  192. return fmt.Errorf("fm.limiterCeiling out of range")
  193. }
  194. if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 {
  195. return fmt.Errorf("fm.mpxGain out of range (0.1..5)")
  196. }
  197. if c.Backend.Kind == "" {
  198. return fmt.Errorf("backend.kind is required")
  199. }
  200. if c.Backend.DeviceSampleRateHz < 0 {
  201. return fmt.Errorf("backend.deviceSampleRateHz must be >= 0")
  202. }
  203. if c.Control.ListenAddress == "" {
  204. return fmt.Errorf("control.listenAddress is required")
  205. }
  206. if c.Runtime.FrameQueueCapacity <= 0 {
  207. return fmt.Errorf("runtime.frameQueueCapacity must be > 0")
  208. }
  209. if c.Ingest.Kind == "" {
  210. c.Ingest.Kind = "none"
  211. }
  212. switch strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) {
  213. case "none", "stdin", "stdin-pcm", "http-raw", "icecast":
  214. default:
  215. return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind)
  216. }
  217. if c.Ingest.PrebufferMs < 0 {
  218. return fmt.Errorf("ingest.prebufferMs must be >= 0")
  219. }
  220. if c.Ingest.StallTimeoutMs < 0 {
  221. return fmt.Errorf("ingest.stallTimeoutMs must be >= 0")
  222. }
  223. if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 {
  224. return fmt.Errorf("ingest.reconnect backoff must be >= 0")
  225. }
  226. if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.InitialBackoffMs <= 0 {
  227. return fmt.Errorf("ingest.reconnect.initialBackoffMs must be > 0 when reconnect is enabled")
  228. }
  229. if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.MaxBackoffMs <= 0 {
  230. return fmt.Errorf("ingest.reconnect.maxBackoffMs must be > 0 when reconnect is enabled")
  231. }
  232. if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs {
  233. return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs")
  234. }
  235. if c.Ingest.Stdin.SampleRateHz <= 0 || c.Ingest.HTTPRaw.SampleRateHz <= 0 {
  236. return fmt.Errorf("ingest pcm sampleRateHz must be > 0")
  237. }
  238. if (c.Ingest.Stdin.Channels != 1 && c.Ingest.Stdin.Channels != 2) || (c.Ingest.HTTPRaw.Channels != 1 && c.Ingest.HTTPRaw.Channels != 2) {
  239. return fmt.Errorf("ingest pcm channels must be 1 or 2")
  240. }
  241. if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" {
  242. return fmt.Errorf("ingest pcm format must be s16le")
  243. }
  244. if c.Ingest.Kind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" {
  245. return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast")
  246. }
  247. switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) {
  248. case "", "auto", "native", "ffmpeg", "fallback":
  249. default:
  250. return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder)
  251. }
  252. // Fail-loud PI validation
  253. if c.RDS.Enabled {
  254. if _, err := ParsePI(c.RDS.PI); err != nil {
  255. return fmt.Errorf("rds config: %w", err)
  256. }
  257. }
  258. if c.RDS.PTY < 0 || c.RDS.PTY > 31 {
  259. return fmt.Errorf("rds.pty out of range (0-31)")
  260. }
  261. if len(c.RDS.PS) > 8 {
  262. return fmt.Errorf("rds.ps must be <= 8 characters")
  263. }
  264. if len(c.RDS.RadioText) > 64 {
  265. return fmt.Errorf("rds.radioText must be <= 64 characters")
  266. }
  267. return nil
  268. }
  269. // EffectiveDeviceRate returns the device sample rate, falling back to composite rate.
  270. func (c Config) EffectiveDeviceRate() float64 {
  271. if c.Backend.DeviceSampleRateHz > 0 {
  272. return c.Backend.DeviceSampleRateHz
  273. }
  274. return float64(c.FM.CompositeRateHz)
  275. }