Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

338 строки
11KB

  1. package main
  2. import (
  3. "context"
  4. "flag"
  5. "fmt"
  6. "log"
  7. "os"
  8. "os/signal"
  9. "syscall"
  10. "time"
  11. apppkg "github.com/jan/fm-rds-tx/internal/app"
  12. "github.com/jan/fm-rds-tx/internal/audio"
  13. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  14. ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
  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/ingest/adapters/httpraw"
  18. "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm"
  19. "github.com/jan/fm-rds-tx/internal/platform"
  20. "github.com/jan/fm-rds-tx/internal/platform/plutosdr"
  21. "github.com/jan/fm-rds-tx/internal/platform/soapysdr"
  22. )
  23. func main() {
  24. configPath := flag.String("config", "", "path to JSON config")
  25. printConfig := flag.Bool("print-config", false, "print effective config and exit")
  26. dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output")
  27. dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout")
  28. simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path")
  29. simulateOutput := flag.String("simulate-output", "", "simulated transmit output file")
  30. simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
  31. txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)")
  32. txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch")
  33. listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit")
  34. audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin")
  35. audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)")
  36. audioHTTP := flag.Bool("audio-http", false, "enable HTTP audio ingest via /audio/stream")
  37. flag.Parse()
  38. if *listDevices {
  39. devices, err := soapysdr.Enumerate()
  40. if err != nil {
  41. log.Fatalf("enumerate: %v", err)
  42. }
  43. if len(devices) == 0 {
  44. fmt.Println("no SoapySDR devices found")
  45. return
  46. }
  47. for i, dev := range devices {
  48. fmt.Printf("device %d:\n", i)
  49. for k, v := range dev {
  50. fmt.Printf(" %s = %s\n", k, v)
  51. }
  52. }
  53. return
  54. }
  55. cfg, err := cfgpkg.Load(*configPath)
  56. if err != nil {
  57. log.Fatalf("load config: %v", err)
  58. }
  59. if *printConfig {
  60. preemph := "off"
  61. if cfg.FM.PreEmphasisTauUS > 0 {
  62. preemph = fmt.Sprintf("%.0fus", 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. if *dryRun {
  72. frame := drypkg.Generate(cfg)
  73. if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
  74. log.Fatalf("dry-run: %v", err)
  75. }
  76. if *dryOutput != "" && *dryOutput != "-" {
  77. fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput)
  78. }
  79. return
  80. }
  81. if *simulate {
  82. summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration)
  83. if err != nil {
  84. log.Fatalf("simulate-tx: %v", err)
  85. }
  86. fmt.Println(summary)
  87. return
  88. }
  89. if *txMode {
  90. driver := selectDriver(cfg)
  91. if driver == nil {
  92. log.Fatal("no hardware driver available - build with -tags pluto (or -tags soapy)")
  93. }
  94. runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP)
  95. return
  96. }
  97. srv := ctrlpkg.NewServer(cfg)
  98. server := ctrlpkg.NewHTTPServer(cfg, srv.Handler())
  99. log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr)
  100. log.Fatal(server.ListenAndServe())
  101. }
  102. func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
  103. kind := cfg.Backend.Kind
  104. if kind == "pluto" || kind == "plutosdr" {
  105. if plutosdr.Available() {
  106. return plutosdr.NewPlutoDriver()
  107. }
  108. log.Printf("warning: backend=%s but pluto driver not available (%s)", kind, plutosdr.AvailableError())
  109. }
  110. if kind == "soapy" || kind == "soapysdr" {
  111. if soapysdr.Available() {
  112. return soapysdr.NewNativeDriver()
  113. }
  114. log.Printf("warning: backend=%s but soapy driver not available", kind)
  115. }
  116. if plutosdr.Available() {
  117. log.Println("auto-selected: pluto-iio driver")
  118. return plutosdr.NewPlutoDriver()
  119. }
  120. if soapysdr.Available() {
  121. log.Println("auto-selected: soapy-native driver")
  122. return soapysdr.NewNativeDriver()
  123. }
  124. return nil
  125. }
  126. func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) {
  127. ctx, cancel := context.WithCancel(context.Background())
  128. defer cancel()
  129. soapyCfg := platform.SoapyConfig{
  130. Driver: cfg.Backend.Driver,
  131. Device: cfg.Backend.Device,
  132. CenterFreqHz: cfg.FM.FrequencyMHz * 1e6,
  133. GainDB: 0,
  134. DeviceArgs: map[string]string{},
  135. }
  136. if cfg.Backend.URI != "" {
  137. soapyCfg.DeviceArgs["uri"] = cfg.Backend.URI
  138. }
  139. for k, v := range cfg.Backend.DeviceArgs {
  140. soapyCfg.DeviceArgs[k] = v
  141. }
  142. soapyCfg.SampleRateHz = cfg.EffectiveDeviceRate()
  143. log.Printf("TX: configuring %s freq=%.3fMHz rate=%.0fHz gain=%.1fdB",
  144. driver.Name(), cfg.FM.FrequencyMHz, soapyCfg.SampleRateHz, soapyCfg.GainDB)
  145. if err := driver.Configure(ctx, soapyCfg); err != nil {
  146. log.Fatalf("configure: %v", err)
  147. }
  148. caps, err := driver.Capabilities(ctx)
  149. if err == nil {
  150. log.Printf("TX: device caps: gain=%.0f..%.0f dB, rate=%.0f..%.0f Hz",
  151. caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate)
  152. }
  153. engine := apppkg.NewEngine(cfg, driver)
  154. cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP)
  155. var streamSrc *audio.StreamSource
  156. var ingestRuntime *ingest.Runtime
  157. var ingress ctrlpkg.AudioIngress
  158. if cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" {
  159. rate := ingestSampleRate(cfg)
  160. bufferFrames := rate * 2
  161. if bufferFrames <= 0 {
  162. bufferFrames = 1
  163. }
  164. streamSrc = audio.NewStreamSource(bufferFrames, rate)
  165. engine.SetStreamSource(streamSrc)
  166. source, sourceIngress, err := buildPhase1Source(cfg)
  167. if err != nil {
  168. log.Fatalf("ingest source: %v", err)
  169. }
  170. ingestRuntime = ingest.NewRuntime(streamSrc, source)
  171. if err := ingestRuntime.Start(ctx); err != nil {
  172. log.Fatalf("ingest start: %v", err)
  173. }
  174. ingress = sourceIngress
  175. log.Printf("ingest: kind=%s rate=%dHz buffer=%d frames", cfg.Ingest.Kind, rate, streamSrc.Stats().Capacity)
  176. }
  177. srv := ctrlpkg.NewServer(cfg)
  178. srv.SetDriver(driver)
  179. srv.SetTXController(&txBridge{engine: engine})
  180. if streamSrc != nil {
  181. srv.SetStreamSource(streamSrc)
  182. }
  183. if ingress != nil {
  184. srv.SetAudioIngress(ingress)
  185. }
  186. if ingestRuntime != nil {
  187. srv.SetIngestRuntime(ingestRuntime)
  188. }
  189. if autoStart {
  190. log.Println("TX: auto-start enabled")
  191. if err := engine.Start(ctx); err != nil {
  192. log.Fatalf("engine start: %v", err)
  193. }
  194. log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate())
  195. } else {
  196. log.Println("TX ready (idle) - POST /tx/start to begin")
  197. }
  198. ctrlServer := ctrlpkg.NewHTTPServer(cfg, srv.Handler())
  199. go func() {
  200. log.Printf("control plane on %s (read=%s write=%s idle=%s)", ctrlServer.Addr, ctrlServer.ReadTimeout, ctrlServer.WriteTimeout, ctrlServer.IdleTimeout)
  201. if err := ctrlServer.ListenAndServe(); err != nil {
  202. log.Printf("http: %v", err)
  203. }
  204. }()
  205. sigCh := make(chan os.Signal, 1)
  206. signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
  207. sig := <-sigCh
  208. log.Printf("received %s, shutting down...", sig)
  209. _ = engine.Stop(ctx)
  210. if ingestRuntime != nil {
  211. _ = ingestRuntime.Stop()
  212. }
  213. _ = driver.Close(ctx)
  214. log.Println("shutdown complete")
  215. }
  216. func applyLegacyAudioFlags(cfg cfgpkg.Config, audioStdin bool, audioRate int, audioHTTP bool) cfgpkg.Config {
  217. if audioRate > 0 {
  218. cfg.Ingest.Stdin.SampleRateHz = audioRate
  219. cfg.Ingest.HTTPRaw.SampleRateHz = audioRate
  220. }
  221. if audioStdin && audioHTTP {
  222. log.Printf("audio: both --audio-stdin and --audio-http set; using ingest kind=stdin")
  223. }
  224. if audioStdin {
  225. cfg.Ingest.Kind = "stdin"
  226. }
  227. if audioHTTP && !audioStdin {
  228. cfg.Ingest.Kind = "http-raw"
  229. }
  230. return cfg
  231. }
  232. func ingestSampleRate(cfg cfgpkg.Config) int {
  233. switch cfg.Ingest.Kind {
  234. case "stdin", "stdin-pcm":
  235. return cfg.Ingest.Stdin.SampleRateHz
  236. case "http-raw":
  237. return cfg.Ingest.HTTPRaw.SampleRateHz
  238. default:
  239. return 44100
  240. }
  241. }
  242. func buildPhase1Source(cfg cfgpkg.Config) (ingest.Source, ctrlpkg.AudioIngress, error) {
  243. switch cfg.Ingest.Kind {
  244. case "stdin", "stdin-pcm":
  245. src := stdinpcm.New("stdin-main", os.Stdin, cfg.Ingest.Stdin.SampleRateHz, cfg.Ingest.Stdin.Channels, 1024)
  246. return src, nil, nil
  247. case "http-raw":
  248. src := httpraw.New("http-raw-main", cfg.Ingest.HTTPRaw.SampleRateHz, cfg.Ingest.HTTPRaw.Channels)
  249. return src, src, nil
  250. case "", "none":
  251. return nil, nil, nil
  252. default:
  253. return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind)
  254. }
  255. }
  256. type txBridge struct{ engine *apppkg.Engine }
  257. func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) }
  258. func (b *txBridge) StopTX() error { return b.engine.Stop(context.Background()) }
  259. func (b *txBridge) TXStats() map[string]any {
  260. s := b.engine.Stats()
  261. return map[string]any{
  262. "runtimeStateDurationSeconds": s.RuntimeStateDurationSeconds,
  263. "state": s.State,
  264. "chunksProduced": s.ChunksProduced,
  265. "totalSamples": s.TotalSamples,
  266. "underruns": s.Underruns,
  267. "lateBuffers": s.LateBuffers,
  268. "lastError": s.LastError,
  269. "uptimeSeconds": s.UptimeSeconds,
  270. "maxCycleMs": s.MaxCycleMs,
  271. "maxGenerateMs": s.MaxGenerateMs,
  272. "maxUpsampleMs": s.MaxUpsampleMs,
  273. "maxWriteMs": s.MaxWriteMs,
  274. "queue": s.Queue,
  275. "runtimeIndicator": s.RuntimeIndicator,
  276. "runtimeAlert": s.RuntimeAlert,
  277. "appliedFrequencyMHz": s.AppliedFrequencyMHz,
  278. "degradedTransitions": s.DegradedTransitions,
  279. "mutedTransitions": s.MutedTransitions,
  280. "faultedTransitions": s.FaultedTransitions,
  281. "faultCount": s.FaultCount,
  282. "faultHistory": s.FaultHistory,
  283. "transitionHistory": s.TransitionHistory,
  284. "lastFault": s.LastFault,
  285. }
  286. }
  287. func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error {
  288. return b.engine.UpdateConfig(apppkg.LiveConfigUpdate{
  289. FrequencyMHz: lp.FrequencyMHz,
  290. OutputDrive: lp.OutputDrive,
  291. StereoEnabled: lp.StereoEnabled,
  292. PilotLevel: lp.PilotLevel,
  293. RDSInjection: lp.RDSInjection,
  294. RDSEnabled: lp.RDSEnabled,
  295. LimiterEnabled: lp.LimiterEnabled,
  296. LimiterCeiling: lp.LimiterCeiling,
  297. PS: lp.PS,
  298. RadioText: lp.RadioText,
  299. })
  300. }
  301. func (b *txBridge) ResetFault() error {
  302. return b.engine.ResetFault()
  303. }