Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

219 行
6.7KB

  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. 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/platform"
  17. "github.com/jan/fm-rds-tx/internal/platform/plutosdr"
  18. "github.com/jan/fm-rds-tx/internal/platform/soapysdr"
  19. )
  20. func main() {
  21. configPath := flag.String("config", "", "path to JSON config")
  22. printConfig := flag.Bool("print-config", false, "print effective config and exit")
  23. dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output")
  24. dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout")
  25. simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path")
  26. simulateOutput := flag.String("simulate-output", "", "simulated transmit output file")
  27. simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
  28. txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)")
  29. txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch")
  30. listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit")
  31. flag.Parse()
  32. // --- list-devices (SoapySDR) ---
  33. if *listDevices {
  34. devices, err := soapysdr.Enumerate()
  35. if err != nil {
  36. log.Fatalf("enumerate: %v", err)
  37. }
  38. if len(devices) == 0 {
  39. fmt.Println("no SoapySDR devices found")
  40. return
  41. }
  42. for i, dev := range devices {
  43. fmt.Printf("device %d:\n", i)
  44. for k, v := range dev {
  45. fmt.Printf(" %s = %s\n", k, v)
  46. }
  47. }
  48. return
  49. }
  50. cfg, err := cfgpkg.Load(*configPath)
  51. if err != nil {
  52. log.Fatalf("load config: %v", err)
  53. }
  54. // --- print-config ---
  55. if *printConfig {
  56. preemph := "off"
  57. if cfg.FM.PreEmphasisTauUS > 0 {
  58. preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS)
  59. }
  60. 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",
  61. cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled,
  62. preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz,
  63. cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress,
  64. plutosdr.Available(), soapysdr.Available())
  65. return
  66. }
  67. // --- dry-run ---
  68. if *dryRun {
  69. frame := drypkg.Generate(cfg)
  70. if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
  71. log.Fatalf("dry-run: %v", err)
  72. }
  73. if *dryOutput != "" && *dryOutput != "-" {
  74. fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput)
  75. }
  76. return
  77. }
  78. // --- simulate ---
  79. if *simulate {
  80. summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration)
  81. if err != nil {
  82. log.Fatalf("simulate-tx: %v", err)
  83. }
  84. fmt.Println(summary)
  85. return
  86. }
  87. // --- TX mode ---
  88. if *txMode {
  89. driver := selectDriver(cfg)
  90. if driver == nil {
  91. log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)")
  92. }
  93. runTXMode(cfg, driver, *txAutoStart)
  94. return
  95. }
  96. // --- default: HTTP only ---
  97. srv := ctrlpkg.NewServer(cfg)
  98. log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", cfg.Control.ListenAddress)
  99. log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()))
  100. }
  101. // selectDriver picks the best available driver based on config and build tags.
  102. func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
  103. kind := cfg.Backend.Kind
  104. // Explicit PlutoSDR
  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. // Explicit SoapySDR
  112. if kind == "soapy" || kind == "soapysdr" {
  113. if soapysdr.Available() {
  114. return soapysdr.NewNativeDriver()
  115. }
  116. log.Printf("warning: backend=%s but soapy driver not available", kind)
  117. }
  118. // Auto-detect: prefer PlutoSDR, fall back to SoapySDR
  119. if plutosdr.Available() {
  120. log.Println("auto-selected: pluto-iio driver")
  121. return plutosdr.NewPlutoDriver()
  122. }
  123. if soapysdr.Available() {
  124. log.Println("auto-selected: soapy-native driver")
  125. return soapysdr.NewNativeDriver()
  126. }
  127. return nil
  128. }
  129. func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) {
  130. ctx, cancel := context.WithCancel(context.Background())
  131. defer cancel()
  132. // Configure driver
  133. // OutputDrive controls composite signal level, NOT hardware gain.
  134. // Hardware TX gain is always 0 dB (max power). Use external attenuator for power control.
  135. soapyCfg := platform.SoapyConfig{
  136. Driver: cfg.Backend.Device,
  137. CenterFreqHz: cfg.FM.FrequencyMHz * 1e6,
  138. GainDB: 0, // 0 dB = max TX power on PlutoSDR
  139. }
  140. soapyCfg.SampleRateHz = cfg.EffectiveDeviceRate()
  141. log.Printf("TX: configuring %s freq=%.3fMHz rate=%.0fHz gain=%.1fdB",
  142. driver.Name(), cfg.FM.FrequencyMHz, soapyCfg.SampleRateHz, soapyCfg.GainDB)
  143. if err := driver.Configure(ctx, soapyCfg); err != nil {
  144. log.Fatalf("configure: %v", err)
  145. }
  146. caps, err := driver.Capabilities(ctx)
  147. if err == nil {
  148. log.Printf("TX: device caps: gain=%.0f..%.0f dB, rate=%.0f..%.0f Hz",
  149. caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate)
  150. }
  151. // Engine
  152. engine := apppkg.NewEngine(cfg, driver)
  153. // Control plane
  154. srv := ctrlpkg.NewServer(cfg)
  155. srv.SetDriver(driver)
  156. srv.SetTXController(&txBridge{engine: engine})
  157. if autoStart {
  158. log.Println("TX: auto-start enabled")
  159. if err := engine.Start(ctx); err != nil {
  160. log.Fatalf("engine start: %v", err)
  161. }
  162. log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate())
  163. } else {
  164. log.Println("TX ready (idle) — POST /tx/start to begin")
  165. }
  166. go func() {
  167. log.Printf("control plane on %s", cfg.Control.ListenAddress)
  168. if err := http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()); err != nil {
  169. log.Printf("http: %v", err)
  170. }
  171. }()
  172. sigCh := make(chan os.Signal, 1)
  173. signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
  174. sig := <-sigCh
  175. log.Printf("received %s, shutting down...", sig)
  176. _ = engine.Stop(ctx)
  177. _ = driver.Close(ctx)
  178. log.Println("shutdown complete")
  179. }
  180. type txBridge struct{ engine *apppkg.Engine }
  181. func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) }
  182. func (b *txBridge) StopTX() error { return b.engine.Stop(context.Background()) }
  183. func (b *txBridge) TXStats() map[string]any {
  184. s := b.engine.Stats()
  185. return map[string]any{
  186. "state": s.State, "chunksProduced": s.ChunksProduced,
  187. "totalSamples": s.TotalSamples, "underruns": s.Underruns,
  188. "lastError": s.LastError, "uptimeSeconds": s.UptimeSeconds,
  189. }
  190. }