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.

249 lines
5.7KB

  1. //go:build soapy
  2. // Package soapysdr provides a pure-Go SoapySDR driver that loads the
  3. // SoapySDR shared library at runtime via dlopen/LoadLibrary.
  4. // No CGO required. No C compiler required.
  5. //
  6. // Build with: go build -tags soapy
  7. // Requires: SoapySDR shared library installed on the system.
  8. // Windows: SoapySDR.dll (via PothosSDR)
  9. // Linux: libSoapySDR.so (via package manager)
  10. // macOS: libSoapySDR.dylib (via brew)
  11. package soapysdr
  12. import (
  13. "context"
  14. "fmt"
  15. "math"
  16. "sync"
  17. "sync/atomic"
  18. "time"
  19. "unsafe"
  20. "github.com/jan/fm-rds-tx/internal/output"
  21. "github.com/jan/fm-rds-tx/internal/platform"
  22. )
  23. // nativeDriver implements platform.SoapyDriver using runtime-loaded SoapySDR.
  24. type nativeDriver struct {
  25. mu sync.Mutex
  26. lib *soapyLib
  27. cfg platform.SoapyConfig
  28. dev uintptr // SoapySDRDevice*
  29. stream uintptr // SoapySDRStream*
  30. mtu int
  31. started bool
  32. configured bool
  33. framesWritten atomic.Uint64
  34. samplesWritten atomic.Uint64
  35. underruns atomic.Uint64
  36. lastError string
  37. lastErrorAt string
  38. }
  39. // NewNativeDriver creates an uninitialized SoapySDR native driver.
  40. func NewNativeDriver() platform.SoapyDriver {
  41. lib, err := loadSoapyLib()
  42. if err != nil {
  43. // Return a driver that will fail on Configure with a clear message
  44. return &nativeDriver{lastError: fmt.Sprintf("load SoapySDR library: %v", err)}
  45. }
  46. return &nativeDriver{lib: lib}
  47. }
  48. // Enumerate lists available SoapySDR devices.
  49. func Enumerate() ([]map[string]string, error) {
  50. lib, err := loadSoapyLib()
  51. if err != nil {
  52. return nil, fmt.Errorf("load SoapySDR: %w", err)
  53. }
  54. return lib.enumerate()
  55. }
  56. func (d *nativeDriver) Name() string { return "soapy-native" }
  57. func (d *nativeDriver) Configure(_ context.Context, cfg platform.SoapyConfig) error {
  58. d.mu.Lock()
  59. defer d.mu.Unlock()
  60. if d.lib == nil {
  61. return fmt.Errorf("soapy: library not loaded: %s", d.lastError)
  62. }
  63. // Close existing
  64. if d.dev != 0 {
  65. if d.stream != 0 {
  66. d.lib.deactivateStream(d.dev, d.stream)
  67. d.lib.closeStream(d.dev, d.stream)
  68. d.stream = 0
  69. }
  70. d.lib.unmakeDevice(d.dev)
  71. d.dev = 0
  72. }
  73. d.cfg = cfg
  74. // Open device
  75. dev, err := d.lib.makeDevice(cfg.Driver, cfg.Device, cfg.DeviceArgs)
  76. if err != nil {
  77. return err
  78. }
  79. d.dev = dev
  80. // Sample rate
  81. rate := cfg.SampleRateHz
  82. if rate <= 0 {
  83. rate = 528000
  84. }
  85. if err := d.lib.setSampleRate(d.dev, dirTX, 0, rate); err != nil {
  86. return err
  87. }
  88. // Frequency
  89. if cfg.CenterFreqHz > 0 {
  90. if err := d.lib.setFrequency(d.dev, dirTX, 0, cfg.CenterFreqHz); err != nil {
  91. return err
  92. }
  93. }
  94. // Gain
  95. if cfg.GainDB != 0 {
  96. _ = d.lib.setGain(d.dev, dirTX, 0, cfg.GainDB)
  97. }
  98. // Setup TX stream (CF32)
  99. stream, err := d.lib.setupStream(d.dev, dirTX, "CF32", []uint{0})
  100. if err != nil {
  101. return err
  102. }
  103. d.stream = stream
  104. d.mtu = d.lib.getStreamMTU(d.dev, d.stream)
  105. if d.mtu <= 0 {
  106. d.mtu = 4096
  107. }
  108. d.configured = true
  109. return nil
  110. }
  111. func (d *nativeDriver) Capabilities(_ context.Context) (platform.DeviceCaps, error) {
  112. d.mu.Lock()
  113. defer d.mu.Unlock()
  114. if d.dev == 0 || d.lib == nil {
  115. return platform.DeviceCaps{}, fmt.Errorf("device not opened")
  116. }
  117. gMin, gMax := d.lib.getGainRange(d.dev, dirTX, 0)
  118. return platform.DeviceCaps{
  119. MinSampleRate: 521e3, MaxSampleRate: 61.44e6,
  120. HasGain: true, GainMinDB: gMin, GainMaxDB: gMax,
  121. Channels: []int{0},
  122. }, nil
  123. }
  124. func (d *nativeDriver) Start(_ context.Context) error {
  125. d.mu.Lock()
  126. defer d.mu.Unlock()
  127. if !d.configured || d.dev == 0 || d.stream == 0 {
  128. return fmt.Errorf("soapy: not configured")
  129. }
  130. if d.started {
  131. return fmt.Errorf("soapy: already started")
  132. }
  133. if err := d.lib.activateStream(d.dev, d.stream); err != nil {
  134. return err
  135. }
  136. d.started = true
  137. return nil
  138. }
  139. func (d *nativeDriver) Write(_ context.Context, frame *output.CompositeFrame) (int, error) {
  140. d.mu.Lock()
  141. lib, dev, stream, started, mtu := d.lib, d.dev, d.stream, d.started, d.mtu
  142. d.mu.Unlock()
  143. if !started || dev == 0 || stream == 0 {
  144. return 0, fmt.Errorf("soapy: stream not active")
  145. }
  146. if frame == nil || len(frame.Samples) == 0 {
  147. return 0, nil
  148. }
  149. total := len(frame.Samples)
  150. written := 0
  151. for written < total {
  152. chunk := total - written
  153. if chunk > mtu {
  154. chunk = mtu
  155. }
  156. // IQSample is {I float32, Q float32} — contiguous CF32 in memory
  157. ptr := unsafe.Pointer(&frame.Samples[written])
  158. n, err := lib.writeStream(dev, stream, ptr, chunk)
  159. if err != nil {
  160. d.mu.Lock()
  161. d.lastError = err.Error()
  162. d.lastErrorAt = time.Now().UTC().Format(time.RFC3339)
  163. d.underruns.Add(1)
  164. d.mu.Unlock()
  165. return written, err
  166. }
  167. written += n
  168. }
  169. d.framesWritten.Add(1)
  170. d.samplesWritten.Add(uint64(written))
  171. return written, nil
  172. }
  173. func (d *nativeDriver) Stop(_ context.Context) error {
  174. d.mu.Lock()
  175. defer d.mu.Unlock()
  176. if !d.started {
  177. return nil
  178. }
  179. if d.dev != 0 && d.stream != 0 {
  180. d.lib.deactivateStream(d.dev, d.stream)
  181. }
  182. d.started = false
  183. return nil
  184. }
  185. func (d *nativeDriver) Flush(_ context.Context) error { return nil }
  186. func (d *nativeDriver) Close(_ context.Context) error {
  187. d.mu.Lock()
  188. defer d.mu.Unlock()
  189. if d.stream != 0 && d.dev != 0 {
  190. if d.started {
  191. d.lib.deactivateStream(d.dev, d.stream)
  192. d.started = false
  193. }
  194. d.lib.closeStream(d.dev, d.stream)
  195. d.stream = 0
  196. }
  197. if d.dev != 0 {
  198. d.lib.unmakeDevice(d.dev)
  199. d.dev = 0
  200. }
  201. d.configured = false
  202. return nil
  203. }
  204. func (d *nativeDriver) Stats() platform.RuntimeStats {
  205. d.mu.Lock()
  206. defer d.mu.Unlock()
  207. return platform.RuntimeStats{
  208. TXEnabled: d.started, StreamActive: d.started,
  209. FramesWritten: d.framesWritten.Load(), SamplesWritten: d.samplesWritten.Load(),
  210. Underruns: d.underruns.Load(), LastError: d.lastError, LastErrorAt: d.lastErrorAt,
  211. EffectiveRate: d.cfg.SampleRateHz,
  212. }
  213. }
  214. // --- helper constants ---
  215. const dirTX = 1 // SOAPY_SDR_TX
  216. // float64 from raw bits
  217. func f64FromPtr(p uintptr) float64 {
  218. return math.Float64frombits(uint64(p))
  219. }