Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

773 líneas
22KB

  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. "time"
  13. "github.com/jan/fm-rds-tx/internal/audio"
  14. "github.com/jan/fm-rds-tx/internal/config"
  15. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  16. "github.com/jan/fm-rds-tx/internal/ingest"
  17. "github.com/jan/fm-rds-tx/internal/platform"
  18. )
  19. //go:embed ui.html
  20. var uiHTML []byte
  21. // TXController is an optional interface the Server uses to start/stop TX
  22. // and apply live config changes.
  23. type TXController interface {
  24. StartTX() error
  25. StopTX() error
  26. TXStats() map[string]any
  27. UpdateConfig(patch LivePatch) error
  28. ResetFault() error
  29. }
  30. // LivePatch mirrors the patchable fields from ConfigPatch for the engine.
  31. // nil = no change.
  32. type LivePatch struct {
  33. FrequencyMHz *float64
  34. OutputDrive *float64
  35. StereoEnabled *bool
  36. StereoMode *string
  37. PilotLevel *float64
  38. RDSInjection *float64
  39. RDSEnabled *bool
  40. LimiterEnabled *bool
  41. LimiterCeiling *float64
  42. PS *string
  43. RadioText *string
  44. TA *bool
  45. TP *bool
  46. ToneLeftHz *float64
  47. ToneRightHz *float64
  48. ToneAmplitude *float64
  49. AudioGain *float64
  50. CompositeClipperEnabled *bool
  51. }
  52. type Server struct {
  53. mu sync.RWMutex
  54. cfg config.Config
  55. tx TXController
  56. drv platform.SoapyDriver // optional, for runtime stats
  57. streamSrc *audio.StreamSource // optional, for live audio ring stats
  58. audioIngress AudioIngress // optional, for /audio/stream
  59. ingestRt IngestRuntime // optional, for /runtime ingest stats
  60. saveConfig func(config.Config) error
  61. hardReload func()
  62. // BUG-F fix: reloadPending prevents multiple concurrent goroutines from
  63. // calling hardReload when handleIngestSave is hit multiple times quickly.
  64. reloadPending atomic.Bool
  65. audit auditCounters
  66. }
  67. type AudioIngress interface {
  68. WritePCM16(data []byte) (int, error)
  69. }
  70. type IngestRuntime interface {
  71. Stats() ingest.Stats
  72. }
  73. type auditEvent string
  74. const (
  75. auditMethodNotAllowed auditEvent = "methodNotAllowed"
  76. auditUnsupportedMediaType auditEvent = "unsupportedMediaType"
  77. auditBodyTooLarge auditEvent = "bodyTooLarge"
  78. auditUnexpectedBody auditEvent = "unexpectedBody"
  79. )
  80. type auditCounters struct {
  81. methodNotAllowed uint64
  82. unsupportedMediaType uint64
  83. bodyTooLarge uint64
  84. unexpectedBody uint64
  85. }
  86. const (
  87. maxConfigBodyBytes = 64 << 10 // 64 KiB
  88. configContentTypeHeader = "application/json"
  89. noBodyErrMsg = "request must not include a body"
  90. audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16"
  91. audioStreamBodyLimitDefault = 512 << 20 // 512 MiB
  92. )
  93. var audioStreamAllowedMediaTypes = []string{
  94. "application/octet-stream",
  95. "audio/l16",
  96. }
  97. var audioStreamBodyLimit = int64(audioStreamBodyLimitDefault) // bytes allowed per /audio/stream request; tests may override.
  98. func isJSONContentType(r *http.Request) bool {
  99. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  100. if ct == "" {
  101. return false
  102. }
  103. mediaType, _, err := mime.ParseMediaType(ct)
  104. if err != nil {
  105. return false
  106. }
  107. return strings.EqualFold(mediaType, configContentTypeHeader)
  108. }
  109. type ConfigPatch struct {
  110. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  111. OutputDrive *float64 `json:"outputDrive,omitempty"`
  112. StereoEnabled *bool `json:"stereoEnabled,omitempty"`
  113. StereoMode *string `json:"stereoMode,omitempty"`
  114. PilotLevel *float64 `json:"pilotLevel,omitempty"`
  115. RDSInjection *float64 `json:"rdsInjection,omitempty"`
  116. RDSEnabled *bool `json:"rdsEnabled,omitempty"`
  117. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  118. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  119. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  120. PS *string `json:"ps,omitempty"`
  121. RadioText *string `json:"radioText,omitempty"`
  122. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  123. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  124. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  125. AudioGain *float64 `json:"audioGain,omitempty"`
  126. PI *string `json:"pi,omitempty"`
  127. PTY *int `json:"pty,omitempty"`
  128. TP *bool `json:"tp,omitempty"`
  129. TA *bool `json:"ta,omitempty"`
  130. MS *bool `json:"ms,omitempty"`
  131. CTEnabled *bool `json:"ctEnabled,omitempty"`
  132. RTPlusEnabled *bool `json:"rtPlusEnabled,omitempty"`
  133. RTPlusSeparator *string `json:"rtPlusSeparator,omitempty"`
  134. PTYN *string `json:"ptyn,omitempty"`
  135. AF *[]float64 `json:"af,omitempty"`
  136. BS412Enabled *bool `json:"bs412Enabled,omitempty"`
  137. BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"`
  138. MpxGain *float64 `json:"mpxGain,omitempty"`
  139. CompositeClipperEnabled *bool `json:"compositeClipperEnabled,omitempty"`
  140. CompositeClipperIterations *int `json:"compositeClipperIterations,omitempty"`
  141. CompositeClipperSoftKnee *float64 `json:"compositeClipperSoftKnee,omitempty"`
  142. CompositeClipperLookaheadMs *float64 `json:"compositeClipperLookaheadMs,omitempty"`
  143. }
  144. type IngestSaveRequest struct {
  145. Ingest config.IngestConfig `json:"ingest"`
  146. }
  147. func NewServer(cfg config.Config) *Server {
  148. return &Server{cfg: cfg}
  149. }
  150. func hasRequestBody(r *http.Request) bool {
  151. if r.ContentLength > 0 {
  152. return true
  153. }
  154. for _, te := range r.TransferEncoding {
  155. if strings.EqualFold(te, "chunked") {
  156. return true
  157. }
  158. }
  159. return false
  160. }
  161. func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool {
  162. // Returns true when the request has an unexpected body and the error response
  163. // has already been written — callers should return immediately in that case.
  164. // Returns false when there is no body (happy path — request should proceed).
  165. if !hasRequestBody(r) {
  166. return false
  167. }
  168. s.recordAudit(auditUnexpectedBody)
  169. http.Error(w, noBodyErrMsg, http.StatusBadRequest)
  170. return true
  171. }
  172. func (s *Server) recordAudit(evt auditEvent) {
  173. switch evt {
  174. case auditMethodNotAllowed:
  175. atomic.AddUint64(&s.audit.methodNotAllowed, 1)
  176. case auditUnsupportedMediaType:
  177. atomic.AddUint64(&s.audit.unsupportedMediaType, 1)
  178. case auditBodyTooLarge:
  179. atomic.AddUint64(&s.audit.bodyTooLarge, 1)
  180. case auditUnexpectedBody:
  181. atomic.AddUint64(&s.audit.unexpectedBody, 1)
  182. }
  183. }
  184. func (s *Server) auditSnapshot() map[string]uint64 {
  185. return map[string]uint64{
  186. "methodNotAllowed": atomic.LoadUint64(&s.audit.methodNotAllowed),
  187. "unsupportedMediaType": atomic.LoadUint64(&s.audit.unsupportedMediaType),
  188. "bodyTooLarge": atomic.LoadUint64(&s.audit.bodyTooLarge),
  189. "unexpectedBody": atomic.LoadUint64(&s.audit.unexpectedBody),
  190. }
  191. }
  192. func isAudioStreamContentType(r *http.Request) bool {
  193. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  194. if ct == "" {
  195. return false
  196. }
  197. mediaType, _, err := mime.ParseMediaType(ct)
  198. if err != nil {
  199. return false
  200. }
  201. for _, allowed := range audioStreamAllowedMediaTypes {
  202. if strings.EqualFold(mediaType, allowed) {
  203. return true
  204. }
  205. }
  206. return false
  207. }
  208. func (s *Server) SetTXController(tx TXController) {
  209. s.mu.Lock()
  210. s.tx = tx
  211. s.mu.Unlock()
  212. }
  213. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  214. s.mu.Lock()
  215. s.drv = drv
  216. s.mu.Unlock()
  217. }
  218. func (s *Server) SetStreamSource(src *audio.StreamSource) {
  219. s.mu.Lock()
  220. s.streamSrc = src
  221. s.mu.Unlock()
  222. }
  223. func (s *Server) SetAudioIngress(ingress AudioIngress) {
  224. s.mu.Lock()
  225. s.audioIngress = ingress
  226. s.mu.Unlock()
  227. }
  228. func (s *Server) SetIngestRuntime(rt IngestRuntime) {
  229. s.mu.Lock()
  230. s.ingestRt = rt
  231. s.mu.Unlock()
  232. }
  233. func (s *Server) SetConfigSaver(save func(config.Config) error) {
  234. s.mu.Lock()
  235. s.saveConfig = save
  236. s.mu.Unlock()
  237. }
  238. func (s *Server) SetHardReload(fn func()) {
  239. s.mu.Lock()
  240. s.hardReload = fn
  241. s.mu.Unlock()
  242. }
  243. func (s *Server) Handler() http.Handler {
  244. mux := http.NewServeMux()
  245. mux.HandleFunc("/", s.handleUI)
  246. mux.HandleFunc("/healthz", s.handleHealth)
  247. mux.HandleFunc("/status", s.handleStatus)
  248. mux.HandleFunc("/dry-run", s.handleDryRun)
  249. mux.HandleFunc("/config", s.handleConfig)
  250. mux.HandleFunc("/config/ingest/save", s.handleIngestSave)
  251. mux.HandleFunc("/runtime", s.handleRuntime)
  252. mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
  253. mux.HandleFunc("/tx/start", s.handleTXStart)
  254. mux.HandleFunc("/tx/stop", s.handleTXStop)
  255. mux.HandleFunc("/audio/stream", s.handleAudioStream)
  256. return mux
  257. }
  258. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  259. w.Header().Set("Content-Type", "application/json")
  260. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  261. }
  262. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
  263. if r.URL.Path != "/" {
  264. http.NotFound(w, r)
  265. return
  266. }
  267. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  268. w.Header().Set("Cache-Control", "no-cache")
  269. w.Write(uiHTML)
  270. }
  271. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  272. s.mu.RLock()
  273. cfg := s.cfg
  274. tx := s.tx
  275. s.mu.RUnlock()
  276. status := map[string]any{
  277. "service": "fm-rds-tx",
  278. "backend": cfg.Backend.Kind,
  279. "frequencyMHz": cfg.FM.FrequencyMHz,
  280. "stereoEnabled": cfg.FM.StereoEnabled,
  281. "stereoMode": cfg.FM.StereoMode,
  282. "rdsEnabled": cfg.RDS.Enabled,
  283. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  284. "limiterEnabled": cfg.FM.LimiterEnabled,
  285. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  286. }
  287. if tx != nil {
  288. if stats := tx.TXStats(); stats != nil {
  289. if ri, ok := stats["runtimeIndicator"]; ok {
  290. status["runtimeIndicator"] = ri
  291. }
  292. if alert, ok := stats["runtimeAlert"]; ok {
  293. status["runtimeAlert"] = alert
  294. }
  295. if queue, ok := stats["queue"]; ok {
  296. status["queue"] = queue
  297. }
  298. if runtimeState, ok := stats["state"]; ok {
  299. status["runtimeState"] = runtimeState
  300. }
  301. }
  302. }
  303. w.Header().Set("Content-Type", "application/json")
  304. _ = json.NewEncoder(w).Encode(status)
  305. }
  306. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  307. s.mu.RLock()
  308. drv := s.drv
  309. tx := s.tx
  310. stream := s.streamSrc
  311. ingestRt := s.ingestRt
  312. s.mu.RUnlock()
  313. result := map[string]any{}
  314. if drv != nil {
  315. result["driver"] = drv.Stats()
  316. }
  317. if tx != nil {
  318. if stats := tx.TXStats(); stats != nil {
  319. result["engine"] = stats
  320. }
  321. }
  322. if stream != nil {
  323. result["audioStream"] = stream.Stats()
  324. }
  325. if ingestRt != nil {
  326. result["ingest"] = ingestRt.Stats()
  327. }
  328. result["controlAudit"] = s.auditSnapshot()
  329. w.Header().Set("Content-Type", "application/json")
  330. _ = json.NewEncoder(w).Encode(result)
  331. }
  332. func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) {
  333. if r.Method != http.MethodPost {
  334. s.recordAudit(auditMethodNotAllowed)
  335. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  336. return
  337. }
  338. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  339. return
  340. }
  341. s.mu.RLock()
  342. tx := s.tx
  343. s.mu.RUnlock()
  344. if tx == nil {
  345. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  346. return
  347. }
  348. if err := tx.ResetFault(); err != nil {
  349. http.Error(w, err.Error(), http.StatusConflict)
  350. return
  351. }
  352. w.Header().Set("Content-Type", "application/json")
  353. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  354. }
  355. // handleAudioStream accepts raw S16LE PCM via HTTP POST and pushes
  356. // it into the configured ingest http-raw source. Use with:
  357. //
  358. // curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
  359. // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
  360. func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
  361. if r.Method != http.MethodPost {
  362. s.recordAudit(auditMethodNotAllowed)
  363. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  364. return
  365. }
  366. if !isAudioStreamContentType(r) {
  367. s.recordAudit(auditUnsupportedMediaType)
  368. http.Error(w, audioStreamContentTypeError, http.StatusUnsupportedMediaType)
  369. return
  370. }
  371. s.mu.RLock()
  372. ingress := s.audioIngress
  373. s.mu.RUnlock()
  374. if ingress == nil {
  375. http.Error(w, "audio ingest not configured (use --audio-http with ingest runtime)", http.StatusServiceUnavailable)
  376. return
  377. }
  378. // BUG-10 fix: /audio/stream is a long-lived streaming endpoint.
  379. // The global HTTP server ReadTimeout (5s) and WriteTimeout (10s) would
  380. // kill connections mid-stream. Disable them per-request via ResponseController
  381. // (requires Go 1.20+, confirmed Go 1.22).
  382. rc := http.NewResponseController(w)
  383. _ = rc.SetReadDeadline(time.Time{})
  384. _ = rc.SetWriteDeadline(time.Time{})
  385. r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit)
  386. // Read body in chunks and push to ring buffer
  387. buf := make([]byte, 32768)
  388. totalFrames := 0
  389. for {
  390. n, err := r.Body.Read(buf)
  391. if n > 0 {
  392. written, writeErr := ingress.WritePCM16(buf[:n])
  393. totalFrames += written
  394. if writeErr != nil {
  395. http.Error(w, writeErr.Error(), http.StatusServiceUnavailable)
  396. return
  397. }
  398. }
  399. if err != nil {
  400. if err == io.EOF {
  401. break
  402. }
  403. var maxErr *http.MaxBytesError
  404. if errors.As(err, &maxErr) {
  405. s.recordAudit(auditBodyTooLarge)
  406. http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge)
  407. return
  408. }
  409. http.Error(w, err.Error(), http.StatusInternalServerError)
  410. return
  411. }
  412. }
  413. w.Header().Set("Content-Type", "application/json")
  414. _ = json.NewEncoder(w).Encode(map[string]any{
  415. "ok": true,
  416. "frames": totalFrames,
  417. })
  418. }
  419. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  420. if r.Method != http.MethodPost {
  421. s.recordAudit(auditMethodNotAllowed)
  422. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  423. return
  424. }
  425. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  426. return
  427. }
  428. s.mu.RLock()
  429. tx := s.tx
  430. s.mu.RUnlock()
  431. if tx == nil {
  432. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  433. return
  434. }
  435. if err := tx.StartTX(); err != nil {
  436. http.Error(w, err.Error(), http.StatusConflict)
  437. return
  438. }
  439. w.Header().Set("Content-Type", "application/json")
  440. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  441. }
  442. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  443. if r.Method != http.MethodPost {
  444. s.recordAudit(auditMethodNotAllowed)
  445. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  446. return
  447. }
  448. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  449. return
  450. }
  451. s.mu.RLock()
  452. tx := s.tx
  453. s.mu.RUnlock()
  454. if tx == nil {
  455. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  456. return
  457. }
  458. go func() {
  459. _ = tx.StopTX()
  460. }()
  461. w.Header().Set("Content-Type", "application/json")
  462. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stop-requested"})
  463. }
  464. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  465. s.mu.RLock()
  466. cfg := s.cfg
  467. s.mu.RUnlock()
  468. w.Header().Set("Content-Type", "application/json")
  469. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  470. }
  471. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  472. switch r.Method {
  473. case http.MethodGet:
  474. s.mu.RLock()
  475. cfg := s.cfg
  476. s.mu.RUnlock()
  477. w.Header().Set("Content-Type", "application/json")
  478. _ = json.NewEncoder(w).Encode(cfg)
  479. case http.MethodPost:
  480. if !isJSONContentType(r) {
  481. s.recordAudit(auditUnsupportedMediaType)
  482. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  483. return
  484. }
  485. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  486. var patch ConfigPatch
  487. // BUG-4 fix: reject unknown JSON fields (typos) with 400 rather than
  488. // silently ignoring them (e.g. "outputDrvie" would succeed and do nothing).
  489. dec := json.NewDecoder(r.Body)
  490. dec.DisallowUnknownFields()
  491. if err := dec.Decode(&patch); err != nil {
  492. statusCode := http.StatusBadRequest
  493. if strings.Contains(err.Error(), "http: request body too large") {
  494. statusCode = http.StatusRequestEntityTooLarge
  495. s.recordAudit(auditBodyTooLarge)
  496. }
  497. http.Error(w, err.Error(), statusCode)
  498. return
  499. }
  500. // Update the server's config snapshot (for GET /config and /status)
  501. s.mu.Lock()
  502. next := s.cfg
  503. if patch.FrequencyMHz != nil {
  504. next.FM.FrequencyMHz = *patch.FrequencyMHz
  505. }
  506. if patch.OutputDrive != nil {
  507. next.FM.OutputDrive = *patch.OutputDrive
  508. }
  509. if patch.ToneLeftHz != nil {
  510. next.Audio.ToneLeftHz = *patch.ToneLeftHz
  511. }
  512. if patch.ToneRightHz != nil {
  513. next.Audio.ToneRightHz = *patch.ToneRightHz
  514. }
  515. if patch.ToneAmplitude != nil {
  516. next.Audio.ToneAmplitude = *patch.ToneAmplitude
  517. }
  518. if patch.AudioGain != nil {
  519. next.Audio.Gain = *patch.AudioGain
  520. }
  521. if patch.PS != nil {
  522. next.RDS.PS = *patch.PS
  523. }
  524. if patch.RadioText != nil {
  525. next.RDS.RadioText = *patch.RadioText
  526. }
  527. if patch.PI != nil {
  528. next.RDS.PI = *patch.PI
  529. }
  530. if patch.PTY != nil {
  531. next.RDS.PTY = *patch.PTY
  532. }
  533. if patch.TP != nil {
  534. next.RDS.TP = *patch.TP
  535. }
  536. if patch.TA != nil {
  537. next.RDS.TA = *patch.TA
  538. }
  539. if patch.MS != nil {
  540. next.RDS.MS = *patch.MS
  541. }
  542. if patch.CTEnabled != nil {
  543. next.RDS.CTEnabled = *patch.CTEnabled
  544. }
  545. if patch.RTPlusEnabled != nil {
  546. next.RDS.RTPlusEnabled = *patch.RTPlusEnabled
  547. }
  548. if patch.RTPlusSeparator != nil {
  549. next.RDS.RTPlusSeparator = *patch.RTPlusSeparator
  550. }
  551. if patch.PTYN != nil {
  552. next.RDS.PTYN = *patch.PTYN
  553. }
  554. if patch.AF != nil {
  555. next.RDS.AF = *patch.AF
  556. }
  557. if patch.PreEmphasisTauUS != nil {
  558. next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
  559. }
  560. if patch.StereoEnabled != nil {
  561. next.FM.StereoEnabled = *patch.StereoEnabled
  562. }
  563. if patch.StereoMode != nil {
  564. next.FM.StereoMode = *patch.StereoMode
  565. }
  566. if patch.LimiterEnabled != nil {
  567. next.FM.LimiterEnabled = *patch.LimiterEnabled
  568. }
  569. if patch.LimiterCeiling != nil {
  570. next.FM.LimiterCeiling = *patch.LimiterCeiling
  571. }
  572. if patch.RDSEnabled != nil {
  573. next.RDS.Enabled = *patch.RDSEnabled
  574. }
  575. if patch.PilotLevel != nil {
  576. next.FM.PilotLevel = *patch.PilotLevel
  577. }
  578. if patch.RDSInjection != nil {
  579. next.FM.RDSInjection = *patch.RDSInjection
  580. }
  581. if patch.BS412Enabled != nil {
  582. next.FM.BS412Enabled = *patch.BS412Enabled
  583. }
  584. if patch.BS412ThresholdDBr != nil {
  585. next.FM.BS412ThresholdDBr = *patch.BS412ThresholdDBr
  586. }
  587. if patch.MpxGain != nil {
  588. next.FM.MpxGain = *patch.MpxGain
  589. }
  590. if patch.CompositeClipperEnabled != nil {
  591. next.FM.CompositeClipper.Enabled = *patch.CompositeClipperEnabled
  592. }
  593. if patch.CompositeClipperIterations != nil {
  594. next.FM.CompositeClipper.Iterations = *patch.CompositeClipperIterations
  595. }
  596. if patch.CompositeClipperSoftKnee != nil {
  597. next.FM.CompositeClipper.SoftKnee = *patch.CompositeClipperSoftKnee
  598. }
  599. if patch.CompositeClipperLookaheadMs != nil {
  600. next.FM.CompositeClipper.LookaheadMs = *patch.CompositeClipperLookaheadMs
  601. }
  602. if err := next.Validate(); err != nil {
  603. s.mu.Unlock()
  604. http.Error(w, err.Error(), http.StatusBadRequest)
  605. return
  606. }
  607. lp := LivePatch{
  608. FrequencyMHz: patch.FrequencyMHz,
  609. OutputDrive: patch.OutputDrive,
  610. StereoEnabled: patch.StereoEnabled,
  611. StereoMode: patch.StereoMode,
  612. PilotLevel: patch.PilotLevel,
  613. RDSInjection: patch.RDSInjection,
  614. RDSEnabled: patch.RDSEnabled,
  615. LimiterEnabled: patch.LimiterEnabled,
  616. LimiterCeiling: patch.LimiterCeiling,
  617. PS: patch.PS,
  618. RadioText: patch.RadioText,
  619. TA: patch.TA,
  620. TP: patch.TP,
  621. ToneLeftHz: patch.ToneLeftHz,
  622. ToneRightHz: patch.ToneRightHz,
  623. ToneAmplitude: patch.ToneAmplitude,
  624. AudioGain: patch.AudioGain,
  625. CompositeClipperEnabled: patch.CompositeClipperEnabled,
  626. }
  627. // NEU-02 fix: determine whether any live-patchable fields are present,
  628. // then release the lock before calling UpdateConfig to avoid holding
  629. // s.mu across a potentially blocking engine call.
  630. tx := s.tx
  631. hasLiveFields := patch.FrequencyMHz != nil || patch.OutputDrive != nil ||
  632. patch.StereoEnabled != nil || patch.StereoMode != nil || patch.PilotLevel != nil ||
  633. patch.RDSInjection != nil || patch.RDSEnabled != nil ||
  634. patch.LimiterEnabled != nil || patch.LimiterCeiling != nil ||
  635. patch.PS != nil || patch.RadioText != nil || patch.TA != nil || patch.TP != nil ||
  636. patch.ToneLeftHz != nil || patch.ToneRightHz != nil ||
  637. patch.ToneAmplitude != nil || patch.AudioGain != nil ||
  638. patch.CompositeClipperEnabled != nil
  639. s.cfg = next
  640. s.mu.Unlock()
  641. // Apply live fields to running engine outside the lock.
  642. var updateErr error
  643. if tx != nil && hasLiveFields {
  644. if err := tx.UpdateConfig(lp); err != nil {
  645. updateErr = err
  646. }
  647. }
  648. if updateErr != nil {
  649. http.Error(w, updateErr.Error(), http.StatusBadRequest)
  650. return
  651. }
  652. // NEU-03 fix: report live=true only when live-patchable fields were applied.
  653. live := tx != nil && hasLiveFields
  654. w.Header().Set("Content-Type", "application/json")
  655. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live})
  656. default:
  657. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  658. }
  659. }
  660. func (s *Server) handleIngestSave(w http.ResponseWriter, r *http.Request) {
  661. if r.Method != http.MethodPost {
  662. s.recordAudit(auditMethodNotAllowed)
  663. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  664. return
  665. }
  666. if !isJSONContentType(r) {
  667. s.recordAudit(auditUnsupportedMediaType)
  668. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  669. return
  670. }
  671. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  672. var req IngestSaveRequest
  673. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  674. statusCode := http.StatusBadRequest
  675. if strings.Contains(err.Error(), "http: request body too large") {
  676. statusCode = http.StatusRequestEntityTooLarge
  677. s.recordAudit(auditBodyTooLarge)
  678. }
  679. http.Error(w, err.Error(), statusCode)
  680. return
  681. }
  682. s.mu.Lock()
  683. next := s.cfg
  684. next.Ingest = req.Ingest
  685. if err := next.Validate(); err != nil {
  686. s.mu.Unlock()
  687. http.Error(w, err.Error(), http.StatusBadRequest)
  688. return
  689. }
  690. save := s.saveConfig
  691. reload := s.hardReload
  692. if save == nil {
  693. s.mu.Unlock()
  694. http.Error(w, "config save is not configured (start with --config <path>)", http.StatusServiceUnavailable)
  695. return
  696. }
  697. if err := save(next); err != nil {
  698. s.mu.Unlock()
  699. http.Error(w, err.Error(), http.StatusInternalServerError)
  700. return
  701. }
  702. s.cfg = next
  703. s.mu.Unlock()
  704. w.Header().Set("Content-Type", "application/json")
  705. reloadScheduled := reload != nil
  706. _ = json.NewEncoder(w).Encode(map[string]any{
  707. "ok": true,
  708. "saved": true,
  709. "reloadScheduled": reloadScheduled,
  710. })
  711. if reloadScheduled && s.reloadPending.CompareAndSwap(false, true) {
  712. go func(fn func()) {
  713. time.Sleep(250 * time.Millisecond)
  714. s.reloadPending.Store(false)
  715. fn()
  716. }(reload)
  717. }
  718. }