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.

526 líneas
14KB

  1. package app
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log"
  7. "sync"
  8. "sync/atomic"
  9. "time"
  10. "github.com/jan/fm-rds-tx/internal/audio"
  11. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  12. "github.com/jan/fm-rds-tx/internal/dsp"
  13. offpkg "github.com/jan/fm-rds-tx/internal/offline"
  14. "github.com/jan/fm-rds-tx/internal/output"
  15. "github.com/jan/fm-rds-tx/internal/platform"
  16. )
  17. type EngineState int
  18. const (
  19. EngineIdle EngineState = iota
  20. EngineRunning
  21. EngineStopping
  22. )
  23. func (s EngineState) String() string {
  24. switch s {
  25. case EngineIdle:
  26. return "idle"
  27. case EngineRunning:
  28. return "running"
  29. case EngineStopping:
  30. return "stopping"
  31. default:
  32. return "unknown"
  33. }
  34. }
  35. func updateMaxDuration(dst *atomic.Uint64, d time.Duration) {
  36. v := uint64(d)
  37. for {
  38. cur := dst.Load()
  39. if v <= cur {
  40. return
  41. }
  42. if dst.CompareAndSwap(cur, v) {
  43. return
  44. }
  45. }
  46. }
  47. func durationMs(ns uint64) float64 {
  48. return float64(ns) / float64(time.Millisecond)
  49. }
  50. type EngineStats struct {
  51. State string `json:"state"`
  52. ChunksProduced uint64 `json:"chunksProduced"`
  53. TotalSamples uint64 `json:"totalSamples"`
  54. Underruns uint64 `json:"underruns"`
  55. LateBuffers uint64 `json:"lateBuffers,omitempty"`
  56. LastError string `json:"lastError,omitempty"`
  57. UptimeSeconds float64 `json:"uptimeSeconds"`
  58. MaxCycleMs float64 `json:"maxCycleMs,omitempty"`
  59. MaxGenerateMs float64 `json:"maxGenerateMs,omitempty"`
  60. MaxUpsampleMs float64 `json:"maxUpsampleMs,omitempty"`
  61. MaxWriteMs float64 `json:"maxWriteMs,omitempty"`
  62. Queue output.QueueStats `json:"queue"`
  63. RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"`
  64. RuntimeAlert string `json:"runtimeAlert,omitempty"`
  65. }
  66. type RuntimeIndicator string
  67. const (
  68. RuntimeIndicatorNormal RuntimeIndicator = "normal"
  69. RuntimeIndicatorDegraded RuntimeIndicator = "degraded"
  70. RuntimeIndicatorQueueCritical RuntimeIndicator = "queueCritical"
  71. )
  72. // Engine is the continuous TX loop. It generates composite IQ in chunks,
  73. // resamples to device rate, and pushes to hardware in a tight loop.
  74. // The hardware buffer_push call is blocking — it returns when the hardware
  75. // has consumed the previous buffer and is ready for the next one.
  76. // This naturally paces the loop to real-time without a ticker.
  77. type Engine struct {
  78. cfg cfgpkg.Config
  79. driver platform.SoapyDriver
  80. generator *offpkg.Generator
  81. upsampler *dsp.FMUpsampler // nil = same-rate, non-nil = split-rate
  82. chunkDuration time.Duration
  83. deviceRate float64
  84. frameQueue *output.FrameQueue
  85. mu sync.Mutex
  86. state EngineState
  87. cancel context.CancelFunc
  88. startedAt time.Time
  89. wg sync.WaitGroup
  90. chunksProduced atomic.Uint64
  91. totalSamples atomic.Uint64
  92. underruns atomic.Uint64
  93. lateBuffers atomic.Uint64
  94. maxCycleNs atomic.Uint64
  95. maxGenerateNs atomic.Uint64
  96. maxUpsampleNs atomic.Uint64
  97. maxWriteNs atomic.Uint64
  98. lastError atomic.Value // string
  99. // Live config: pending frequency change, applied between chunks
  100. pendingFreq atomic.Pointer[float64]
  101. // Live audio stream (optional)
  102. streamSrc *audio.StreamSource
  103. }
  104. // SetStreamSource configures a live audio stream as the audio source.
  105. // Must be called before Start(). The StreamResampler is created internally
  106. // to convert from the stream's sample rate to the DSP composite rate.
  107. func (e *Engine) SetStreamSource(src *audio.StreamSource) {
  108. e.streamSrc = src
  109. compositeRate := float64(e.cfg.FM.CompositeRateHz)
  110. if compositeRate <= 0 {
  111. compositeRate = 228000
  112. }
  113. resampler := audio.NewStreamResampler(src, compositeRate)
  114. e.generator.SetExternalSource(resampler)
  115. log.Printf("engine: live audio stream — %d Hz → %.0f Hz (buffer %d frames)",
  116. src.SampleRate, compositeRate, src.Stats().Capacity)
  117. }
  118. // StreamSource returns the live audio stream source, or nil.
  119. // Used by the control server for stats and HTTP audio ingest.
  120. func (e *Engine) StreamSource() *audio.StreamSource {
  121. return e.streamSrc
  122. }
  123. func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine {
  124. deviceRate := cfg.EffectiveDeviceRate()
  125. compositeRate := float64(cfg.FM.CompositeRateHz)
  126. if compositeRate <= 0 {
  127. compositeRate = 228000
  128. }
  129. var upsampler *dsp.FMUpsampler
  130. if deviceRate > compositeRate*1.001 {
  131. // Split-rate: DSP chain runs at compositeRate (typ. 228 kHz),
  132. // FMUpsampler handles FM modulation + interpolation to deviceRate.
  133. // This halves CPU load compared to running all DSP at deviceRate.
  134. cfg.FM.FMModulationEnabled = false
  135. maxDev := cfg.FM.MaxDeviationHz
  136. if maxDev <= 0 {
  137. maxDev = 75000
  138. }
  139. // mpxGain scales the FM deviation to compensate for hardware
  140. // DAC/SDR scaling factors. DSP chain stays at logical 0-1.0 levels.
  141. if cfg.FM.MpxGain > 0 && cfg.FM.MpxGain != 1.0 {
  142. maxDev *= cfg.FM.MpxGain
  143. }
  144. upsampler = dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev)
  145. log.Printf("engine: split-rate mode — DSP@%.0fHz → upsample@%.0fHz (ratio %.2f)",
  146. compositeRate, deviceRate, deviceRate/compositeRate)
  147. } else {
  148. // Same-rate: entire DSP chain runs at deviceRate.
  149. // Used when deviceRate ≈ compositeRate (e.g. LimeSDR at 228 kHz).
  150. if deviceRate > 0 {
  151. cfg.FM.CompositeRateHz = int(deviceRate)
  152. }
  153. cfg.FM.FMModulationEnabled = true
  154. log.Printf("engine: same-rate mode — DSP@%dHz", cfg.FM.CompositeRateHz)
  155. }
  156. return &Engine{
  157. cfg: cfg,
  158. driver: driver,
  159. generator: offpkg.NewGenerator(cfg),
  160. upsampler: upsampler,
  161. chunkDuration: 50 * time.Millisecond,
  162. deviceRate: deviceRate,
  163. state: EngineIdle,
  164. frameQueue: output.NewFrameQueue(cfg.Runtime.FrameQueueCapacity),
  165. }
  166. }
  167. func (e *Engine) SetChunkDuration(d time.Duration) {
  168. e.chunkDuration = d
  169. }
  170. // LiveConfigUpdate carries hot-reloadable parameters from the control API.
  171. // nil pointers mean "no change". Validated before applying.
  172. type LiveConfigUpdate struct {
  173. FrequencyMHz *float64
  174. OutputDrive *float64
  175. StereoEnabled *bool
  176. PilotLevel *float64
  177. RDSInjection *float64
  178. RDSEnabled *bool
  179. LimiterEnabled *bool
  180. LimiterCeiling *float64
  181. PS *string
  182. RadioText *string
  183. }
  184. // UpdateConfig applies live parameter changes without restarting the engine.
  185. // DSP params take effect at the next chunk boundary (~50ms max).
  186. // Frequency changes are applied between chunks via driver.Tune().
  187. // RDS text updates are applied at the next RDS group boundary (~88ms).
  188. func (e *Engine) UpdateConfig(u LiveConfigUpdate) error {
  189. // --- Validate ---
  190. if u.FrequencyMHz != nil {
  191. if *u.FrequencyMHz < 65 || *u.FrequencyMHz > 110 {
  192. return fmt.Errorf("frequencyMHz out of range (65-110)")
  193. }
  194. }
  195. if u.OutputDrive != nil {
  196. if *u.OutputDrive < 0 || *u.OutputDrive > 10 {
  197. return fmt.Errorf("outputDrive out of range (0-10)")
  198. }
  199. }
  200. if u.PilotLevel != nil {
  201. if *u.PilotLevel < 0 || *u.PilotLevel > 0.2 {
  202. return fmt.Errorf("pilotLevel out of range (0-0.2)")
  203. }
  204. }
  205. if u.RDSInjection != nil {
  206. if *u.RDSInjection < 0 || *u.RDSInjection > 0.15 {
  207. return fmt.Errorf("rdsInjection out of range (0-0.15)")
  208. }
  209. }
  210. if u.LimiterCeiling != nil {
  211. if *u.LimiterCeiling < 0 || *u.LimiterCeiling > 2 {
  212. return fmt.Errorf("limiterCeiling out of range (0-2)")
  213. }
  214. }
  215. // --- Frequency: store for run loop to apply via driver.Tune() ---
  216. if u.FrequencyMHz != nil {
  217. freqHz := *u.FrequencyMHz * 1e6
  218. e.pendingFreq.Store(&freqHz)
  219. }
  220. // --- RDS text: forward to encoder atomics ---
  221. if u.PS != nil || u.RadioText != nil {
  222. if enc := e.generator.RDSEncoder(); enc != nil {
  223. ps, rt := "", ""
  224. if u.PS != nil {
  225. ps = *u.PS
  226. }
  227. if u.RadioText != nil {
  228. rt = *u.RadioText
  229. }
  230. enc.UpdateText(ps, rt)
  231. }
  232. }
  233. // --- DSP params: build new LiveParams from current + patch ---
  234. // Read current, apply deltas, store new
  235. current := e.generator.CurrentLiveParams()
  236. next := current // copy
  237. if u.OutputDrive != nil {
  238. next.OutputDrive = *u.OutputDrive
  239. }
  240. if u.StereoEnabled != nil {
  241. next.StereoEnabled = *u.StereoEnabled
  242. }
  243. if u.PilotLevel != nil {
  244. next.PilotLevel = *u.PilotLevel
  245. }
  246. if u.RDSInjection != nil {
  247. next.RDSInjection = *u.RDSInjection
  248. }
  249. if u.RDSEnabled != nil {
  250. next.RDSEnabled = *u.RDSEnabled
  251. }
  252. if u.LimiterEnabled != nil {
  253. next.LimiterEnabled = *u.LimiterEnabled
  254. }
  255. if u.LimiterCeiling != nil {
  256. next.LimiterCeiling = *u.LimiterCeiling
  257. }
  258. e.generator.UpdateLive(next)
  259. return nil
  260. }
  261. func (e *Engine) Start(ctx context.Context) error {
  262. e.mu.Lock()
  263. if e.state != EngineIdle {
  264. e.mu.Unlock()
  265. return fmt.Errorf("engine already in state %s", e.state)
  266. }
  267. if err := e.driver.Start(ctx); err != nil {
  268. e.mu.Unlock()
  269. return fmt.Errorf("driver start: %w", err)
  270. }
  271. runCtx, cancel := context.WithCancel(ctx)
  272. e.cancel = cancel
  273. e.state = EngineRunning
  274. e.startedAt = time.Now()
  275. e.wg.Add(1)
  276. e.mu.Unlock()
  277. go e.run(runCtx)
  278. return nil
  279. }
  280. func (e *Engine) Stop(ctx context.Context) error {
  281. e.mu.Lock()
  282. if e.state != EngineRunning {
  283. e.mu.Unlock()
  284. return nil
  285. }
  286. e.state = EngineStopping
  287. e.cancel()
  288. e.mu.Unlock()
  289. // Wait for run() goroutine to exit — deterministic, no guessing
  290. e.wg.Wait()
  291. if err := e.driver.Flush(ctx); err != nil {
  292. return err
  293. }
  294. if err := e.driver.Stop(ctx); err != nil {
  295. return err
  296. }
  297. e.mu.Lock()
  298. e.state = EngineIdle
  299. e.mu.Unlock()
  300. return nil
  301. }
  302. func (e *Engine) Stats() EngineStats {
  303. e.mu.Lock()
  304. state := e.state
  305. startedAt := e.startedAt
  306. e.mu.Unlock()
  307. var uptime float64
  308. if state == EngineRunning {
  309. uptime = time.Since(startedAt).Seconds()
  310. }
  311. errVal, _ := e.lastError.Load().(string)
  312. queue := e.frameQueue.Stats()
  313. lateBuffers := e.lateBuffers.Load()
  314. ri := runtimeIndicator(queue.Health, lateBuffers)
  315. return EngineStats{
  316. State: state.String(),
  317. ChunksProduced: e.chunksProduced.Load(),
  318. TotalSamples: e.totalSamples.Load(),
  319. Underruns: e.underruns.Load(),
  320. LateBuffers: lateBuffers,
  321. LastError: errVal,
  322. UptimeSeconds: uptime,
  323. MaxCycleMs: durationMs(e.maxCycleNs.Load()),
  324. MaxGenerateMs: durationMs(e.maxGenerateNs.Load()),
  325. MaxUpsampleMs: durationMs(e.maxUpsampleNs.Load()),
  326. MaxWriteMs: durationMs(e.maxWriteNs.Load()),
  327. Queue: queue,
  328. RuntimeIndicator: ri,
  329. RuntimeAlert: runtimeAlert(queue.Health, lateBuffers),
  330. }
  331. }
  332. func runtimeIndicator(queueHealth output.QueueHealth, lateBuffers uint64) RuntimeIndicator {
  333. switch {
  334. case queueHealth == output.QueueHealthCritical:
  335. return RuntimeIndicatorQueueCritical
  336. case queueHealth == output.QueueHealthLow || lateBuffers > 0:
  337. return RuntimeIndicatorDegraded
  338. default:
  339. return RuntimeIndicatorNormal
  340. }
  341. }
  342. func runtimeAlert(queueHealth output.QueueHealth, lateBuffers uint64) string {
  343. switch {
  344. case queueHealth == output.QueueHealthCritical:
  345. return "queue health critical"
  346. case lateBuffers > 0:
  347. return "late buffers"
  348. case queueHealth == output.QueueHealthLow:
  349. return "queue health low"
  350. default:
  351. return ""
  352. }
  353. }
  354. func (e *Engine) run(ctx context.Context) {
  355. e.wg.Add(1)
  356. go e.writerLoop(ctx)
  357. defer e.wg.Done()
  358. for {
  359. if ctx.Err() != nil {
  360. return
  361. }
  362. // Apply pending frequency change between chunks
  363. if pf := e.pendingFreq.Swap(nil); pf != nil {
  364. if err := e.driver.Tune(ctx, *pf); err != nil {
  365. e.lastError.Store(fmt.Sprintf("tune: %v", err))
  366. } else {
  367. log.Printf("engine: tuned to %.3f MHz", *pf/1e6)
  368. }
  369. }
  370. t0 := time.Now()
  371. frame := e.generator.GenerateFrame(e.chunkDuration)
  372. frame.GeneratedAt = t0
  373. t1 := time.Now()
  374. if e.upsampler != nil {
  375. frame = e.upsampler.Process(frame)
  376. frame.GeneratedAt = t0
  377. }
  378. t2 := time.Now()
  379. genDur := t1.Sub(t0)
  380. upDur := t2.Sub(t1)
  381. updateMaxDuration(&e.maxGenerateNs, genDur)
  382. updateMaxDuration(&e.maxUpsampleNs, upDur)
  383. enqueued := cloneFrame(frame)
  384. if enqueued == nil {
  385. e.lastError.Store("engine: frame clone failed")
  386. e.underruns.Add(1)
  387. continue
  388. }
  389. if err := e.frameQueue.Push(ctx, enqueued); err != nil {
  390. if ctx.Err() != nil {
  391. return
  392. }
  393. if errors.Is(err, output.ErrFrameQueueClosed) {
  394. return
  395. }
  396. e.lastError.Store(err.Error())
  397. e.underruns.Add(1)
  398. select {
  399. case <-time.After(e.chunkDuration):
  400. case <-ctx.Done():
  401. return
  402. }
  403. continue
  404. }
  405. }
  406. }
  407. func (e *Engine) writerLoop(ctx context.Context) {
  408. defer e.wg.Done()
  409. for {
  410. frame, err := e.frameQueue.Pop(ctx)
  411. if err != nil {
  412. if ctx.Err() != nil {
  413. return
  414. }
  415. if errors.Is(err, output.ErrFrameQueueClosed) {
  416. return
  417. }
  418. e.lastError.Store(err.Error())
  419. e.underruns.Add(1)
  420. continue
  421. }
  422. writeStart := time.Now()
  423. n, err := e.driver.Write(ctx, frame)
  424. writeDur := time.Since(writeStart)
  425. cycleDur := writeDur
  426. if !frame.GeneratedAt.IsZero() {
  427. cycleDur = time.Since(frame.GeneratedAt)
  428. }
  429. updateMaxDuration(&e.maxWriteNs, writeDur)
  430. updateMaxDuration(&e.maxCycleNs, cycleDur)
  431. if cycleDur > e.chunkDuration {
  432. late := e.lateBuffers.Add(1)
  433. if late <= 5 || late%20 == 0 {
  434. log.Printf("TX LATE: cycle=%s budget=%s write=%s over=%s",
  435. cycleDur, e.chunkDuration, writeDur, cycleDur-e.chunkDuration)
  436. }
  437. }
  438. if err != nil {
  439. if ctx.Err() != nil {
  440. return
  441. }
  442. e.lastError.Store(err.Error())
  443. e.underruns.Add(1)
  444. select {
  445. case <-time.After(e.chunkDuration):
  446. case <-ctx.Done():
  447. return
  448. }
  449. continue
  450. }
  451. e.chunksProduced.Add(1)
  452. e.totalSamples.Add(uint64(n))
  453. }
  454. }
  455. func cloneFrame(src *output.CompositeFrame) *output.CompositeFrame {
  456. if src == nil {
  457. return nil
  458. }
  459. samples := make([]output.IQSample, len(src.Samples))
  460. copy(samples, src.Samples)
  461. return &output.CompositeFrame{
  462. Samples: samples,
  463. SampleRateHz: src.SampleRateHz,
  464. Timestamp: src.Timestamp,
  465. GeneratedAt: src.GeneratedAt,
  466. Sequence: src.Sequence,
  467. }
  468. }