Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

414 wiersze
12KB

  1. package main
  2. import (
  3. "log"
  4. "math"
  5. "sort"
  6. "strconv"
  7. "time"
  8. "sdr-wideband-suite/internal/config"
  9. "sdr-wideband-suite/internal/demod/gpudemod"
  10. "sdr-wideband-suite/internal/detector"
  11. "sdr-wideband-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. sigMHz := sig.CenterHz / 1e6
  119. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  120. jobOutRate := decimTarget
  121. if isWFM {
  122. jobOutRate = 500000
  123. }
  124. // Minimum extraction BW: ensure enough bandwidth for demod features
  125. // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo pilot + RDS at 57kHz
  126. // Also widen for any signal classified as WFM (in case of re-extraction)
  127. if isWFM {
  128. if bw < 250000 {
  129. bw = 250000
  130. }
  131. } else if bw < 20000 {
  132. bw = 20000
  133. }
  134. jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: jobOutRate}
  135. }
  136. if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) {
  137. // batch extraction OK (silent)
  138. for i := range gpuOuts {
  139. out[i] = gpuOuts[i]
  140. if i < len(gpuRates) {
  141. rates[i] = gpuRates[i]
  142. }
  143. }
  144. return out, rates
  145. } else if err != nil {
  146. log.Printf("gpudemod: batch extraction failed for %d signals: %v", len(signals), err)
  147. }
  148. }
  149. // CPU extraction fallback (silent — see batch extraction failed above if applicable)
  150. for i, sig := range signals {
  151. offset := sig.CenterHz - centerHz
  152. shifted := dsp.FreqShift(iq, sampleRate, offset)
  153. bw := sig.BWHz
  154. // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo + RDS
  155. sigMHz := sig.CenterHz / 1e6
  156. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  157. if isWFM {
  158. if bw < 250000 {
  159. bw = 250000
  160. }
  161. } else if bw < 20000 {
  162. bw = 20000
  163. }
  164. cutoff := bw / 2
  165. if cutoff < 200 {
  166. cutoff = 200
  167. }
  168. if cutoff > float64(sampleRate)/2-1 {
  169. cutoff = float64(sampleRate)/2 - 1
  170. }
  171. taps := dsp.LowpassFIR(cutoff, sampleRate, 101)
  172. filtered := dsp.ApplyFIR(shifted, taps)
  173. decim := sampleRate / decimTarget
  174. if decim < 1 {
  175. decim = 1
  176. }
  177. out[i] = dsp.Decimate(filtered, decim)
  178. rates[i] = sampleRate / decim
  179. }
  180. return out, rates
  181. }
  182. func parseSince(raw string) (time.Time, error) {
  183. if raw == "" {
  184. return time.Time{}, nil
  185. }
  186. if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
  187. if ms > 1e12 {
  188. return time.UnixMilli(ms), nil
  189. }
  190. return time.Unix(ms, 0), nil
  191. }
  192. if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
  193. return t, nil
  194. }
  195. return time.Parse(time.RFC3339, raw)
  196. }
  197. // streamExtractState holds per-signal persistent state for phase-continuous
  198. // GPU extraction. Stored in the DSP loop, keyed by signal ID.
  199. type streamExtractState struct {
  200. phase float64 // FreqShift phase accumulator
  201. }
  202. // streamIQOverlap holds the tail of the previous allIQ for FIR halo prepend.
  203. type streamIQOverlap struct {
  204. tail []complex64
  205. }
  206. // extractionConfig holds audio quality settings for signal extraction.
  207. type extractionConfig struct {
  208. firTaps int // AQ-3: FIR tap count (default 101)
  209. bwMult float64 // AQ-5: BW multiplier (default 1.2)
  210. }
  211. const streamOverlapLen = 512 // must be >= FIR tap count with margin
  212. // extractForStreaming performs GPU-accelerated extraction with:
  213. // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob)
  214. // - IQ overlap prepended to allIQ so FIR kernel has real data in halo
  215. //
  216. // Returns extracted snippets with overlap trimmed, and updates phase state.
  217. func extractForStreaming(
  218. extractMgr *extractionManager,
  219. allIQ []complex64,
  220. sampleRate int,
  221. centerHz float64,
  222. signals []detector.Signal,
  223. phaseState map[int64]*streamExtractState,
  224. overlap *streamIQOverlap,
  225. aqCfg extractionConfig,
  226. ) ([][]complex64, []int) {
  227. out := make([][]complex64, len(signals))
  228. rates := make([]int, len(signals))
  229. if len(allIQ) == 0 || sampleRate <= 0 || len(signals) == 0 {
  230. return out, rates
  231. }
  232. // AQ-3: Use configured overlap length (must cover FIR taps)
  233. overlapNeeded := streamOverlapLen
  234. if aqCfg.firTaps > 0 && aqCfg.firTaps+64 > overlapNeeded {
  235. overlapNeeded = aqCfg.firTaps + 64
  236. }
  237. // Prepend overlap from previous frame so FIR kernel has real halo data
  238. var gpuIQ []complex64
  239. overlapLen := len(overlap.tail)
  240. if overlapLen > 0 {
  241. gpuIQ = make([]complex64, overlapLen+len(allIQ))
  242. copy(gpuIQ, overlap.tail)
  243. copy(gpuIQ[overlapLen:], allIQ)
  244. } else {
  245. gpuIQ = allIQ
  246. overlapLen = 0
  247. }
  248. // Save tail for next frame (sized to cover configured FIR taps)
  249. if len(allIQ) > overlapNeeded {
  250. overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-overlapNeeded:]...)
  251. } else {
  252. overlap.tail = append(overlap.tail[:0], allIQ...)
  253. }
  254. decimTarget := 200000
  255. // AQ-5: BW multiplier for extraction (wider = better S/N for weak signals)
  256. bwMult := aqCfg.bwMult
  257. if bwMult <= 0 {
  258. bwMult = 1.0
  259. }
  260. // Build jobs with per-signal phase
  261. jobs := make([]gpudemod.ExtractJob, len(signals))
  262. for i, sig := range signals {
  263. bw := sig.BWHz * bwMult // AQ-5: widen extraction BW
  264. sigMHz := sig.CenterHz / 1e6
  265. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) ||
  266. (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  267. jobOutRate := decimTarget
  268. if isWFM {
  269. jobOutRate = 300000
  270. if bw < 150000 {
  271. bw = 150000
  272. }
  273. } else if bw < 20000 {
  274. bw = 20000
  275. }
  276. ps := phaseState[sig.ID]
  277. if ps == nil {
  278. ps = &streamExtractState{}
  279. phaseState[sig.ID] = ps
  280. }
  281. // PhaseStart is where the NEW data begins. But gpuIQ has overlap
  282. // prepended, so the GPU kernel starts processing at the overlap.
  283. // We need to rewind the phase by overlapLen samples so that the
  284. // overlap region gets the correct phase, and the new data region
  285. // starts at ps.phase exactly.
  286. phaseInc := -2.0 * math.Pi * (sig.CenterHz - centerHz) / float64(sampleRate)
  287. gpuPhaseStart := ps.phase - phaseInc*float64(overlapLen)
  288. jobs[i] = gpudemod.ExtractJob{
  289. OffsetHz: sig.CenterHz - centerHz,
  290. BW: bw,
  291. OutRate: jobOutRate,
  292. PhaseStart: gpuPhaseStart,
  293. }
  294. }
  295. // Try GPU BatchRunner with phase
  296. runner := extractMgr.get(len(gpuIQ), sampleRate)
  297. if runner != nil {
  298. results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
  299. if err == nil && len(results) == len(signals) {
  300. for i, res := range results {
  301. outRate := decimTarget
  302. sigMHz := signals[i].CenterHz / 1e6
  303. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (signals[i].Class != nil && (signals[i].Class.ModType == "WFM" || signals[i].Class.ModType == "WFM_STEREO"))
  304. if isWFM {
  305. outRate = 500000
  306. }
  307. decim := sampleRate / outRate
  308. if decim < 1 {
  309. decim = 1
  310. }
  311. trimSamples := overlapLen / decim
  312. // Update phase state — advance only by NEW data length, not overlap
  313. phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate)
  314. phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ))
  315. // Trim overlap from output
  316. iq := res.IQ
  317. if trimSamples > 0 && trimSamples < len(iq) {
  318. iq = iq[trimSamples:]
  319. }
  320. out[i] = iq
  321. rates[i] = res.Rate
  322. }
  323. return out, rates
  324. } else if err != nil {
  325. log.Printf("gpudemod: stream batch extraction failed: %v", err)
  326. }
  327. }
  328. // CPU fallback (with phase tracking)
  329. for i, sig := range signals {
  330. offset := sig.CenterHz - centerHz
  331. bw := jobs[i].BW
  332. ps := phaseState[sig.ID]
  333. // Phase-continuous FreqShift — rewind by overlap so new data starts at ps.phase
  334. shifted := make([]complex64, len(gpuIQ))
  335. inc := -2.0 * math.Pi * offset / float64(sampleRate)
  336. phase := ps.phase - inc*float64(overlapLen)
  337. for k, v := range gpuIQ {
  338. phase += inc
  339. re := math.Cos(phase)
  340. im := math.Sin(phase)
  341. shifted[k] = complex(
  342. float32(float64(real(v))*re-float64(imag(v))*im),
  343. float32(float64(real(v))*im+float64(imag(v))*re),
  344. )
  345. }
  346. // Advance phase by NEW data length only
  347. ps.phase += inc * float64(len(allIQ))
  348. cutoff := bw / 2
  349. if cutoff < 200 {
  350. cutoff = 200
  351. }
  352. if cutoff > float64(sampleRate)/2-1 {
  353. cutoff = float64(sampleRate)/2 - 1
  354. }
  355. firTaps := 101
  356. if aqCfg.firTaps > 0 {
  357. firTaps = aqCfg.firTaps
  358. }
  359. taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps)
  360. filtered := dsp.ApplyFIR(shifted, taps)
  361. sigMHz := sig.CenterHz / 1e6
  362. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  363. outRate := decimTarget
  364. if isWFM {
  365. outRate = 500000
  366. }
  367. decim := sampleRate / outRate
  368. if decim < 1 {
  369. decim = 1
  370. }
  371. decimated := dsp.Decimate(filtered, decim)
  372. rates[i] = sampleRate / decim
  373. // Trim overlap
  374. trimSamples := overlapLen / decim
  375. if trimSamples > 0 && trimSamples < len(decimated) {
  376. decimated = decimated[trimSamples:]
  377. }
  378. out[i] = decimated
  379. }
  380. return out, rates
  381. }