Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

385 rindas
13KB

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