Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

344 lines
11KB

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