Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

412 Zeilen
11KB

  1. package control
  2. import (
  3. _ "embed"
  4. "encoding/json"
  5. "io"
  6. "mime"
  7. "net/http"
  8. "strings"
  9. "sync"
  10. "github.com/jan/fm-rds-tx/internal/audio"
  11. "github.com/jan/fm-rds-tx/internal/config"
  12. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  13. "github.com/jan/fm-rds-tx/internal/platform"
  14. )
  15. //go:embed ui.html
  16. var uiHTML []byte
  17. // TXController is an optional interface the Server uses to start/stop TX
  18. // and apply live config changes.
  19. type TXController interface {
  20. StartTX() error
  21. StopTX() error
  22. TXStats() map[string]any
  23. UpdateConfig(patch LivePatch) error
  24. ResetFault() error
  25. }
  26. // LivePatch mirrors the patchable fields from ConfigPatch for the engine.
  27. // nil = no change.
  28. type LivePatch struct {
  29. FrequencyMHz *float64
  30. OutputDrive *float64
  31. StereoEnabled *bool
  32. PilotLevel *float64
  33. RDSInjection *float64
  34. RDSEnabled *bool
  35. LimiterEnabled *bool
  36. LimiterCeiling *float64
  37. PS *string
  38. RadioText *string
  39. }
  40. type Server struct {
  41. mu sync.RWMutex
  42. cfg config.Config
  43. tx TXController
  44. drv platform.SoapyDriver // optional, for runtime stats
  45. streamSrc *audio.StreamSource // optional, for live audio ingest
  46. }
  47. const (
  48. maxConfigBodyBytes = 64 << 10 // 64 KiB
  49. configContentTypeHeader = "application/json"
  50. )
  51. func isJSONContentType(r *http.Request) bool {
  52. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  53. if ct == "" {
  54. return false
  55. }
  56. mediaType, _, err := mime.ParseMediaType(ct)
  57. if err != nil {
  58. return false
  59. }
  60. return strings.EqualFold(mediaType, configContentTypeHeader)
  61. }
  62. type ConfigPatch struct {
  63. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  64. OutputDrive *float64 `json:"outputDrive,omitempty"`
  65. StereoEnabled *bool `json:"stereoEnabled,omitempty"`
  66. PilotLevel *float64 `json:"pilotLevel,omitempty"`
  67. RDSInjection *float64 `json:"rdsInjection,omitempty"`
  68. RDSEnabled *bool `json:"rdsEnabled,omitempty"`
  69. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  70. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  71. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  72. PS *string `json:"ps,omitempty"`
  73. RadioText *string `json:"radioText,omitempty"`
  74. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  75. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  76. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  77. }
  78. func NewServer(cfg config.Config) *Server {
  79. return &Server{cfg: cfg}
  80. }
  81. func (s *Server) SetTXController(tx TXController) {
  82. s.mu.Lock()
  83. s.tx = tx
  84. s.mu.Unlock()
  85. }
  86. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  87. s.mu.Lock()
  88. s.drv = drv
  89. s.mu.Unlock()
  90. }
  91. func (s *Server) SetStreamSource(src *audio.StreamSource) {
  92. s.mu.Lock()
  93. s.streamSrc = src
  94. s.mu.Unlock()
  95. }
  96. func (s *Server) Handler() http.Handler {
  97. mux := http.NewServeMux()
  98. mux.HandleFunc("/", s.handleUI)
  99. mux.HandleFunc("/healthz", s.handleHealth)
  100. mux.HandleFunc("/status", s.handleStatus)
  101. mux.HandleFunc("/dry-run", s.handleDryRun)
  102. mux.HandleFunc("/config", s.handleConfig)
  103. mux.HandleFunc("/runtime", s.handleRuntime)
  104. mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
  105. mux.HandleFunc("/tx/start", s.handleTXStart)
  106. mux.HandleFunc("/tx/stop", s.handleTXStop)
  107. mux.HandleFunc("/audio/stream", s.handleAudioStream)
  108. return mux
  109. }
  110. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  111. w.Header().Set("Content-Type", "application/json")
  112. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  113. }
  114. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
  115. if r.URL.Path != "/" {
  116. http.NotFound(w, r)
  117. return
  118. }
  119. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  120. w.Header().Set("Cache-Control", "no-cache")
  121. w.Write(uiHTML)
  122. }
  123. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  124. s.mu.RLock()
  125. cfg := s.cfg
  126. tx := s.tx
  127. s.mu.RUnlock()
  128. status := map[string]any{
  129. "service": "fm-rds-tx",
  130. "backend": cfg.Backend.Kind,
  131. "frequencyMHz": cfg.FM.FrequencyMHz,
  132. "stereoEnabled": cfg.FM.StereoEnabled,
  133. "rdsEnabled": cfg.RDS.Enabled,
  134. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  135. "limiterEnabled": cfg.FM.LimiterEnabled,
  136. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  137. }
  138. if tx != nil {
  139. if stats := tx.TXStats(); stats != nil {
  140. if ri, ok := stats["runtimeIndicator"]; ok {
  141. status["runtimeIndicator"] = ri
  142. }
  143. if alert, ok := stats["runtimeAlert"]; ok {
  144. status["runtimeAlert"] = alert
  145. }
  146. if queue, ok := stats["queue"]; ok {
  147. status["queue"] = queue
  148. }
  149. if runtimeState, ok := stats["state"]; ok {
  150. status["runtimeState"] = runtimeState
  151. }
  152. }
  153. }
  154. w.Header().Set("Content-Type", "application/json")
  155. _ = json.NewEncoder(w).Encode(status)
  156. }
  157. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  158. s.mu.RLock()
  159. drv := s.drv
  160. tx := s.tx
  161. stream := s.streamSrc
  162. s.mu.RUnlock()
  163. result := map[string]any{}
  164. if drv != nil {
  165. result["driver"] = drv.Stats()
  166. }
  167. if tx != nil {
  168. result["engine"] = tx.TXStats()
  169. }
  170. if stream != nil {
  171. result["audioStream"] = stream.Stats()
  172. }
  173. w.Header().Set("Content-Type", "application/json")
  174. _ = json.NewEncoder(w).Encode(result)
  175. }
  176. func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) {
  177. if r.Method != http.MethodPost {
  178. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  179. return
  180. }
  181. s.mu.RLock()
  182. tx := s.tx
  183. s.mu.RUnlock()
  184. if tx == nil {
  185. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  186. return
  187. }
  188. if err := tx.ResetFault(); err != nil {
  189. http.Error(w, err.Error(), http.StatusConflict)
  190. return
  191. }
  192. w.Header().Set("Content-Type", "application/json")
  193. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  194. }
  195. // handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes
  196. // it into the live audio ring buffer. Use with:
  197. // curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
  198. // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
  199. func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
  200. if r.Method != http.MethodPost {
  201. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  202. return
  203. }
  204. s.mu.RLock()
  205. stream := s.streamSrc
  206. s.mu.RUnlock()
  207. if stream == nil {
  208. http.Error(w, "audio stream not configured (use --audio-stdin or --audio-http)", http.StatusServiceUnavailable)
  209. return
  210. }
  211. // Read body in chunks and push to ring buffer
  212. buf := make([]byte, 32768)
  213. totalFrames := 0
  214. for {
  215. n, err := r.Body.Read(buf)
  216. if n > 0 {
  217. totalFrames += stream.WritePCM(buf[:n])
  218. }
  219. if err != nil {
  220. if err == io.EOF {
  221. break
  222. }
  223. http.Error(w, err.Error(), http.StatusInternalServerError)
  224. return
  225. }
  226. }
  227. w.Header().Set("Content-Type", "application/json")
  228. _ = json.NewEncoder(w).Encode(map[string]any{
  229. "ok": true,
  230. "frames": totalFrames,
  231. "stats": stream.Stats(),
  232. })
  233. }
  234. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  235. if r.Method != http.MethodPost {
  236. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  237. return
  238. }
  239. s.mu.RLock()
  240. tx := s.tx
  241. s.mu.RUnlock()
  242. if tx == nil {
  243. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  244. return
  245. }
  246. if err := tx.StartTX(); err != nil {
  247. http.Error(w, err.Error(), http.StatusConflict)
  248. return
  249. }
  250. w.Header().Set("Content-Type", "application/json")
  251. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  252. }
  253. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  254. if r.Method != http.MethodPost {
  255. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  256. return
  257. }
  258. s.mu.RLock()
  259. tx := s.tx
  260. s.mu.RUnlock()
  261. if tx == nil {
  262. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  263. return
  264. }
  265. if err := tx.StopTX(); err != nil {
  266. http.Error(w, err.Error(), http.StatusInternalServerError)
  267. return
  268. }
  269. w.Header().Set("Content-Type", "application/json")
  270. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
  271. }
  272. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  273. s.mu.RLock()
  274. cfg := s.cfg
  275. s.mu.RUnlock()
  276. w.Header().Set("Content-Type", "application/json")
  277. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  278. }
  279. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  280. switch r.Method {
  281. case http.MethodGet:
  282. s.mu.RLock()
  283. cfg := s.cfg
  284. s.mu.RUnlock()
  285. w.Header().Set("Content-Type", "application/json")
  286. _ = json.NewEncoder(w).Encode(cfg)
  287. case http.MethodPost:
  288. if !isJSONContentType(r) {
  289. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  290. return
  291. }
  292. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  293. var patch ConfigPatch
  294. if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
  295. statusCode := http.StatusBadRequest
  296. if strings.Contains(err.Error(), "http: request body too large") {
  297. statusCode = http.StatusRequestEntityTooLarge
  298. }
  299. http.Error(w, err.Error(), statusCode)
  300. return
  301. }
  302. // Update the server's config snapshot (for GET /config and /status)
  303. s.mu.Lock()
  304. next := s.cfg
  305. if patch.FrequencyMHz != nil {
  306. next.FM.FrequencyMHz = *patch.FrequencyMHz
  307. }
  308. if patch.OutputDrive != nil {
  309. next.FM.OutputDrive = *patch.OutputDrive
  310. }
  311. if patch.ToneLeftHz != nil {
  312. next.Audio.ToneLeftHz = *patch.ToneLeftHz
  313. }
  314. if patch.ToneRightHz != nil {
  315. next.Audio.ToneRightHz = *patch.ToneRightHz
  316. }
  317. if patch.ToneAmplitude != nil {
  318. next.Audio.ToneAmplitude = *patch.ToneAmplitude
  319. }
  320. if patch.PS != nil {
  321. next.RDS.PS = *patch.PS
  322. }
  323. if patch.RadioText != nil {
  324. next.RDS.RadioText = *patch.RadioText
  325. }
  326. if patch.PreEmphasisTauUS != nil {
  327. next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
  328. }
  329. if patch.StereoEnabled != nil {
  330. next.FM.StereoEnabled = *patch.StereoEnabled
  331. }
  332. if patch.LimiterEnabled != nil {
  333. next.FM.LimiterEnabled = *patch.LimiterEnabled
  334. }
  335. if patch.LimiterCeiling != nil {
  336. next.FM.LimiterCeiling = *patch.LimiterCeiling
  337. }
  338. if patch.RDSEnabled != nil {
  339. next.RDS.Enabled = *patch.RDSEnabled
  340. }
  341. if patch.PilotLevel != nil {
  342. next.FM.PilotLevel = *patch.PilotLevel
  343. }
  344. if patch.RDSInjection != nil {
  345. next.FM.RDSInjection = *patch.RDSInjection
  346. }
  347. if err := next.Validate(); err != nil {
  348. s.mu.Unlock()
  349. http.Error(w, err.Error(), http.StatusBadRequest)
  350. return
  351. }
  352. lp := LivePatch{
  353. FrequencyMHz: patch.FrequencyMHz,
  354. OutputDrive: patch.OutputDrive,
  355. StereoEnabled: patch.StereoEnabled,
  356. PilotLevel: patch.PilotLevel,
  357. RDSInjection: patch.RDSInjection,
  358. RDSEnabled: patch.RDSEnabled,
  359. LimiterEnabled: patch.LimiterEnabled,
  360. LimiterCeiling: patch.LimiterCeiling,
  361. PS: patch.PS,
  362. RadioText: patch.RadioText,
  363. }
  364. tx := s.tx
  365. if tx != nil {
  366. if err := tx.UpdateConfig(lp); err != nil {
  367. s.mu.Unlock()
  368. http.Error(w, err.Error(), http.StatusBadRequest)
  369. return
  370. }
  371. }
  372. s.cfg = next
  373. live := tx != nil
  374. s.mu.Unlock()
  375. w.Header().Set("Content-Type", "application/json")
  376. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live})
  377. default:
  378. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  379. }
  380. }