Wideband autonomous SDR analysis engine forked from sdr-visual-suite
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

461 行
14KB

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