Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

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