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.

180 líneas
3.9KB

  1. package app
  2. import (
  3. "context"
  4. "fmt"
  5. "sync"
  6. "sync/atomic"
  7. "time"
  8. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  9. offpkg "github.com/jan/fm-rds-tx/internal/offline"
  10. "github.com/jan/fm-rds-tx/internal/platform"
  11. )
  12. // EngineState represents the current state of the TX engine.
  13. type EngineState int
  14. const (
  15. EngineIdle EngineState = iota
  16. EngineRunning
  17. EngineStopping
  18. )
  19. func (s EngineState) String() string {
  20. switch s {
  21. case EngineIdle:
  22. return "idle"
  23. case EngineRunning:
  24. return "running"
  25. case EngineStopping:
  26. return "stopping"
  27. default:
  28. return "unknown"
  29. }
  30. }
  31. // EngineStats exposes runtime telemetry from the engine.
  32. type EngineStats struct {
  33. State string `json:"state"`
  34. ChunksProduced uint64 `json:"chunksProduced"`
  35. TotalSamples uint64 `json:"totalSamples"`
  36. Underruns uint64 `json:"underruns"`
  37. LastError string `json:"lastError,omitempty"`
  38. UptimeSeconds float64 `json:"uptimeSeconds"`
  39. }
  40. // Engine is the continuous TX loop that produces chunks of composite/IQ
  41. // samples and feeds them to a backend driver.
  42. type Engine struct {
  43. cfg cfgpkg.Config
  44. driver platform.SoapyDriver
  45. generator *offpkg.Generator
  46. chunkDuration time.Duration
  47. mu sync.Mutex
  48. state EngineState
  49. cancel context.CancelFunc
  50. startedAt time.Time
  51. chunksProduced atomic.Uint64
  52. totalSamples atomic.Uint64
  53. underruns atomic.Uint64
  54. lastError atomic.Value // string
  55. }
  56. // NewEngine creates a TX engine. Default chunk duration is 50ms.
  57. func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine {
  58. return &Engine{
  59. cfg: cfg,
  60. driver: driver,
  61. generator: offpkg.NewGenerator(cfg),
  62. chunkDuration: 50 * time.Millisecond,
  63. state: EngineIdle,
  64. }
  65. }
  66. // SetChunkDuration changes the generation chunk size. Must be called before Start.
  67. func (e *Engine) SetChunkDuration(d time.Duration) {
  68. e.chunkDuration = d
  69. }
  70. // Start begins continuous transmission. TX is NOT started automatically.
  71. func (e *Engine) Start(ctx context.Context) error {
  72. e.mu.Lock()
  73. if e.state != EngineIdle {
  74. e.mu.Unlock()
  75. return fmt.Errorf("engine already in state %s", e.state)
  76. }
  77. if err := e.driver.Start(ctx); err != nil {
  78. e.mu.Unlock()
  79. return fmt.Errorf("driver start: %w", err)
  80. }
  81. runCtx, cancel := context.WithCancel(ctx)
  82. e.cancel = cancel
  83. e.state = EngineRunning
  84. e.startedAt = time.Now()
  85. e.mu.Unlock()
  86. go e.run(runCtx)
  87. return nil
  88. }
  89. // Stop gracefully stops the TX engine.
  90. func (e *Engine) Stop(ctx context.Context) error {
  91. e.mu.Lock()
  92. if e.state != EngineRunning {
  93. e.mu.Unlock()
  94. return nil
  95. }
  96. e.state = EngineStopping
  97. e.cancel()
  98. e.mu.Unlock()
  99. // Give the run loop time to drain
  100. time.Sleep(e.chunkDuration * 2)
  101. if err := e.driver.Flush(ctx); err != nil {
  102. return err
  103. }
  104. if err := e.driver.Stop(ctx); err != nil {
  105. return err
  106. }
  107. e.mu.Lock()
  108. e.state = EngineIdle
  109. e.mu.Unlock()
  110. return nil
  111. }
  112. // Stats returns current engine telemetry.
  113. func (e *Engine) Stats() EngineStats {
  114. e.mu.Lock()
  115. state := e.state
  116. startedAt := e.startedAt
  117. e.mu.Unlock()
  118. var uptime float64
  119. if state == EngineRunning {
  120. uptime = time.Since(startedAt).Seconds()
  121. }
  122. errVal, _ := e.lastError.Load().(string)
  123. return EngineStats{
  124. State: state.String(),
  125. ChunksProduced: e.chunksProduced.Load(),
  126. TotalSamples: e.totalSamples.Load(),
  127. Underruns: e.underruns.Load(),
  128. LastError: errVal,
  129. UptimeSeconds: uptime,
  130. }
  131. }
  132. func (e *Engine) run(ctx context.Context) {
  133. ticker := time.NewTicker(e.chunkDuration)
  134. defer ticker.Stop()
  135. for {
  136. select {
  137. case <-ctx.Done():
  138. return
  139. case <-ticker.C:
  140. frame := e.generator.GenerateFrame(e.chunkDuration)
  141. n, err := e.driver.Write(ctx, frame)
  142. if err != nil {
  143. if ctx.Err() != nil {
  144. return // clean shutdown
  145. }
  146. e.lastError.Store(err.Error())
  147. e.underruns.Add(1)
  148. continue
  149. }
  150. e.chunksProduced.Add(1)
  151. e.totalSamples.Add(uint64(n))
  152. }
  153. }
  154. }