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.

730 Zeilen
21KB

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