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.

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