Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

373 řádky
10KB

  1. package main
  2. import (
  3. "log"
  4. "math"
  5. "sort"
  6. "strconv"
  7. "time"
  8. "sdr-visual-suite/internal/config"
  9. "sdr-visual-suite/internal/demod/gpudemod"
  10. "sdr-visual-suite/internal/detector"
  11. "sdr-visual-suite/internal/dsp"
  12. )
  13. func mustParseDuration(raw string, fallback time.Duration) time.Duration {
  14. if raw == "" {
  15. return fallback
  16. }
  17. if d, err := time.ParseDuration(raw); err == nil {
  18. return d
  19. }
  20. return fallback
  21. }
  22. func buildDecoderMap(cfg config.Config) map[string]string {
  23. out := map[string]string{}
  24. if cfg.Decoder.FT8Cmd != "" {
  25. out["FT8"] = cfg.Decoder.FT8Cmd
  26. }
  27. if cfg.Decoder.WSPRCmd != "" {
  28. out["WSPR"] = cfg.Decoder.WSPRCmd
  29. }
  30. if cfg.Decoder.DMRCmd != "" {
  31. out["DMR"] = cfg.Decoder.DMRCmd
  32. }
  33. if cfg.Decoder.DStarCmd != "" {
  34. out["D-STAR"] = cfg.Decoder.DStarCmd
  35. }
  36. if cfg.Decoder.FSKCmd != "" {
  37. out["FSK"] = cfg.Decoder.FSKCmd
  38. }
  39. if cfg.Decoder.PSKCmd != "" {
  40. out["PSK"] = cfg.Decoder.PSKCmd
  41. }
  42. return out
  43. }
  44. func decoderKeys(cfg config.Config) []string {
  45. m := buildDecoderMap(cfg)
  46. keys := make([]string, 0, len(m))
  47. for k := range m {
  48. keys = append(keys, k)
  49. }
  50. sort.Strings(keys)
  51. return keys
  52. }
  53. func (m *extractionManager) reset() {
  54. if m == nil {
  55. return
  56. }
  57. m.mu.Lock()
  58. defer m.mu.Unlock()
  59. if m.runner != nil {
  60. m.runner.Close()
  61. m.runner = nil
  62. }
  63. }
  64. func (m *extractionManager) get(sampleCount int, sampleRate int) *gpudemod.BatchRunner {
  65. if m == nil || sampleCount <= 0 || sampleRate <= 0 || !gpudemod.Available() {
  66. return nil
  67. }
  68. m.mu.Lock()
  69. defer m.mu.Unlock()
  70. if m.runner != nil && sampleCount > m.maxSamples {
  71. m.runner.Close()
  72. m.runner = nil
  73. }
  74. if m.runner == nil {
  75. // Allocate generously: enough for full allIQ (sampleRate/10 ≈ 100ms)
  76. // so the runner never needs re-allocation when used for both
  77. // classification (FFT-block ~65k) and streaming (allIQ ~273k+).
  78. allocSize := sampleCount
  79. generous := sampleRate/10 + 1024 // ~400k at 4MHz — covers any scenario
  80. if generous > allocSize {
  81. allocSize = generous
  82. }
  83. if r, err := gpudemod.NewBatchRunner(allocSize, sampleRate); err == nil {
  84. m.runner = r
  85. m.maxSamples = allocSize
  86. } else {
  87. log.Printf("gpudemod: batch runner init failed: %v", err)
  88. }
  89. return m.runner
  90. }
  91. return m.runner
  92. }
  93. func extractSignalIQ(iq []complex64, sampleRate int, centerHz float64, sigHz float64, bwHz float64) []complex64 {
  94. if len(iq) == 0 || sampleRate <= 0 {
  95. return nil
  96. }
  97. results, _ := extractSignalIQBatch(nil, iq, sampleRate, centerHz, []detector.Signal{{CenterHz: sigHz, BWHz: bwHz}})
  98. if len(results) == 0 {
  99. return nil
  100. }
  101. return results[0]
  102. }
  103. func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleRate int, centerHz float64, signals []detector.Signal) ([][]complex64, []int) {
  104. out := make([][]complex64, len(signals))
  105. rates := make([]int, len(signals))
  106. if len(iq) == 0 || sampleRate <= 0 || len(signals) == 0 {
  107. return out, rates
  108. }
  109. decimTarget := 200000
  110. if decimTarget <= 0 {
  111. decimTarget = sampleRate
  112. }
  113. runner := extractMgr.get(len(iq), sampleRate)
  114. if runner != nil {
  115. jobs := make([]gpudemod.ExtractJob, len(signals))
  116. for i, sig := range signals {
  117. bw := sig.BWHz
  118. // Minimum extraction BW: ensure enough bandwidth for demod features
  119. // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo pilot + RDS at 57kHz
  120. // Also widen for any signal classified as WFM (in case of re-extraction)
  121. sigMHz := sig.CenterHz / 1e6
  122. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  123. if isWFM {
  124. if bw < 150000 {
  125. bw = 150000
  126. }
  127. } else if bw < 20000 {
  128. bw = 20000
  129. }
  130. jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: decimTarget}
  131. }
  132. if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) {
  133. // batch extraction OK (silent)
  134. for i := range gpuOuts {
  135. out[i] = gpuOuts[i]
  136. if i < len(gpuRates) {
  137. rates[i] = gpuRates[i]
  138. }
  139. }
  140. return out, rates
  141. } else if err != nil {
  142. log.Printf("gpudemod: batch extraction failed for %d signals: %v", len(signals), err)
  143. }
  144. }
  145. // CPU extraction fallback (silent — see batch extraction failed above if applicable)
  146. for i, sig := range signals {
  147. offset := sig.CenterHz - centerHz
  148. shifted := dsp.FreqShift(iq, sampleRate, offset)
  149. bw := sig.BWHz
  150. // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo + RDS
  151. sigMHz := sig.CenterHz / 1e6
  152. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  153. if isWFM {
  154. if bw < 150000 {
  155. bw = 150000
  156. }
  157. } else if bw < 20000 {
  158. bw = 20000
  159. }
  160. cutoff := bw / 2
  161. if cutoff < 200 {
  162. cutoff = 200
  163. }
  164. if cutoff > float64(sampleRate)/2-1 {
  165. cutoff = float64(sampleRate)/2 - 1
  166. }
  167. taps := dsp.LowpassFIR(cutoff, sampleRate, 101)
  168. filtered := dsp.ApplyFIR(shifted, taps)
  169. decim := sampleRate / decimTarget
  170. if decim < 1 {
  171. decim = 1
  172. }
  173. out[i] = dsp.Decimate(filtered, decim)
  174. rates[i] = sampleRate / decim
  175. }
  176. return out, rates
  177. }
  178. func parseSince(raw string) (time.Time, error) {
  179. if raw == "" {
  180. return time.Time{}, nil
  181. }
  182. if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
  183. if ms > 1e12 {
  184. return time.UnixMilli(ms), nil
  185. }
  186. return time.Unix(ms, 0), nil
  187. }
  188. if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
  189. return t, nil
  190. }
  191. return time.Parse(time.RFC3339, raw)
  192. }
  193. // streamExtractState holds per-signal persistent state for phase-continuous
  194. // GPU extraction. Stored in the DSP loop, keyed by signal ID.
  195. type streamExtractState struct {
  196. phase float64 // FreqShift phase accumulator
  197. }
  198. // streamIQOverlap holds the tail of the previous allIQ for FIR halo prepend.
  199. type streamIQOverlap struct {
  200. tail []complex64
  201. }
  202. const streamOverlapLen = 512 // must be >= FIR tap count (101) with margin
  203. // extractForStreaming performs GPU-accelerated extraction with:
  204. // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob)
  205. // - IQ overlap prepended to allIQ so FIR kernel has real data in halo
  206. //
  207. // Returns extracted snippets with overlap trimmed, and updates phase state.
  208. func extractForStreaming(
  209. extractMgr *extractionManager,
  210. allIQ []complex64,
  211. sampleRate int,
  212. centerHz float64,
  213. signals []detector.Signal,
  214. phaseState map[int64]*streamExtractState,
  215. overlap *streamIQOverlap,
  216. ) ([][]complex64, []int) {
  217. out := make([][]complex64, len(signals))
  218. rates := make([]int, len(signals))
  219. if len(allIQ) == 0 || sampleRate <= 0 || len(signals) == 0 {
  220. return out, rates
  221. }
  222. // Prepend overlap from previous frame so FIR kernel has real halo data
  223. var gpuIQ []complex64
  224. overlapLen := len(overlap.tail)
  225. if overlapLen > 0 {
  226. gpuIQ = make([]complex64, overlapLen+len(allIQ))
  227. copy(gpuIQ, overlap.tail)
  228. copy(gpuIQ[overlapLen:], allIQ)
  229. } else {
  230. gpuIQ = allIQ
  231. overlapLen = 0
  232. }
  233. // Save tail for next frame
  234. if len(allIQ) > streamOverlapLen {
  235. overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-streamOverlapLen:]...)
  236. } else {
  237. overlap.tail = append(overlap.tail[:0], allIQ...)
  238. }
  239. decimTarget := 200000
  240. // Build jobs with per-signal phase
  241. jobs := make([]gpudemod.ExtractJob, len(signals))
  242. for i, sig := range signals {
  243. bw := sig.BWHz
  244. sigMHz := sig.CenterHz / 1e6
  245. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) ||
  246. (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  247. if isWFM {
  248. if bw < 150000 {
  249. bw = 150000
  250. }
  251. } else if bw < 20000 {
  252. bw = 20000
  253. }
  254. ps := phaseState[sig.ID]
  255. if ps == nil {
  256. ps = &streamExtractState{}
  257. phaseState[sig.ID] = ps
  258. }
  259. // PhaseStart is where the NEW data begins. But gpuIQ has overlap
  260. // prepended, so the GPU kernel starts processing at the overlap.
  261. // We need to rewind the phase by overlapLen samples so that the
  262. // overlap region gets the correct phase, and the new data region
  263. // starts at ps.phase exactly.
  264. phaseInc := -2.0 * math.Pi * (sig.CenterHz - centerHz) / float64(sampleRate)
  265. gpuPhaseStart := ps.phase - phaseInc*float64(overlapLen)
  266. jobs[i] = gpudemod.ExtractJob{
  267. OffsetHz: sig.CenterHz - centerHz,
  268. BW: bw,
  269. OutRate: decimTarget,
  270. PhaseStart: gpuPhaseStart,
  271. }
  272. }
  273. // Try GPU BatchRunner with phase
  274. runner := extractMgr.get(len(gpuIQ), sampleRate)
  275. if runner != nil {
  276. results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
  277. if err == nil && len(results) == len(signals) {
  278. decim := sampleRate / decimTarget
  279. if decim < 1 {
  280. decim = 1
  281. }
  282. trimSamples := overlapLen / decim
  283. for i, res := range results {
  284. // Update phase state — advance only by NEW data length, not overlap
  285. phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate)
  286. phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ))
  287. // Trim overlap from output
  288. iq := res.IQ
  289. if trimSamples > 0 && trimSamples < len(iq) {
  290. iq = iq[trimSamples:]
  291. }
  292. out[i] = iq
  293. rates[i] = res.Rate
  294. }
  295. return out, rates
  296. } else if err != nil {
  297. log.Printf("gpudemod: stream batch extraction failed: %v", err)
  298. }
  299. }
  300. // CPU fallback (with phase tracking)
  301. for i, sig := range signals {
  302. offset := sig.CenterHz - centerHz
  303. bw := jobs[i].BW
  304. ps := phaseState[sig.ID]
  305. // Phase-continuous FreqShift — rewind by overlap so new data starts at ps.phase
  306. shifted := make([]complex64, len(gpuIQ))
  307. inc := -2.0 * math.Pi * offset / float64(sampleRate)
  308. phase := ps.phase - inc*float64(overlapLen)
  309. for k, v := range gpuIQ {
  310. phase += inc
  311. re := math.Cos(phase)
  312. im := math.Sin(phase)
  313. shifted[k] = complex(
  314. float32(float64(real(v))*re-float64(imag(v))*im),
  315. float32(float64(real(v))*im+float64(imag(v))*re),
  316. )
  317. }
  318. // Advance phase by NEW data length only
  319. ps.phase += inc * float64(len(allIQ))
  320. cutoff := bw / 2
  321. if cutoff < 200 {
  322. cutoff = 200
  323. }
  324. if cutoff > float64(sampleRate)/2-1 {
  325. cutoff = float64(sampleRate)/2 - 1
  326. }
  327. taps := dsp.LowpassFIR(cutoff, sampleRate, 101)
  328. filtered := dsp.ApplyFIR(shifted, taps)
  329. decim := sampleRate / decimTarget
  330. if decim < 1 {
  331. decim = 1
  332. }
  333. decimated := dsp.Decimate(filtered, decim)
  334. rates[i] = sampleRate / decim
  335. // Trim overlap
  336. trimSamples := overlapLen / decim
  337. if trimSamples > 0 && trimSamples < len(decimated) {
  338. decimated = decimated[trimSamples:]
  339. }
  340. out[i] = decimated
  341. }
  342. return out, rates
  343. }