Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

296 line
9.3KB

  1. package main
  2. import (
  3. "context"
  4. "flag"
  5. "fmt"
  6. "log"
  7. "net/http"
  8. "os"
  9. "os/signal"
  10. "syscall"
  11. "time"
  12. apppkg "github.com/jan/fm-rds-tx/internal/app"
  13. "github.com/jan/fm-rds-tx/internal/audio"
  14. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  15. ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
  16. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  17. "github.com/jan/fm-rds-tx/internal/platform"
  18. "github.com/jan/fm-rds-tx/internal/platform/plutosdr"
  19. "github.com/jan/fm-rds-tx/internal/platform/soapysdr"
  20. )
  21. func main() {
  22. configPath := flag.String("config", "", "path to JSON config")
  23. printConfig := flag.Bool("print-config", false, "print effective config and exit")
  24. dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output")
  25. dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout")
  26. simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path")
  27. simulateOutput := flag.String("simulate-output", "", "simulated transmit output file")
  28. simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
  29. txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)")
  30. txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch")
  31. listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit")
  32. audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin")
  33. audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)")
  34. audioHTTP := flag.Bool("audio-http", false, "enable HTTP audio ingest via /audio/stream")
  35. flag.Parse()
  36. // --- list-devices (SoapySDR) ---
  37. if *listDevices {
  38. devices, err := soapysdr.Enumerate()
  39. if err != nil {
  40. log.Fatalf("enumerate: %v", err)
  41. }
  42. if len(devices) == 0 {
  43. fmt.Println("no SoapySDR devices found")
  44. return
  45. }
  46. for i, dev := range devices {
  47. fmt.Printf("device %d:\n", i)
  48. for k, v := range dev {
  49. fmt.Printf(" %s = %s\n", k, v)
  50. }
  51. }
  52. return
  53. }
  54. cfg, err := cfgpkg.Load(*configPath)
  55. if err != nil {
  56. log.Fatalf("load config: %v", err)
  57. }
  58. // --- print-config ---
  59. if *printConfig {
  60. preemph := "off"
  61. if cfg.FM.PreEmphasisTauUS > 0 {
  62. preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS)
  63. }
  64. fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz compositeRate=%dHz deviceRate=%.0fHz listen=%s pluto=%t soapy=%t\n",
  65. cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled,
  66. preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz,
  67. cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress,
  68. plutosdr.Available(), soapysdr.Available())
  69. return
  70. }
  71. // --- dry-run ---
  72. if *dryRun {
  73. frame := drypkg.Generate(cfg)
  74. if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
  75. log.Fatalf("dry-run: %v", err)
  76. }
  77. if *dryOutput != "" && *dryOutput != "-" {
  78. fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput)
  79. }
  80. return
  81. }
  82. // --- simulate ---
  83. if *simulate {
  84. summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration)
  85. if err != nil {
  86. log.Fatalf("simulate-tx: %v", err)
  87. }
  88. fmt.Println(summary)
  89. return
  90. }
  91. // --- TX mode ---
  92. if *txMode {
  93. driver := selectDriver(cfg)
  94. if driver == nil {
  95. log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)")
  96. }
  97. runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP)
  98. return
  99. }
  100. // --- default: HTTP only ---
  101. srv := ctrlpkg.NewServer(cfg)
  102. log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", cfg.Control.ListenAddress)
  103. log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()))
  104. }
  105. // selectDriver picks the best available driver based on config and build tags.
  106. func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
  107. kind := cfg.Backend.Kind
  108. // Explicit PlutoSDR
  109. if kind == "pluto" || kind == "plutosdr" {
  110. if plutosdr.Available() {
  111. return plutosdr.NewPlutoDriver()
  112. }
  113. log.Printf("warning: backend=%s but pluto driver not available (%s)", kind, plutosdr.AvailableError())
  114. }
  115. // Explicit SoapySDR
  116. if kind == "soapy" || kind == "soapysdr" {
  117. if soapysdr.Available() {
  118. return soapysdr.NewNativeDriver()
  119. }
  120. log.Printf("warning: backend=%s but soapy driver not available", kind)
  121. }
  122. // Auto-detect: prefer PlutoSDR, fall back to SoapySDR
  123. if plutosdr.Available() {
  124. log.Println("auto-selected: pluto-iio driver")
  125. return plutosdr.NewPlutoDriver()
  126. }
  127. if soapysdr.Available() {
  128. log.Println("auto-selected: soapy-native driver")
  129. return soapysdr.NewNativeDriver()
  130. }
  131. return nil
  132. }
  133. func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) {
  134. ctx, cancel := context.WithCancel(context.Background())
  135. defer cancel()
  136. // Configure driver
  137. // OutputDrive controls composite signal level, NOT hardware gain.
  138. // Hardware TX gain is always 0 dB (max power). Use external attenuator for power control.
  139. soapyCfg := platform.SoapyConfig{
  140. Driver: cfg.Backend.Driver,
  141. Device: cfg.Backend.Device,
  142. CenterFreqHz: cfg.FM.FrequencyMHz * 1e6,
  143. GainDB: 0, // 0 dB = max TX power on PlutoSDR
  144. DeviceArgs: map[string]string{},
  145. }
  146. if cfg.Backend.URI != "" {
  147. soapyCfg.DeviceArgs["uri"] = cfg.Backend.URI
  148. }
  149. for k, v := range cfg.Backend.DeviceArgs {
  150. soapyCfg.DeviceArgs[k] = v
  151. }
  152. soapyCfg.SampleRateHz = cfg.EffectiveDeviceRate()
  153. log.Printf("TX: configuring %s freq=%.3fMHz rate=%.0fHz gain=%.1fdB",
  154. driver.Name(), cfg.FM.FrequencyMHz, soapyCfg.SampleRateHz, soapyCfg.GainDB)
  155. if err := driver.Configure(ctx, soapyCfg); err != nil {
  156. log.Fatalf("configure: %v", err)
  157. }
  158. caps, err := driver.Capabilities(ctx)
  159. if err == nil {
  160. log.Printf("TX: device caps: gain=%.0f..%.0f dB, rate=%.0f..%.0f Hz",
  161. caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate)
  162. }
  163. // Engine
  164. engine := apppkg.NewEngine(cfg, driver)
  165. // Live audio stream source (optional)
  166. var streamSrc *audio.StreamSource
  167. if audioStdin || audioHTTP {
  168. // Buffer: 2 seconds at input rate — enough to absorb jitter
  169. bufferFrames := audioRate * 2
  170. if bufferFrames <= 0 {
  171. bufferFrames = 1
  172. }
  173. streamSrc = audio.NewStreamSource(bufferFrames, audioRate)
  174. engine.SetStreamSource(streamSrc)
  175. if audioStdin {
  176. go func() {
  177. log.Printf("audio: reading S16LE stereo PCM from stdin at %d Hz", audioRate)
  178. if err := audio.IngestReader(os.Stdin, streamSrc); err != nil {
  179. log.Printf("audio: stdin ingest ended: %v", err)
  180. } else {
  181. log.Println("audio: stdin EOF")
  182. }
  183. }()
  184. }
  185. if audioHTTP {
  186. log.Printf("audio: HTTP ingest enabled on /audio/stream (rate=%dHz, buffer=%d frames)", audioRate, streamSrc.Stats().Capacity)
  187. }
  188. }
  189. // Control plane
  190. srv := ctrlpkg.NewServer(cfg)
  191. srv.SetDriver(driver)
  192. srv.SetTXController(&txBridge{engine: engine})
  193. if streamSrc != nil {
  194. srv.SetStreamSource(streamSrc)
  195. }
  196. if autoStart {
  197. log.Println("TX: auto-start enabled")
  198. if err := engine.Start(ctx); err != nil {
  199. log.Fatalf("engine start: %v", err)
  200. }
  201. log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate())
  202. } else {
  203. log.Println("TX ready (idle) — POST /tx/start to begin")
  204. }
  205. go func() {
  206. log.Printf("control plane on %s", cfg.Control.ListenAddress)
  207. if err := http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()); err != nil {
  208. log.Printf("http: %v", err)
  209. }
  210. }()
  211. sigCh := make(chan os.Signal, 1)
  212. signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
  213. sig := <-sigCh
  214. log.Printf("received %s, shutting down...", sig)
  215. _ = engine.Stop(ctx)
  216. _ = driver.Close(ctx)
  217. log.Println("shutdown complete")
  218. }
  219. type txBridge struct{ engine *apppkg.Engine }
  220. func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) }
  221. func (b *txBridge) StopTX() error { return b.engine.Stop(context.Background()) }
  222. func (b *txBridge) TXStats() map[string]any {
  223. s := b.engine.Stats()
  224. return map[string]any{
  225. "state": s.State,
  226. "chunksProduced": s.ChunksProduced,
  227. "totalSamples": s.TotalSamples,
  228. "underruns": s.Underruns,
  229. "lateBuffers": s.LateBuffers,
  230. "lastError": s.LastError,
  231. "uptimeSeconds": s.UptimeSeconds,
  232. "maxCycleMs": s.MaxCycleMs,
  233. "maxGenerateMs": s.MaxGenerateMs,
  234. "maxUpsampleMs": s.MaxUpsampleMs,
  235. "maxWriteMs": s.MaxWriteMs,
  236. "queue": s.Queue,
  237. "runtimeIndicator": s.RuntimeIndicator,
  238. "runtimeAlert": s.RuntimeAlert,
  239. "degradedTransitions": s.DegradedTransitions,
  240. "mutedTransitions": s.MutedTransitions,
  241. "faultedTransitions": s.FaultedTransitions,
  242. "faultCount": s.FaultCount,
  243. "faultHistory": s.FaultHistory,
  244. "transitionHistory": s.TransitionHistory,
  245. "lastFault": s.LastFault,
  246. }
  247. }
  248. func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error {
  249. return b.engine.UpdateConfig(apppkg.LiveConfigUpdate{
  250. FrequencyMHz: lp.FrequencyMHz,
  251. OutputDrive: lp.OutputDrive,
  252. StereoEnabled: lp.StereoEnabled,
  253. PilotLevel: lp.PilotLevel,
  254. RDSInjection: lp.RDSInjection,
  255. RDSEnabled: lp.RDSEnabled,
  256. LimiterEnabled: lp.LimiterEnabled,
  257. LimiterCeiling: lp.LimiterCeiling,
  258. PS: lp.PS,
  259. RadioText: lp.RadioText,
  260. })
  261. }
  262. func (b *txBridge) ResetFault() error {
  263. return b.engine.ResetFault()
  264. }