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

564 строки
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. if stats := tx.TXStats(); stats != nil {
  268. result["engine"] = stats
  269. }
  270. }
  271. if stream != nil {
  272. result["audioStream"] = stream.Stats()
  273. }
  274. if ingestRt != nil {
  275. result["ingest"] = ingestRt.Stats()
  276. }
  277. result["controlAudit"] = s.auditSnapshot()
  278. w.Header().Set("Content-Type", "application/json")
  279. _ = json.NewEncoder(w).Encode(result)
  280. }
  281. func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) {
  282. if r.Method != http.MethodPost {
  283. s.recordAudit(auditMethodNotAllowed)
  284. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  285. return
  286. }
  287. if !s.rejectBody(w, r) {
  288. return
  289. }
  290. s.mu.RLock()
  291. tx := s.tx
  292. s.mu.RUnlock()
  293. if tx == nil {
  294. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  295. return
  296. }
  297. if err := tx.ResetFault(); err != nil {
  298. http.Error(w, err.Error(), http.StatusConflict)
  299. return
  300. }
  301. w.Header().Set("Content-Type", "application/json")
  302. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  303. }
  304. // handleAudioStream accepts raw S16LE PCM via HTTP POST and pushes
  305. // it into the configured ingest http-raw source. Use with:
  306. //
  307. // curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
  308. // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
  309. func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
  310. if r.Method != http.MethodPost {
  311. s.recordAudit(auditMethodNotAllowed)
  312. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  313. return
  314. }
  315. if !isAudioStreamContentType(r) {
  316. s.recordAudit(auditUnsupportedMediaType)
  317. http.Error(w, audioStreamContentTypeError, http.StatusUnsupportedMediaType)
  318. return
  319. }
  320. s.mu.RLock()
  321. ingress := s.audioIngress
  322. s.mu.RUnlock()
  323. if ingress == nil {
  324. http.Error(w, "audio ingest not configured (use --audio-http with ingest runtime)", http.StatusServiceUnavailable)
  325. return
  326. }
  327. r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit)
  328. // Read body in chunks and push to ring buffer
  329. buf := make([]byte, 32768)
  330. totalFrames := 0
  331. for {
  332. n, err := r.Body.Read(buf)
  333. if n > 0 {
  334. written, writeErr := ingress.WritePCM16(buf[:n])
  335. totalFrames += written
  336. if writeErr != nil {
  337. http.Error(w, writeErr.Error(), http.StatusServiceUnavailable)
  338. return
  339. }
  340. }
  341. if err != nil {
  342. if err == io.EOF {
  343. break
  344. }
  345. var maxErr *http.MaxBytesError
  346. if errors.As(err, &maxErr) {
  347. s.recordAudit(auditBodyTooLarge)
  348. http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge)
  349. return
  350. }
  351. http.Error(w, err.Error(), http.StatusInternalServerError)
  352. return
  353. }
  354. }
  355. w.Header().Set("Content-Type", "application/json")
  356. _ = json.NewEncoder(w).Encode(map[string]any{
  357. "ok": true,
  358. "frames": totalFrames,
  359. })
  360. }
  361. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  362. if r.Method != http.MethodPost {
  363. s.recordAudit(auditMethodNotAllowed)
  364. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  365. return
  366. }
  367. if !s.rejectBody(w, r) {
  368. return
  369. }
  370. s.mu.RLock()
  371. tx := s.tx
  372. s.mu.RUnlock()
  373. if tx == nil {
  374. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  375. return
  376. }
  377. if err := tx.StartTX(); err != nil {
  378. http.Error(w, err.Error(), http.StatusConflict)
  379. return
  380. }
  381. w.Header().Set("Content-Type", "application/json")
  382. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  383. }
  384. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  385. if r.Method != http.MethodPost {
  386. s.recordAudit(auditMethodNotAllowed)
  387. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  388. return
  389. }
  390. if !s.rejectBody(w, r) {
  391. return
  392. }
  393. s.mu.RLock()
  394. tx := s.tx
  395. s.mu.RUnlock()
  396. if tx == nil {
  397. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  398. return
  399. }
  400. if err := tx.StopTX(); err != nil {
  401. http.Error(w, err.Error(), http.StatusInternalServerError)
  402. return
  403. }
  404. w.Header().Set("Content-Type", "application/json")
  405. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
  406. }
  407. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  408. s.mu.RLock()
  409. cfg := s.cfg
  410. s.mu.RUnlock()
  411. w.Header().Set("Content-Type", "application/json")
  412. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  413. }
  414. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  415. switch r.Method {
  416. case http.MethodGet:
  417. s.mu.RLock()
  418. cfg := s.cfg
  419. s.mu.RUnlock()
  420. w.Header().Set("Content-Type", "application/json")
  421. _ = json.NewEncoder(w).Encode(cfg)
  422. case http.MethodPost:
  423. if !isJSONContentType(r) {
  424. s.recordAudit(auditUnsupportedMediaType)
  425. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  426. return
  427. }
  428. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  429. var patch ConfigPatch
  430. if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
  431. statusCode := http.StatusBadRequest
  432. if strings.Contains(err.Error(), "http: request body too large") {
  433. statusCode = http.StatusRequestEntityTooLarge
  434. s.recordAudit(auditBodyTooLarge)
  435. }
  436. http.Error(w, err.Error(), statusCode)
  437. return
  438. }
  439. // Update the server's config snapshot (for GET /config and /status)
  440. s.mu.Lock()
  441. next := s.cfg
  442. if patch.FrequencyMHz != nil {
  443. next.FM.FrequencyMHz = *patch.FrequencyMHz
  444. }
  445. if patch.OutputDrive != nil {
  446. next.FM.OutputDrive = *patch.OutputDrive
  447. }
  448. if patch.ToneLeftHz != nil {
  449. next.Audio.ToneLeftHz = *patch.ToneLeftHz
  450. }
  451. if patch.ToneRightHz != nil {
  452. next.Audio.ToneRightHz = *patch.ToneRightHz
  453. }
  454. if patch.ToneAmplitude != nil {
  455. next.Audio.ToneAmplitude = *patch.ToneAmplitude
  456. }
  457. if patch.PS != nil {
  458. next.RDS.PS = *patch.PS
  459. }
  460. if patch.RadioText != nil {
  461. next.RDS.RadioText = *patch.RadioText
  462. }
  463. if patch.PreEmphasisTauUS != nil {
  464. next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
  465. }
  466. if patch.StereoEnabled != nil {
  467. next.FM.StereoEnabled = *patch.StereoEnabled
  468. }
  469. if patch.LimiterEnabled != nil {
  470. next.FM.LimiterEnabled = *patch.LimiterEnabled
  471. }
  472. if patch.LimiterCeiling != nil {
  473. next.FM.LimiterCeiling = *patch.LimiterCeiling
  474. }
  475. if patch.RDSEnabled != nil {
  476. next.RDS.Enabled = *patch.RDSEnabled
  477. }
  478. if patch.PilotLevel != nil {
  479. next.FM.PilotLevel = *patch.PilotLevel
  480. }
  481. if patch.RDSInjection != nil {
  482. next.FM.RDSInjection = *patch.RDSInjection
  483. }
  484. if err := next.Validate(); err != nil {
  485. s.mu.Unlock()
  486. http.Error(w, err.Error(), http.StatusBadRequest)
  487. return
  488. }
  489. lp := LivePatch{
  490. FrequencyMHz: patch.FrequencyMHz,
  491. OutputDrive: patch.OutputDrive,
  492. StereoEnabled: patch.StereoEnabled,
  493. PilotLevel: patch.PilotLevel,
  494. RDSInjection: patch.RDSInjection,
  495. RDSEnabled: patch.RDSEnabled,
  496. LimiterEnabled: patch.LimiterEnabled,
  497. LimiterCeiling: patch.LimiterCeiling,
  498. PS: patch.PS,
  499. RadioText: patch.RadioText,
  500. }
  501. tx := s.tx
  502. if tx != nil {
  503. if err := tx.UpdateConfig(lp); err != nil {
  504. s.mu.Unlock()
  505. http.Error(w, err.Error(), http.StatusBadRequest)
  506. return
  507. }
  508. }
  509. s.cfg = next
  510. live := tx != nil
  511. s.mu.Unlock()
  512. w.Header().Set("Content-Type", "application/json")
  513. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live})
  514. default:
  515. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  516. }
  517. }