Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

244 lignes
7.4KB

  1. package control
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "sync"
  6. "github.com/jan/fm-rds-tx/internal/config"
  7. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  8. "github.com/jan/fm-rds-tx/internal/platform"
  9. )
  10. // TXController is an optional interface the Server uses to start/stop TX
  11. // and apply live config changes.
  12. type TXController interface {
  13. StartTX() error
  14. StopTX() error
  15. TXStats() map[string]any
  16. UpdateConfig(patch LivePatch) error
  17. }
  18. // LivePatch mirrors the patchable fields from ConfigPatch for the engine.
  19. // nil = no change.
  20. type LivePatch struct {
  21. FrequencyMHz *float64
  22. OutputDrive *float64
  23. StereoEnabled *bool
  24. PilotLevel *float64
  25. RDSInjection *float64
  26. RDSEnabled *bool
  27. LimiterEnabled *bool
  28. LimiterCeiling *float64
  29. PS *string
  30. RadioText *string
  31. }
  32. type Server struct {
  33. mu sync.RWMutex
  34. cfg config.Config
  35. tx TXController
  36. drv platform.SoapyDriver // optional, for runtime stats
  37. }
  38. type ConfigPatch struct {
  39. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  40. OutputDrive *float64 `json:"outputDrive,omitempty"`
  41. StereoEnabled *bool `json:"stereoEnabled,omitempty"`
  42. PilotLevel *float64 `json:"pilotLevel,omitempty"`
  43. RDSInjection *float64 `json:"rdsInjection,omitempty"`
  44. RDSEnabled *bool `json:"rdsEnabled,omitempty"`
  45. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  46. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  47. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  48. PS *string `json:"ps,omitempty"`
  49. RadioText *string `json:"radioText,omitempty"`
  50. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  51. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  52. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  53. }
  54. func NewServer(cfg config.Config) *Server {
  55. return &Server{cfg: cfg}
  56. }
  57. func (s *Server) SetTXController(tx TXController) {
  58. s.mu.Lock()
  59. s.tx = tx
  60. s.mu.Unlock()
  61. }
  62. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  63. s.mu.Lock()
  64. s.drv = drv
  65. s.mu.Unlock()
  66. }
  67. func (s *Server) Handler() http.Handler {
  68. mux := http.NewServeMux()
  69. mux.HandleFunc("/healthz", s.handleHealth)
  70. mux.HandleFunc("/status", s.handleStatus)
  71. mux.HandleFunc("/dry-run", s.handleDryRun)
  72. mux.HandleFunc("/config", s.handleConfig)
  73. mux.HandleFunc("/runtime", s.handleRuntime)
  74. mux.HandleFunc("/tx/start", s.handleTXStart)
  75. mux.HandleFunc("/tx/stop", s.handleTXStop)
  76. return mux
  77. }
  78. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  79. w.Header().Set("Content-Type", "application/json")
  80. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  81. }
  82. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  83. s.mu.RLock()
  84. cfg := s.cfg
  85. s.mu.RUnlock()
  86. w.Header().Set("Content-Type", "application/json")
  87. _ = json.NewEncoder(w).Encode(map[string]any{
  88. "service": "fm-rds-tx",
  89. "backend": cfg.Backend.Kind,
  90. "frequencyMHz": cfg.FM.FrequencyMHz,
  91. "stereoEnabled": cfg.FM.StereoEnabled,
  92. "rdsEnabled": cfg.RDS.Enabled,
  93. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  94. "limiterEnabled": cfg.FM.LimiterEnabled,
  95. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  96. })
  97. }
  98. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  99. s.mu.RLock()
  100. drv := s.drv
  101. tx := s.tx
  102. s.mu.RUnlock()
  103. result := map[string]any{}
  104. if drv != nil {
  105. result["driver"] = drv.Stats()
  106. }
  107. if tx != nil {
  108. result["engine"] = tx.TXStats()
  109. }
  110. w.Header().Set("Content-Type", "application/json")
  111. _ = json.NewEncoder(w).Encode(result)
  112. }
  113. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  114. if r.Method != http.MethodPost {
  115. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  116. return
  117. }
  118. s.mu.RLock()
  119. tx := s.tx
  120. s.mu.RUnlock()
  121. if tx == nil {
  122. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  123. return
  124. }
  125. if err := tx.StartTX(); err != nil {
  126. http.Error(w, err.Error(), http.StatusConflict)
  127. return
  128. }
  129. w.Header().Set("Content-Type", "application/json")
  130. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  131. }
  132. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  133. if r.Method != http.MethodPost {
  134. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  135. return
  136. }
  137. s.mu.RLock()
  138. tx := s.tx
  139. s.mu.RUnlock()
  140. if tx == nil {
  141. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  142. return
  143. }
  144. if err := tx.StopTX(); err != nil {
  145. http.Error(w, err.Error(), http.StatusInternalServerError)
  146. return
  147. }
  148. w.Header().Set("Content-Type", "application/json")
  149. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
  150. }
  151. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  152. s.mu.RLock()
  153. cfg := s.cfg
  154. s.mu.RUnlock()
  155. w.Header().Set("Content-Type", "application/json")
  156. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  157. }
  158. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  159. switch r.Method {
  160. case http.MethodGet:
  161. s.mu.RLock()
  162. cfg := s.cfg
  163. s.mu.RUnlock()
  164. w.Header().Set("Content-Type", "application/json")
  165. _ = json.NewEncoder(w).Encode(cfg)
  166. case http.MethodPost:
  167. var patch ConfigPatch
  168. if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
  169. http.Error(w, err.Error(), http.StatusBadRequest)
  170. return
  171. }
  172. // Update the server's config snapshot (for GET /config and /status)
  173. s.mu.Lock()
  174. next := s.cfg
  175. if patch.FrequencyMHz != nil { next.FM.FrequencyMHz = *patch.FrequencyMHz }
  176. if patch.OutputDrive != nil { next.FM.OutputDrive = *patch.OutputDrive }
  177. if patch.ToneLeftHz != nil { next.Audio.ToneLeftHz = *patch.ToneLeftHz }
  178. if patch.ToneRightHz != nil { next.Audio.ToneRightHz = *patch.ToneRightHz }
  179. if patch.ToneAmplitude != nil { next.Audio.ToneAmplitude = *patch.ToneAmplitude }
  180. if patch.PS != nil { next.RDS.PS = *patch.PS }
  181. if patch.RadioText != nil { next.RDS.RadioText = *patch.RadioText }
  182. if patch.PreEmphasisTauUS != nil { next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS }
  183. if patch.StereoEnabled != nil { next.FM.StereoEnabled = *patch.StereoEnabled }
  184. if patch.LimiterEnabled != nil { next.FM.LimiterEnabled = *patch.LimiterEnabled }
  185. if patch.LimiterCeiling != nil { next.FM.LimiterCeiling = *patch.LimiterCeiling }
  186. if patch.RDSEnabled != nil { next.RDS.Enabled = *patch.RDSEnabled }
  187. if patch.PilotLevel != nil { next.FM.PilotLevel = *patch.PilotLevel }
  188. if patch.RDSInjection != nil { next.FM.RDSInjection = *patch.RDSInjection }
  189. if err := next.Validate(); err != nil {
  190. s.mu.Unlock()
  191. http.Error(w, err.Error(), http.StatusBadRequest)
  192. return
  193. }
  194. s.cfg = next
  195. tx := s.tx
  196. s.mu.Unlock()
  197. // Forward live-patchable params to running engine (if active)
  198. if tx != nil {
  199. lp := LivePatch{
  200. FrequencyMHz: patch.FrequencyMHz,
  201. OutputDrive: patch.OutputDrive,
  202. StereoEnabled: patch.StereoEnabled,
  203. PilotLevel: patch.PilotLevel,
  204. RDSInjection: patch.RDSInjection,
  205. RDSEnabled: patch.RDSEnabled,
  206. LimiterEnabled: patch.LimiterEnabled,
  207. LimiterCeiling: patch.LimiterCeiling,
  208. PS: patch.PS,
  209. RadioText: patch.RadioText,
  210. }
  211. if err := tx.UpdateConfig(lp); err != nil {
  212. http.Error(w, err.Error(), http.StatusBadRequest)
  213. return
  214. }
  215. }
  216. w.Header().Set("Content-Type", "application/json")
  217. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": tx != nil})
  218. default:
  219. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  220. }
  221. }