Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

562 linhas
15KB

  1. package control
  2. import (
  3. _ "embed"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "mime"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "github.com/jan/fm-rds-tx/internal/audio"
  13. "github.com/jan/fm-rds-tx/internal/config"
  14. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  15. "github.com/jan/fm-rds-tx/internal/ingest"
  16. "github.com/jan/fm-rds-tx/internal/platform"
  17. )
  18. //go:embed ui.html
  19. var uiHTML []byte
  20. // TXController is an optional interface the Server uses to start/stop TX
  21. // and apply live config changes.
  22. type TXController interface {
  23. StartTX() error
  24. StopTX() error
  25. TXStats() map[string]any
  26. UpdateConfig(patch LivePatch) error
  27. ResetFault() error
  28. }
  29. // LivePatch mirrors the patchable fields from ConfigPatch for the engine.
  30. // nil = no change.
  31. type LivePatch struct {
  32. FrequencyMHz *float64
  33. OutputDrive *float64
  34. StereoEnabled *bool
  35. PilotLevel *float64
  36. RDSInjection *float64
  37. RDSEnabled *bool
  38. LimiterEnabled *bool
  39. LimiterCeiling *float64
  40. PS *string
  41. RadioText *string
  42. }
  43. type Server struct {
  44. mu sync.RWMutex
  45. cfg config.Config
  46. tx TXController
  47. drv platform.SoapyDriver // optional, for runtime stats
  48. streamSrc *audio.StreamSource // optional, for live audio ring stats
  49. audioIngress AudioIngress // optional, for /audio/stream
  50. ingestRt IngestRuntime // optional, for /runtime ingest stats
  51. audit auditCounters
  52. }
  53. type AudioIngress interface {
  54. WritePCM16(data []byte) (int, error)
  55. }
  56. type IngestRuntime interface {
  57. Stats() ingest.Stats
  58. }
  59. type auditEvent string
  60. const (
  61. auditMethodNotAllowed auditEvent = "methodNotAllowed"
  62. auditUnsupportedMediaType auditEvent = "unsupportedMediaType"
  63. auditBodyTooLarge auditEvent = "bodyTooLarge"
  64. auditUnexpectedBody auditEvent = "unexpectedBody"
  65. )
  66. type auditCounters struct {
  67. methodNotAllowed uint64
  68. unsupportedMediaType uint64
  69. bodyTooLarge uint64
  70. unexpectedBody uint64
  71. }
  72. const (
  73. maxConfigBodyBytes = 64 << 10 // 64 KiB
  74. configContentTypeHeader = "application/json"
  75. noBodyErrMsg = "request must not include a body"
  76. audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16"
  77. audioStreamBodyLimitDefault = 512 << 20 // 512 MiB
  78. )
  79. var audioStreamAllowedMediaTypes = []string{
  80. "application/octet-stream",
  81. "audio/l16",
  82. }
  83. var audioStreamBodyLimit = int64(audioStreamBodyLimitDefault) // bytes allowed per /audio/stream request; tests may override.
  84. func isJSONContentType(r *http.Request) bool {
  85. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  86. if ct == "" {
  87. return false
  88. }
  89. mediaType, _, err := mime.ParseMediaType(ct)
  90. if err != nil {
  91. return false
  92. }
  93. return strings.EqualFold(mediaType, configContentTypeHeader)
  94. }
  95. type ConfigPatch struct {
  96. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  97. OutputDrive *float64 `json:"outputDrive,omitempty"`
  98. StereoEnabled *bool `json:"stereoEnabled,omitempty"`
  99. PilotLevel *float64 `json:"pilotLevel,omitempty"`
  100. RDSInjection *float64 `json:"rdsInjection,omitempty"`
  101. RDSEnabled *bool `json:"rdsEnabled,omitempty"`
  102. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  103. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  104. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  105. PS *string `json:"ps,omitempty"`
  106. RadioText *string `json:"radioText,omitempty"`
  107. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  108. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  109. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  110. }
  111. func NewServer(cfg config.Config) *Server {
  112. return &Server{cfg: cfg}
  113. }
  114. func hasRequestBody(r *http.Request) bool {
  115. if r.ContentLength > 0 {
  116. return true
  117. }
  118. for _, te := range r.TransferEncoding {
  119. if strings.EqualFold(te, "chunked") {
  120. return true
  121. }
  122. }
  123. return false
  124. }
  125. func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool {
  126. if !hasRequestBody(r) {
  127. return true
  128. }
  129. s.recordAudit(auditUnexpectedBody)
  130. http.Error(w, noBodyErrMsg, http.StatusBadRequest)
  131. return false
  132. }
  133. func (s *Server) recordAudit(evt auditEvent) {
  134. switch evt {
  135. case auditMethodNotAllowed:
  136. atomic.AddUint64(&s.audit.methodNotAllowed, 1)
  137. case auditUnsupportedMediaType:
  138. atomic.AddUint64(&s.audit.unsupportedMediaType, 1)
  139. case auditBodyTooLarge:
  140. atomic.AddUint64(&s.audit.bodyTooLarge, 1)
  141. case auditUnexpectedBody:
  142. atomic.AddUint64(&s.audit.unexpectedBody, 1)
  143. }
  144. }
  145. func (s *Server) auditSnapshot() map[string]uint64 {
  146. return map[string]uint64{
  147. "methodNotAllowed": atomic.LoadUint64(&s.audit.methodNotAllowed),
  148. "unsupportedMediaType": atomic.LoadUint64(&s.audit.unsupportedMediaType),
  149. "bodyTooLarge": atomic.LoadUint64(&s.audit.bodyTooLarge),
  150. "unexpectedBody": atomic.LoadUint64(&s.audit.unexpectedBody),
  151. }
  152. }
  153. func isAudioStreamContentType(r *http.Request) bool {
  154. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  155. if ct == "" {
  156. return false
  157. }
  158. mediaType, _, err := mime.ParseMediaType(ct)
  159. if err != nil {
  160. return false
  161. }
  162. for _, allowed := range audioStreamAllowedMediaTypes {
  163. if strings.EqualFold(mediaType, allowed) {
  164. return true
  165. }
  166. }
  167. return false
  168. }
  169. func (s *Server) SetTXController(tx TXController) {
  170. s.mu.Lock()
  171. s.tx = tx
  172. s.mu.Unlock()
  173. }
  174. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  175. s.mu.Lock()
  176. s.drv = drv
  177. s.mu.Unlock()
  178. }
  179. func (s *Server) SetStreamSource(src *audio.StreamSource) {
  180. s.mu.Lock()
  181. s.streamSrc = src
  182. s.mu.Unlock()
  183. }
  184. func (s *Server) SetAudioIngress(ingress AudioIngress) {
  185. s.mu.Lock()
  186. s.audioIngress = ingress
  187. s.mu.Unlock()
  188. }
  189. func (s *Server) SetIngestRuntime(rt IngestRuntime) {
  190. s.mu.Lock()
  191. s.ingestRt = rt
  192. s.mu.Unlock()
  193. }
  194. func (s *Server) Handler() http.Handler {
  195. mux := http.NewServeMux()
  196. mux.HandleFunc("/", s.handleUI)
  197. mux.HandleFunc("/healthz", s.handleHealth)
  198. mux.HandleFunc("/status", s.handleStatus)
  199. mux.HandleFunc("/dry-run", s.handleDryRun)
  200. mux.HandleFunc("/config", s.handleConfig)
  201. mux.HandleFunc("/runtime", s.handleRuntime)
  202. mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
  203. mux.HandleFunc("/tx/start", s.handleTXStart)
  204. mux.HandleFunc("/tx/stop", s.handleTXStop)
  205. mux.HandleFunc("/audio/stream", s.handleAudioStream)
  206. return mux
  207. }
  208. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  209. w.Header().Set("Content-Type", "application/json")
  210. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  211. }
  212. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
  213. if r.URL.Path != "/" {
  214. http.NotFound(w, r)
  215. return
  216. }
  217. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  218. w.Header().Set("Cache-Control", "no-cache")
  219. w.Write(uiHTML)
  220. }
  221. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  222. s.mu.RLock()
  223. cfg := s.cfg
  224. tx := s.tx
  225. s.mu.RUnlock()
  226. status := map[string]any{
  227. "service": "fm-rds-tx",
  228. "backend": cfg.Backend.Kind,
  229. "frequencyMHz": cfg.FM.FrequencyMHz,
  230. "stereoEnabled": cfg.FM.StereoEnabled,
  231. "rdsEnabled": cfg.RDS.Enabled,
  232. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  233. "limiterEnabled": cfg.FM.LimiterEnabled,
  234. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  235. }
  236. if tx != nil {
  237. if stats := tx.TXStats(); stats != nil {
  238. if ri, ok := stats["runtimeIndicator"]; ok {
  239. status["runtimeIndicator"] = ri
  240. }
  241. if alert, ok := stats["runtimeAlert"]; ok {
  242. status["runtimeAlert"] = alert
  243. }
  244. if queue, ok := stats["queue"]; ok {
  245. status["queue"] = queue
  246. }
  247. if runtimeState, ok := stats["state"]; ok {
  248. status["runtimeState"] = runtimeState
  249. }
  250. }
  251. }
  252. w.Header().Set("Content-Type", "application/json")
  253. _ = json.NewEncoder(w).Encode(status)
  254. }
  255. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  256. s.mu.RLock()
  257. drv := s.drv
  258. tx := s.tx
  259. stream := s.streamSrc
  260. ingestRt := s.ingestRt
  261. s.mu.RUnlock()
  262. result := map[string]any{}
  263. if drv != nil {
  264. result["driver"] = drv.Stats()
  265. }
  266. if tx != nil {
  267. result["engine"] = tx.TXStats()
  268. }
  269. if stream != nil {
  270. result["audioStream"] = stream.Stats()
  271. }
  272. if ingestRt != nil {
  273. result["ingest"] = ingestRt.Stats()
  274. }
  275. result["controlAudit"] = s.auditSnapshot()
  276. w.Header().Set("Content-Type", "application/json")
  277. _ = json.NewEncoder(w).Encode(result)
  278. }
  279. func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) {
  280. if r.Method != http.MethodPost {
  281. s.recordAudit(auditMethodNotAllowed)
  282. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  283. return
  284. }
  285. if !s.rejectBody(w, r) {
  286. return
  287. }
  288. s.mu.RLock()
  289. tx := s.tx
  290. s.mu.RUnlock()
  291. if tx == nil {
  292. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  293. return
  294. }
  295. if err := tx.ResetFault(); err != nil {
  296. http.Error(w, err.Error(), http.StatusConflict)
  297. return
  298. }
  299. w.Header().Set("Content-Type", "application/json")
  300. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  301. }
  302. // handleAudioStream accepts raw S16LE PCM via HTTP POST and pushes
  303. // it into the configured ingest http-raw source. Use with:
  304. //
  305. // curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
  306. // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
  307. func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
  308. if r.Method != http.MethodPost {
  309. s.recordAudit(auditMethodNotAllowed)
  310. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  311. return
  312. }
  313. if !isAudioStreamContentType(r) {
  314. s.recordAudit(auditUnsupportedMediaType)
  315. http.Error(w, audioStreamContentTypeError, http.StatusUnsupportedMediaType)
  316. return
  317. }
  318. s.mu.RLock()
  319. ingress := s.audioIngress
  320. s.mu.RUnlock()
  321. if ingress == nil {
  322. http.Error(w, "audio ingest not configured (use --audio-http with ingest runtime)", http.StatusServiceUnavailable)
  323. return
  324. }
  325. r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit)
  326. // Read body in chunks and push to ring buffer
  327. buf := make([]byte, 32768)
  328. totalFrames := 0
  329. for {
  330. n, err := r.Body.Read(buf)
  331. if n > 0 {
  332. written, writeErr := ingress.WritePCM16(buf[:n])
  333. totalFrames += written
  334. if writeErr != nil {
  335. http.Error(w, writeErr.Error(), http.StatusServiceUnavailable)
  336. return
  337. }
  338. }
  339. if err != nil {
  340. if err == io.EOF {
  341. break
  342. }
  343. var maxErr *http.MaxBytesError
  344. if errors.As(err, &maxErr) {
  345. s.recordAudit(auditBodyTooLarge)
  346. http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge)
  347. return
  348. }
  349. http.Error(w, err.Error(), http.StatusInternalServerError)
  350. return
  351. }
  352. }
  353. w.Header().Set("Content-Type", "application/json")
  354. _ = json.NewEncoder(w).Encode(map[string]any{
  355. "ok": true,
  356. "frames": totalFrames,
  357. })
  358. }
  359. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  360. if r.Method != http.MethodPost {
  361. s.recordAudit(auditMethodNotAllowed)
  362. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  363. return
  364. }
  365. if !s.rejectBody(w, r) {
  366. return
  367. }
  368. s.mu.RLock()
  369. tx := s.tx
  370. s.mu.RUnlock()
  371. if tx == nil {
  372. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  373. return
  374. }
  375. if err := tx.StartTX(); err != nil {
  376. http.Error(w, err.Error(), http.StatusConflict)
  377. return
  378. }
  379. w.Header().Set("Content-Type", "application/json")
  380. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  381. }
  382. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  383. if r.Method != http.MethodPost {
  384. s.recordAudit(auditMethodNotAllowed)
  385. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  386. return
  387. }
  388. if !s.rejectBody(w, r) {
  389. return
  390. }
  391. s.mu.RLock()
  392. tx := s.tx
  393. s.mu.RUnlock()
  394. if tx == nil {
  395. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  396. return
  397. }
  398. if err := tx.StopTX(); err != nil {
  399. http.Error(w, err.Error(), http.StatusInternalServerError)
  400. return
  401. }
  402. w.Header().Set("Content-Type", "application/json")
  403. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
  404. }
  405. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  406. s.mu.RLock()
  407. cfg := s.cfg
  408. s.mu.RUnlock()
  409. w.Header().Set("Content-Type", "application/json")
  410. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  411. }
  412. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  413. switch r.Method {
  414. case http.MethodGet:
  415. s.mu.RLock()
  416. cfg := s.cfg
  417. s.mu.RUnlock()
  418. w.Header().Set("Content-Type", "application/json")
  419. _ = json.NewEncoder(w).Encode(cfg)
  420. case http.MethodPost:
  421. if !isJSONContentType(r) {
  422. s.recordAudit(auditUnsupportedMediaType)
  423. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  424. return
  425. }
  426. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  427. var patch ConfigPatch
  428. if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
  429. statusCode := http.StatusBadRequest
  430. if strings.Contains(err.Error(), "http: request body too large") {
  431. statusCode = http.StatusRequestEntityTooLarge
  432. s.recordAudit(auditBodyTooLarge)
  433. }
  434. http.Error(w, err.Error(), statusCode)
  435. return
  436. }
  437. // Update the server's config snapshot (for GET /config and /status)
  438. s.mu.Lock()
  439. next := s.cfg
  440. if patch.FrequencyMHz != nil {
  441. next.FM.FrequencyMHz = *patch.FrequencyMHz
  442. }
  443. if patch.OutputDrive != nil {
  444. next.FM.OutputDrive = *patch.OutputDrive
  445. }
  446. if patch.ToneLeftHz != nil {
  447. next.Audio.ToneLeftHz = *patch.ToneLeftHz
  448. }
  449. if patch.ToneRightHz != nil {
  450. next.Audio.ToneRightHz = *patch.ToneRightHz
  451. }
  452. if patch.ToneAmplitude != nil {
  453. next.Audio.ToneAmplitude = *patch.ToneAmplitude
  454. }
  455. if patch.PS != nil {
  456. next.RDS.PS = *patch.PS
  457. }
  458. if patch.RadioText != nil {
  459. next.RDS.RadioText = *patch.RadioText
  460. }
  461. if patch.PreEmphasisTauUS != nil {
  462. next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
  463. }
  464. if patch.StereoEnabled != nil {
  465. next.FM.StereoEnabled = *patch.StereoEnabled
  466. }
  467. if patch.LimiterEnabled != nil {
  468. next.FM.LimiterEnabled = *patch.LimiterEnabled
  469. }
  470. if patch.LimiterCeiling != nil {
  471. next.FM.LimiterCeiling = *patch.LimiterCeiling
  472. }
  473. if patch.RDSEnabled != nil {
  474. next.RDS.Enabled = *patch.RDSEnabled
  475. }
  476. if patch.PilotLevel != nil {
  477. next.FM.PilotLevel = *patch.PilotLevel
  478. }
  479. if patch.RDSInjection != nil {
  480. next.FM.RDSInjection = *patch.RDSInjection
  481. }
  482. if err := next.Validate(); err != nil {
  483. s.mu.Unlock()
  484. http.Error(w, err.Error(), http.StatusBadRequest)
  485. return
  486. }
  487. lp := LivePatch{
  488. FrequencyMHz: patch.FrequencyMHz,
  489. OutputDrive: patch.OutputDrive,
  490. StereoEnabled: patch.StereoEnabled,
  491. PilotLevel: patch.PilotLevel,
  492. RDSInjection: patch.RDSInjection,
  493. RDSEnabled: patch.RDSEnabled,
  494. LimiterEnabled: patch.LimiterEnabled,
  495. LimiterCeiling: patch.LimiterCeiling,
  496. PS: patch.PS,
  497. RadioText: patch.RadioText,
  498. }
  499. tx := s.tx
  500. if tx != nil {
  501. if err := tx.UpdateConfig(lp); err != nil {
  502. s.mu.Unlock()
  503. http.Error(w, err.Error(), http.StatusBadRequest)
  504. return
  505. }
  506. }
  507. s.cfg = next
  508. live := tx != nil
  509. s.mu.Unlock()
  510. w.Header().Set("Content-Type", "application/json")
  511. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live})
  512. default:
  513. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  514. }
  515. }