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

178 строки
5.6KB

  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. }
  16. type AudioConfig struct {
  17. InputPath string `json:"inputPath"`
  18. InputSampleRate int `json:"inputSampleRate"` // sample rate for WAV/tone source
  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"` // linear injection level in composite (e.g. 0.1 = 10%)
  35. RDSInjection float64 `json:"rdsInjection"` // linear injection level in composite (e.g. 0.05 = 5%)
  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. }
  44. type BackendConfig struct {
  45. Kind string `json:"kind"`
  46. Device string `json:"device"`
  47. OutputPath string `json:"outputPath"`
  48. DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz
  49. }
  50. type ControlConfig struct {
  51. ListenAddress string `json:"listenAddress"`
  52. }
  53. func Default() Config {
  54. return Config{
  55. Audio: AudioConfig{InputSampleRate: 48000, Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4},
  56. RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0},
  57. FM: FMConfig{
  58. FrequencyMHz: 100.0,
  59. StereoEnabled: true,
  60. PilotLevel: 0.1,
  61. RDSInjection: 0.05,
  62. PreEmphasisTauUS: 50,
  63. OutputDrive: 0.5,
  64. CompositeRateHz: 228000,
  65. MaxDeviationHz: 75000,
  66. LimiterEnabled: true,
  67. LimiterCeiling: 1.0,
  68. FMModulationEnabled: true,
  69. },
  70. Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
  71. Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
  72. }
  73. }
  74. // ParsePI parses a hex PI code string. Returns an error for invalid input.
  75. func ParsePI(pi string) (uint16, error) {
  76. trimmed := strings.TrimSpace(pi)
  77. if trimmed == "" {
  78. return 0, fmt.Errorf("rds.pi is required")
  79. }
  80. trimmed = strings.TrimPrefix(trimmed, "0x")
  81. trimmed = strings.TrimPrefix(trimmed, "0X")
  82. v, err := strconv.ParseUint(trimmed, 16, 16)
  83. if err != nil {
  84. return 0, fmt.Errorf("invalid rds.pi: %q", pi)
  85. }
  86. return uint16(v), nil
  87. }
  88. func Load(path string) (Config, error) {
  89. cfg := Default()
  90. if path == "" {
  91. return cfg, cfg.Validate()
  92. }
  93. data, err := os.ReadFile(path)
  94. if err != nil {
  95. return Config{}, err
  96. }
  97. if err := json.Unmarshal(data, &cfg); err != nil {
  98. return Config{}, err
  99. }
  100. return cfg, cfg.Validate()
  101. }
  102. func (c Config) Validate() error {
  103. if c.Audio.InputSampleRate < 8000 || c.Audio.InputSampleRate > 384000 {
  104. return fmt.Errorf("audio.inputSampleRate out of range")
  105. }
  106. if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
  107. return fmt.Errorf("audio.gain out of range")
  108. }
  109. if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 {
  110. return fmt.Errorf("audio tone frequencies must be positive")
  111. }
  112. if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 {
  113. return fmt.Errorf("audio.toneAmplitude out of range")
  114. }
  115. if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 {
  116. return fmt.Errorf("fm.frequencyMHz out of range")
  117. }
  118. if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 {
  119. return fmt.Errorf("fm.pilotLevel out of range")
  120. }
  121. if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 {
  122. return fmt.Errorf("fm.rdsInjection out of range")
  123. }
  124. if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 1 {
  125. return fmt.Errorf("fm.outputDrive out of range")
  126. }
  127. if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 {
  128. return fmt.Errorf("fm.compositeRateHz out of range")
  129. }
  130. if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 {
  131. return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)")
  132. }
  133. if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 {
  134. return fmt.Errorf("fm.maxDeviationHz out of range")
  135. }
  136. if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 {
  137. return fmt.Errorf("fm.limiterCeiling out of range")
  138. }
  139. if c.Backend.Kind == "" {
  140. return fmt.Errorf("backend.kind is required")
  141. }
  142. if c.Backend.DeviceSampleRateHz < 0 {
  143. return fmt.Errorf("backend.deviceSampleRateHz must be >= 0")
  144. }
  145. if c.Control.ListenAddress == "" {
  146. return fmt.Errorf("control.listenAddress is required")
  147. }
  148. // Fail-loud PI validation
  149. if c.RDS.Enabled {
  150. if _, err := ParsePI(c.RDS.PI); err != nil {
  151. return fmt.Errorf("rds config: %w", err)
  152. }
  153. }
  154. if c.RDS.PTY < 0 || c.RDS.PTY > 31 {
  155. return fmt.Errorf("rds.pty out of range (0-31)")
  156. }
  157. return nil
  158. }
  159. // EffectiveDeviceRate returns the device sample rate, falling back to composite rate.
  160. func (c Config) EffectiveDeviceRate() float64 {
  161. if c.Backend.DeviceSampleRateHz > 0 {
  162. return c.Backend.DeviceSampleRateHz
  163. }
  164. return float64(c.FM.CompositeRateHz)
  165. }