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

202 строки
6.5KB

  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. }
  17. type AudioConfig struct {
  18. InputPath string `json:"inputPath"`
  19. Gain float64 `json:"gain"`
  20. ToneLeftHz float64 `json:"toneLeftHz"`
  21. ToneRightHz float64 `json:"toneRightHz"`
  22. ToneAmplitude float64 `json:"toneAmplitude"`
  23. }
  24. type RDSConfig struct {
  25. Enabled bool `json:"enabled"`
  26. PI string `json:"pi"`
  27. PS string `json:"ps"`
  28. RadioText string `json:"radioText"`
  29. PTY int `json:"pty"`
  30. }
  31. type FMConfig struct {
  32. FrequencyMHz float64 `json:"frequencyMHz"`
  33. StereoEnabled bool `json:"stereoEnabled"`
  34. PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard)
  35. RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical)
  36. PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off
  37. OutputDrive float64 `json:"outputDrive"`
  38. CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate
  39. MaxDeviationHz float64 `json:"maxDeviationHz"`
  40. LimiterEnabled bool `json:"limiterEnabled"`
  41. LimiterCeiling float64 `json:"limiterCeiling"`
  42. FMModulationEnabled bool `json:"fmModulationEnabled"`
  43. MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0)
  44. BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement)
  45. BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed)
  46. }
  47. type BackendConfig struct {
  48. Kind string `json:"kind"`
  49. Driver string `json:"driver,omitempty"`
  50. Device string `json:"device"`
  51. URI string `json:"uri,omitempty"`
  52. DeviceArgs map[string]string `json:"deviceArgs,omitempty"`
  53. OutputPath string `json:"outputPath"`
  54. DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz
  55. }
  56. type ControlConfig struct {
  57. ListenAddress string `json:"listenAddress"`
  58. }
  59. type RuntimeConfig struct {
  60. FrameQueueCapacity int `json:"frameQueueCapacity"`
  61. }
  62. func Default() Config {
  63. return Config{
  64. Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4},
  65. RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0},
  66. FM: FMConfig{
  67. FrequencyMHz: 100.0,
  68. StereoEnabled: true,
  69. PilotLevel: 0.09,
  70. RDSInjection: 0.04,
  71. PreEmphasisTauUS: 50,
  72. OutputDrive: 0.5,
  73. CompositeRateHz: 228000,
  74. MaxDeviationHz: 75000,
  75. LimiterEnabled: true,
  76. LimiterCeiling: 1.0,
  77. FMModulationEnabled: true,
  78. MpxGain: 1.0,
  79. },
  80. Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
  81. Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
  82. Runtime: RuntimeConfig{FrameQueueCapacity: 3},
  83. }
  84. }
  85. // ParsePI parses a hex PI code string. Returns an error for invalid input.
  86. func ParsePI(pi string) (uint16, error) {
  87. trimmed := strings.TrimSpace(pi)
  88. if trimmed == "" {
  89. return 0, fmt.Errorf("rds.pi is required")
  90. }
  91. trimmed = strings.TrimPrefix(trimmed, "0x")
  92. trimmed = strings.TrimPrefix(trimmed, "0X")
  93. v, err := strconv.ParseUint(trimmed, 16, 16)
  94. if err != nil {
  95. return 0, fmt.Errorf("invalid rds.pi: %q", pi)
  96. }
  97. return uint16(v), nil
  98. }
  99. func Load(path string) (Config, error) {
  100. cfg := Default()
  101. if path == "" {
  102. return cfg, cfg.Validate()
  103. }
  104. data, err := os.ReadFile(path)
  105. if err != nil {
  106. return Config{}, err
  107. }
  108. if err := json.Unmarshal(data, &cfg); err != nil {
  109. return Config{}, err
  110. }
  111. return cfg, cfg.Validate()
  112. }
  113. func (c Config) Validate() error {
  114. if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
  115. return fmt.Errorf("audio.gain out of range")
  116. }
  117. if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 {
  118. return fmt.Errorf("audio tone frequencies must be positive")
  119. }
  120. if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 {
  121. return fmt.Errorf("audio.toneAmplitude out of range")
  122. }
  123. if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 {
  124. return fmt.Errorf("fm.frequencyMHz out of range")
  125. }
  126. if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 {
  127. return fmt.Errorf("fm.pilotLevel out of range")
  128. }
  129. if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 {
  130. return fmt.Errorf("fm.rdsInjection out of range")
  131. }
  132. if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 {
  133. return fmt.Errorf("fm.outputDrive out of range (0..10)")
  134. }
  135. if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 {
  136. return fmt.Errorf("fm.compositeRateHz out of range")
  137. }
  138. if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 {
  139. return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)")
  140. }
  141. if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 {
  142. return fmt.Errorf("fm.maxDeviationHz out of range")
  143. }
  144. if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 {
  145. return fmt.Errorf("fm.limiterCeiling out of range")
  146. }
  147. if c.FM.MpxGain == 0 {
  148. c.FM.MpxGain = 1.0
  149. } // default if omitted from JSON
  150. if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 {
  151. return fmt.Errorf("fm.mpxGain out of range (0.1..5)")
  152. }
  153. if c.Backend.Kind == "" {
  154. return fmt.Errorf("backend.kind is required")
  155. }
  156. if c.Backend.DeviceSampleRateHz < 0 {
  157. return fmt.Errorf("backend.deviceSampleRateHz must be >= 0")
  158. }
  159. if c.Control.ListenAddress == "" {
  160. return fmt.Errorf("control.listenAddress is required")
  161. }
  162. if c.Runtime.FrameQueueCapacity <= 0 {
  163. return fmt.Errorf("runtime.frameQueueCapacity must be > 0")
  164. }
  165. // Fail-loud PI validation
  166. if c.RDS.Enabled {
  167. if _, err := ParsePI(c.RDS.PI); err != nil {
  168. return fmt.Errorf("rds config: %w", err)
  169. }
  170. }
  171. if c.RDS.PTY < 0 || c.RDS.PTY > 31 {
  172. return fmt.Errorf("rds.pty out of range (0-31)")
  173. }
  174. if len(c.RDS.PS) > 8 {
  175. return fmt.Errorf("rds.ps must be <= 8 characters")
  176. }
  177. if len(c.RDS.RadioText) > 64 {
  178. return fmt.Errorf("rds.radioText must be <= 64 characters")
  179. }
  180. return nil
  181. }
  182. // EffectiveDeviceRate returns the device sample rate, falling back to composite rate.
  183. func (c Config) EffectiveDeviceRate() float64 {
  184. if c.Backend.DeviceSampleRateHz > 0 {
  185. return c.Backend.DeviceSampleRateHz
  186. }
  187. return float64(c.FM.CompositeRateHz)
  188. }