Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

187 lignes
4.1KB

  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. type EngineState int
  13. const (
  14. EngineIdle EngineState = iota
  15. EngineRunning
  16. EngineStopping
  17. )
  18. func (s EngineState) String() string {
  19. switch s {
  20. case EngineIdle:
  21. return "idle"
  22. case EngineRunning:
  23. return "running"
  24. case EngineStopping:
  25. return "stopping"
  26. default:
  27. return "unknown"
  28. }
  29. }
  30. type EngineStats struct {
  31. State string `json:"state"`
  32. ChunksProduced uint64 `json:"chunksProduced"`
  33. TotalSamples uint64 `json:"totalSamples"`
  34. Underruns uint64 `json:"underruns"`
  35. LastError string `json:"lastError,omitempty"`
  36. UptimeSeconds float64 `json:"uptimeSeconds"`
  37. }
  38. // Engine is the continuous TX loop. It generates composite IQ in chunks,
  39. // resamples to device rate, and pushes to hardware in a tight loop.
  40. // The hardware buffer_push call is blocking — it returns when the hardware
  41. // has consumed the previous buffer and is ready for the next one.
  42. // This naturally paces the loop to real-time without a ticker.
  43. type Engine struct {
  44. cfg cfgpkg.Config
  45. driver platform.SoapyDriver
  46. generator *offpkg.Generator
  47. chunkDuration time.Duration
  48. deviceRate float64
  49. mu sync.Mutex
  50. state EngineState
  51. cancel context.CancelFunc
  52. startedAt time.Time
  53. wg sync.WaitGroup
  54. chunksProduced atomic.Uint64
  55. totalSamples atomic.Uint64
  56. underruns atomic.Uint64
  57. lastError atomic.Value // string
  58. }
  59. func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine {
  60. // Run entire DSP chain at device rate. RDS encoder resamples its
  61. // PiFmRds waveform internally. No phase upsampling needed.
  62. deviceRate := cfg.EffectiveDeviceRate()
  63. if deviceRate > 0 {
  64. cfg.FM.CompositeRateHz = int(deviceRate)
  65. }
  66. cfg.FM.FMModulationEnabled = true
  67. return &Engine{
  68. cfg: cfg,
  69. driver: driver,
  70. generator: offpkg.NewGenerator(cfg),
  71. chunkDuration: 50 * time.Millisecond,
  72. deviceRate: deviceRate,
  73. state: EngineIdle,
  74. }
  75. }
  76. func (e *Engine) SetChunkDuration(d time.Duration) {
  77. e.chunkDuration = d
  78. }
  79. func (e *Engine) Start(ctx context.Context) error {
  80. e.mu.Lock()
  81. if e.state != EngineIdle {
  82. e.mu.Unlock()
  83. return fmt.Errorf("engine already in state %s", e.state)
  84. }
  85. if err := e.driver.Start(ctx); err != nil {
  86. e.mu.Unlock()
  87. return fmt.Errorf("driver start: %w", err)
  88. }
  89. runCtx, cancel := context.WithCancel(ctx)
  90. e.cancel = cancel
  91. e.state = EngineRunning
  92. e.startedAt = time.Now()
  93. e.wg.Add(1)
  94. e.mu.Unlock()
  95. go e.run(runCtx)
  96. return nil
  97. }
  98. func (e *Engine) Stop(ctx context.Context) error {
  99. e.mu.Lock()
  100. if e.state != EngineRunning {
  101. e.mu.Unlock()
  102. return nil
  103. }
  104. e.state = EngineStopping
  105. e.cancel()
  106. e.mu.Unlock()
  107. // Wait for run() goroutine to exit — deterministic, no guessing
  108. e.wg.Wait()
  109. if err := e.driver.Flush(ctx); err != nil {
  110. return err
  111. }
  112. if err := e.driver.Stop(ctx); err != nil {
  113. return err
  114. }
  115. e.mu.Lock()
  116. e.state = EngineIdle
  117. e.mu.Unlock()
  118. return nil
  119. }
  120. func (e *Engine) Stats() EngineStats {
  121. e.mu.Lock()
  122. state := e.state
  123. startedAt := e.startedAt
  124. e.mu.Unlock()
  125. var uptime float64
  126. if state == EngineRunning {
  127. uptime = time.Since(startedAt).Seconds()
  128. }
  129. errVal, _ := e.lastError.Load().(string)
  130. return EngineStats{
  131. State: state.String(),
  132. ChunksProduced: e.chunksProduced.Load(),
  133. TotalSamples: e.totalSamples.Load(),
  134. Underruns: e.underruns.Load(),
  135. LastError: errVal,
  136. UptimeSeconds: uptime,
  137. }
  138. }
  139. func (e *Engine) run(ctx context.Context) {
  140. defer e.wg.Done()
  141. for {
  142. if ctx.Err() != nil {
  143. return
  144. }
  145. frame := e.generator.GenerateFrame(e.chunkDuration)
  146. n, err := e.driver.Write(ctx, frame)
  147. if err != nil {
  148. if ctx.Err() != nil { return }
  149. e.lastError.Store(err.Error())
  150. e.underruns.Add(1)
  151. // Back off to avoid pegging CPU on persistent errors
  152. select {
  153. case <-time.After(e.chunkDuration):
  154. case <-ctx.Done():
  155. return
  156. }
  157. continue
  158. }
  159. e.chunksProduced.Add(1)
  160. e.totalSamples.Add(uint64(n))
  161. }
  162. }