Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

396 строки
11KB

  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. // extractionConfig holds audio quality settings for signal extraction.
  203. type extractionConfig struct {
  204. firTaps int // AQ-3: FIR tap count (default 101)
  205. bwMult float64 // AQ-5: BW multiplier (default 1.2)
  206. }
  207. const streamOverlapLen = 512 // must be >= FIR tap count with margin
  208. // extractForStreaming performs GPU-accelerated extraction with:
  209. // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob)
  210. // - IQ overlap prepended to allIQ so FIR kernel has real data in halo
  211. //
  212. // Returns extracted snippets with overlap trimmed, and updates phase state.
  213. func extractForStreaming(
  214. extractMgr *extractionManager,
  215. allIQ []complex64,
  216. sampleRate int,
  217. centerHz float64,
  218. signals []detector.Signal,
  219. phaseState map[int64]*streamExtractState,
  220. overlap *streamIQOverlap,
  221. aqCfg extractionConfig,
  222. ) ([][]complex64, []int) {
  223. out := make([][]complex64, len(signals))
  224. rates := make([]int, len(signals))
  225. if len(allIQ) == 0 || sampleRate <= 0 || len(signals) == 0 {
  226. return out, rates
  227. }
  228. // AQ-3: Use configured overlap length (must cover FIR taps)
  229. overlapNeeded := streamOverlapLen
  230. if aqCfg.firTaps > 0 && aqCfg.firTaps+64 > overlapNeeded {
  231. overlapNeeded = aqCfg.firTaps + 64
  232. }
  233. // Prepend overlap from previous frame so FIR kernel has real halo data
  234. var gpuIQ []complex64
  235. overlapLen := len(overlap.tail)
  236. if overlapLen > 0 {
  237. gpuIQ = make([]complex64, overlapLen+len(allIQ))
  238. copy(gpuIQ, overlap.tail)
  239. copy(gpuIQ[overlapLen:], allIQ)
  240. } else {
  241. gpuIQ = allIQ
  242. overlapLen = 0
  243. }
  244. // Save tail for next frame (sized to cover configured FIR taps)
  245. if len(allIQ) > overlapNeeded {
  246. overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-overlapNeeded:]...)
  247. } else {
  248. overlap.tail = append(overlap.tail[:0], allIQ...)
  249. }
  250. decimTarget := 200000
  251. // AQ-5: BW multiplier for extraction (wider = better S/N for weak signals)
  252. bwMult := aqCfg.bwMult
  253. if bwMult <= 0 {
  254. bwMult = 1.0
  255. }
  256. // Build jobs with per-signal phase
  257. jobs := make([]gpudemod.ExtractJob, len(signals))
  258. for i, sig := range signals {
  259. bw := sig.BWHz * bwMult // AQ-5: widen extraction BW
  260. sigMHz := sig.CenterHz / 1e6
  261. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) ||
  262. (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  263. if isWFM {
  264. if bw < 150000 {
  265. bw = 150000
  266. }
  267. } else if bw < 20000 {
  268. bw = 20000
  269. }
  270. ps := phaseState[sig.ID]
  271. if ps == nil {
  272. ps = &streamExtractState{}
  273. phaseState[sig.ID] = ps
  274. }
  275. // PhaseStart is where the NEW data begins. But gpuIQ has overlap
  276. // prepended, so the GPU kernel starts processing at the overlap.
  277. // We need to rewind the phase by overlapLen samples so that the
  278. // overlap region gets the correct phase, and the new data region
  279. // starts at ps.phase exactly.
  280. phaseInc := -2.0 * math.Pi * (sig.CenterHz - centerHz) / float64(sampleRate)
  281. gpuPhaseStart := ps.phase - phaseInc*float64(overlapLen)
  282. jobs[i] = gpudemod.ExtractJob{
  283. OffsetHz: sig.CenterHz - centerHz,
  284. BW: bw,
  285. OutRate: decimTarget,
  286. PhaseStart: gpuPhaseStart,
  287. }
  288. }
  289. // Try GPU BatchRunner with phase
  290. runner := extractMgr.get(len(gpuIQ), sampleRate)
  291. if runner != nil {
  292. results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
  293. if err == nil && len(results) == len(signals) {
  294. decim := sampleRate / decimTarget
  295. if decim < 1 {
  296. decim = 1
  297. }
  298. trimSamples := overlapLen / decim
  299. for i, res := range results {
  300. // Update phase state — advance only by NEW data length, not overlap
  301. phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate)
  302. phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ))
  303. // Trim overlap from output
  304. iq := res.IQ
  305. if trimSamples > 0 && trimSamples < len(iq) {
  306. iq = iq[trimSamples:]
  307. }
  308. out[i] = iq
  309. rates[i] = res.Rate
  310. }
  311. return out, rates
  312. } else if err != nil {
  313. log.Printf("gpudemod: stream batch extraction failed: %v", err)
  314. }
  315. }
  316. // CPU fallback (with phase tracking)
  317. for i, sig := range signals {
  318. offset := sig.CenterHz - centerHz
  319. bw := jobs[i].BW
  320. ps := phaseState[sig.ID]
  321. // Phase-continuous FreqShift — rewind by overlap so new data starts at ps.phase
  322. shifted := make([]complex64, len(gpuIQ))
  323. inc := -2.0 * math.Pi * offset / float64(sampleRate)
  324. phase := ps.phase - inc*float64(overlapLen)
  325. for k, v := range gpuIQ {
  326. phase += inc
  327. re := math.Cos(phase)
  328. im := math.Sin(phase)
  329. shifted[k] = complex(
  330. float32(float64(real(v))*re-float64(imag(v))*im),
  331. float32(float64(real(v))*im+float64(imag(v))*re),
  332. )
  333. }
  334. // Advance phase by NEW data length only
  335. ps.phase += inc * float64(len(allIQ))
  336. cutoff := bw / 2
  337. if cutoff < 200 {
  338. cutoff = 200
  339. }
  340. if cutoff > float64(sampleRate)/2-1 {
  341. cutoff = float64(sampleRate)/2 - 1
  342. }
  343. firTaps := 101
  344. if aqCfg.firTaps > 0 {
  345. firTaps = aqCfg.firTaps
  346. }
  347. taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps)
  348. filtered := dsp.ApplyFIR(shifted, taps)
  349. decim := sampleRate / decimTarget
  350. if decim < 1 {
  351. decim = 1
  352. }
  353. decimated := dsp.Decimate(filtered, decim)
  354. rates[i] = sampleRate / decim
  355. // Trim overlap
  356. trimSamples := overlapLen / decim
  357. if trimSamples > 0 && trimSamples < len(decimated) {
  358. decimated = decimated[trimSamples:]
  359. }
  360. out[i] = decimated
  361. }
  362. return out, rates
  363. }