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

865 строки
30KB

  1. package main
  2. import (
  3. "fmt"
  4. "log"
  5. "math"
  6. "os"
  7. "sort"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "sdr-wideband-suite/internal/config"
  12. "sdr-wideband-suite/internal/demod/gpudemod"
  13. "sdr-wideband-suite/internal/detector"
  14. "sdr-wideband-suite/internal/dsp"
  15. "sdr-wideband-suite/internal/logging"
  16. "sdr-wideband-suite/internal/telemetry"
  17. )
  18. func mustParseDuration(raw string, fallback time.Duration) time.Duration {
  19. if raw == "" {
  20. return fallback
  21. }
  22. if d, err := time.ParseDuration(raw); err == nil {
  23. return d
  24. }
  25. return fallback
  26. }
  27. func buildDecoderMap(cfg config.Config) map[string]string {
  28. out := map[string]string{}
  29. if cfg.Decoder.FT8Cmd != "" {
  30. out["FT8"] = cfg.Decoder.FT8Cmd
  31. }
  32. if cfg.Decoder.WSPRCmd != "" {
  33. out["WSPR"] = cfg.Decoder.WSPRCmd
  34. }
  35. if cfg.Decoder.DMRCmd != "" {
  36. out["DMR"] = cfg.Decoder.DMRCmd
  37. }
  38. if cfg.Decoder.DStarCmd != "" {
  39. out["D-STAR"] = cfg.Decoder.DStarCmd
  40. }
  41. if cfg.Decoder.FSKCmd != "" {
  42. out["FSK"] = cfg.Decoder.FSKCmd
  43. }
  44. if cfg.Decoder.PSKCmd != "" {
  45. out["PSK"] = cfg.Decoder.PSKCmd
  46. }
  47. return out
  48. }
  49. func decoderKeys(cfg config.Config) []string {
  50. m := buildDecoderMap(cfg)
  51. keys := make([]string, 0, len(m))
  52. for k := range m {
  53. keys = append(keys, k)
  54. }
  55. sort.Strings(keys)
  56. return keys
  57. }
  58. func (m *extractionManager) reset() {
  59. if m == nil {
  60. return
  61. }
  62. m.mu.Lock()
  63. defer m.mu.Unlock()
  64. if m.runner != nil {
  65. m.runner.Close()
  66. m.runner = nil
  67. }
  68. }
  69. func (m *extractionManager) get(sampleCount int, sampleRate int) *gpudemod.BatchRunner {
  70. if m == nil || sampleCount <= 0 || sampleRate <= 0 || !gpudemod.Available() {
  71. return nil
  72. }
  73. m.mu.Lock()
  74. defer m.mu.Unlock()
  75. if m.runner != nil && sampleCount > m.maxSamples {
  76. m.runner.Close()
  77. m.runner = nil
  78. }
  79. if m.runner == nil {
  80. // Allocate generously: enough for full allIQ (sampleRate/10 ≈ 100ms)
  81. // so the runner never needs re-allocation when used for both
  82. // classification (FFT-block ~65k) and streaming (allIQ ~273k+).
  83. allocSize := sampleCount
  84. generous := sampleRate/10 + 1024 // ~400k at 4MHz — covers any scenario
  85. if generous > allocSize {
  86. allocSize = generous
  87. }
  88. if r, err := gpudemod.NewBatchRunner(allocSize, sampleRate); err == nil {
  89. m.runner = r
  90. m.maxSamples = allocSize
  91. } else {
  92. log.Printf("gpudemod: batch runner init failed: %v", err)
  93. }
  94. return m.runner
  95. }
  96. return m.runner
  97. }
  98. func extractSignalIQ(iq []complex64, sampleRate int, centerHz float64, sigHz float64, bwHz float64) []complex64 {
  99. if len(iq) == 0 || sampleRate <= 0 {
  100. return nil
  101. }
  102. results, _ := extractSignalIQBatch(nil, iq, sampleRate, centerHz, []detector.Signal{{CenterHz: sigHz, BWHz: bwHz}})
  103. if len(results) == 0 {
  104. return nil
  105. }
  106. return results[0]
  107. }
  108. func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleRate int, centerHz float64, signals []detector.Signal) ([][]complex64, []int) {
  109. out := make([][]complex64, len(signals))
  110. rates := make([]int, len(signals))
  111. if len(iq) == 0 || sampleRate <= 0 || len(signals) == 0 {
  112. return out, rates
  113. }
  114. decimTarget := 200000
  115. if decimTarget <= 0 {
  116. decimTarget = sampleRate
  117. }
  118. runner := extractMgr.get(len(iq), sampleRate)
  119. if runner != nil {
  120. jobs := make([]gpudemod.ExtractJob, len(signals))
  121. for i, sig := range signals {
  122. bw := sig.BWHz
  123. sigMHz := sig.CenterHz / 1e6
  124. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  125. jobOutRate := decimTarget
  126. if isWFM {
  127. jobOutRate = wfmStreamOutRate
  128. }
  129. // Minimum extraction BW: ensure enough bandwidth for demod features
  130. // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo pilot + RDS at 57kHz
  131. // Also widen for any signal classified as WFM (in case of re-extraction)
  132. if isWFM {
  133. if bw < wfmStreamMinBW {
  134. bw = wfmStreamMinBW
  135. }
  136. } else if bw < 20000 {
  137. bw = 20000
  138. }
  139. jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: jobOutRate}
  140. }
  141. if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) {
  142. // batch extraction OK (silent)
  143. for i := range gpuOuts {
  144. out[i] = gpuOuts[i]
  145. if i < len(gpuRates) {
  146. rates[i] = gpuRates[i]
  147. }
  148. }
  149. return out, rates
  150. } else if err != nil {
  151. log.Printf("gpudemod: batch extraction failed for %d signals: %v", len(signals), err)
  152. }
  153. }
  154. // CPU extraction fallback (silent — see batch extraction failed above if applicable)
  155. for i, sig := range signals {
  156. offset := sig.CenterHz - centerHz
  157. shifted := dsp.FreqShift(iq, sampleRate, offset)
  158. bw := sig.BWHz
  159. // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo + RDS
  160. sigMHz := sig.CenterHz / 1e6
  161. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  162. if isWFM {
  163. if bw < wfmStreamMinBW {
  164. bw = wfmStreamMinBW
  165. }
  166. } else if bw < 20000 {
  167. bw = 20000
  168. }
  169. cutoff := bw / 2
  170. if cutoff < 200 {
  171. cutoff = 200
  172. }
  173. if cutoff > float64(sampleRate)/2-1 {
  174. cutoff = float64(sampleRate)/2 - 1
  175. }
  176. taps := dsp.LowpassFIR(cutoff, sampleRate, 101)
  177. filtered := dsp.ApplyFIR(shifted, taps)
  178. decim := sampleRate / decimTarget
  179. if decim < 1 {
  180. decim = 1
  181. }
  182. out[i] = dsp.Decimate(filtered, decim)
  183. rates[i] = sampleRate / decim
  184. }
  185. return out, rates
  186. }
  187. func parseSince(raw string) (time.Time, error) {
  188. if raw == "" {
  189. return time.Time{}, nil
  190. }
  191. if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
  192. if ms > 1e12 {
  193. return time.UnixMilli(ms), nil
  194. }
  195. return time.Unix(ms, 0), nil
  196. }
  197. if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
  198. return t, nil
  199. }
  200. return time.Parse(time.RFC3339, raw)
  201. }
  202. // streamExtractState holds per-signal persistent state for phase-continuous
  203. // GPU extraction. Stored in the DSP loop, keyed by signal ID.
  204. type streamExtractState struct {
  205. phase float64 // FreqShift phase accumulator
  206. }
  207. // streamIQOverlap holds the tail of the previous allIQ for FIR halo prepend.
  208. type streamIQOverlap struct {
  209. tail []complex64
  210. }
  211. // extractionConfig holds audio quality settings for signal extraction.
  212. type extractionConfig struct {
  213. firTaps int // AQ-3: FIR tap count (default 101)
  214. bwMult float64 // AQ-5: BW multiplier (default 1.2)
  215. }
  216. const streamOverlapLen = 512 // must be >= FIR tap count with margin
  217. const (
  218. wfmStreamOutRate = 512000
  219. wfmStreamMinBW = 250000
  220. )
  221. var forceCPUStreamExtract = func() bool {
  222. raw := strings.TrimSpace(os.Getenv("SDR_FORCE_CPU_STREAM_EXTRACT"))
  223. if raw == "" {
  224. return false
  225. }
  226. v, err := strconv.ParseBool(raw)
  227. if err != nil {
  228. return false
  229. }
  230. return v
  231. }()
  232. // extractForStreaming performs GPU-accelerated extraction with:
  233. // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob)
  234. // - IQ overlap prepended to allIQ so FIR kernel has real data in halo
  235. //
  236. // Returns extracted snippets with overlap trimmed, and updates phase state.
  237. // extractForStreaming is the current legacy production path.
  238. // It still relies on overlap-prepend + trim semantics and is intentionally
  239. // kept separate from the new streaming refactor/oracle path under development.
  240. func extractForStreaming(
  241. extractMgr *extractionManager,
  242. allIQ []complex64,
  243. sampleRate int,
  244. centerHz float64,
  245. signals []detector.Signal,
  246. phaseState map[int64]*streamExtractState,
  247. overlap *streamIQOverlap,
  248. aqCfg extractionConfig,
  249. coll *telemetry.Collector,
  250. ) ([][]complex64, []int) {
  251. if useStreamingProductionPath {
  252. out, rates, err := extractForStreamingProduction(extractMgr, allIQ, sampleRate, centerHz, signals, aqCfg, coll)
  253. if err == nil {
  254. logging.Debug("extract", "path_active", "path", "streaming_production", "signals", len(signals), "allIQ", len(allIQ))
  255. if coll != nil {
  256. coll.IncCounter("extract.path.streaming_production", 1, nil)
  257. }
  258. return out, rates
  259. }
  260. // CRITICAL: the streaming production path failed — log WHY before falling through
  261. log.Printf("EXTRACT PATH FALLTHROUGH: streaming production failed: %v — using legacy overlap+trim", err)
  262. logging.Warn("extract", "streaming_production_fallthrough",
  263. "err", err.Error(),
  264. "signals", len(signals),
  265. "allIQ", len(allIQ),
  266. "sampleRate", sampleRate,
  267. )
  268. if coll != nil {
  269. coll.IncCounter("extract.path.streaming_production_failed", 1, nil)
  270. coll.Event("extraction_path_fallthrough", "warn",
  271. "streaming production path failed, using legacy overlap+trim", nil,
  272. map[string]any{
  273. "error": err.Error(),
  274. "signals": len(signals),
  275. "allIQ_len": len(allIQ),
  276. "sampleRate": sampleRate,
  277. })
  278. }
  279. }
  280. if useStreamingOraclePath {
  281. out, rates, err := extractForStreamingOracle(allIQ, sampleRate, centerHz, signals, aqCfg, coll)
  282. if err == nil {
  283. logging.Debug("extract", "path_active", "path", "streaming_oracle", "signals", len(signals))
  284. if coll != nil {
  285. coll.IncCounter("extract.path.streaming_oracle", 1, nil)
  286. }
  287. return out, rates
  288. }
  289. log.Printf("EXTRACT PATH FALLTHROUGH: streaming oracle failed: %v", err)
  290. logging.Warn("extract", "streaming_oracle_fallthrough", "err", err.Error())
  291. if coll != nil {
  292. coll.IncCounter("extract.path.streaming_oracle_failed", 1, nil)
  293. }
  294. }
  295. // If we reach here, the legacy overlap+trim path is running
  296. logging.Warn("extract", "path_active", "path", "legacy_overlap_trim", "signals", len(signals), "allIQ", len(allIQ))
  297. if coll != nil {
  298. coll.IncCounter("extract.path.legacy_overlap_trim", 1, nil)
  299. }
  300. out := make([][]complex64, len(signals))
  301. rates := make([]int, len(signals))
  302. if len(allIQ) == 0 || sampleRate <= 0 || len(signals) == 0 {
  303. return out, rates
  304. }
  305. // AQ-3: Use configured overlap length (must cover FIR taps)
  306. overlapNeeded := streamOverlapLen
  307. if aqCfg.firTaps > 0 && aqCfg.firTaps+64 > overlapNeeded {
  308. overlapNeeded = aqCfg.firTaps + 64
  309. }
  310. // Prepend overlap from previous frame so FIR kernel has real halo data
  311. var gpuIQ []complex64
  312. overlapLen := len(overlap.tail)
  313. logging.Debug("extract", "overlap", "len", overlapLen, "needed", overlapNeeded, "allIQ", len(allIQ))
  314. if overlapLen > 0 {
  315. gpuIQ = make([]complex64, overlapLen+len(allIQ))
  316. copy(gpuIQ, overlap.tail)
  317. copy(gpuIQ[overlapLen:], allIQ)
  318. } else {
  319. gpuIQ = allIQ
  320. overlapLen = 0
  321. }
  322. // Save tail for next frame (sized to cover configured FIR taps)
  323. if len(allIQ) > overlapNeeded {
  324. overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-overlapNeeded:]...)
  325. } else {
  326. overlap.tail = append(overlap.tail[:0], allIQ...)
  327. }
  328. decimTarget := 200000
  329. // AQ-5: BW multiplier for extraction (wider = better S/N for weak signals)
  330. bwMult := aqCfg.bwMult
  331. if bwMult <= 0 {
  332. bwMult = 1.0
  333. }
  334. if coll != nil {
  335. coll.SetGauge("iq.extract.input.length", float64(len(allIQ)), nil)
  336. coll.SetGauge("iq.extract.input.overlap_length", float64(overlapLen), nil)
  337. headMean, tailMean, boundaryScore, _ := boundaryMetrics(overlap.tail, allIQ, 32)
  338. coll.SetGauge("iq.extract.input.head_mean_mag", headMean, nil)
  339. coll.SetGauge("iq.extract.input.prev_tail_mean_mag", tailMean, nil)
  340. coll.Observe("iq.extract.input.discontinuity_score", boundaryScore, nil)
  341. }
  342. rawBoundary := make(map[int64]boundaryProbeState, len(signals))
  343. trimmedBoundary := make(map[int64]boundaryProbeState, len(signals))
  344. // Build jobs with per-signal phase
  345. jobs := make([]gpudemod.ExtractJob, len(signals))
  346. for i, sig := range signals {
  347. bw := sig.BWHz * bwMult // AQ-5: widen extraction BW
  348. sigMHz := sig.CenterHz / 1e6
  349. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) ||
  350. (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  351. jobOutRate := decimTarget
  352. if isWFM {
  353. jobOutRate = wfmStreamOutRate
  354. if bw < wfmStreamMinBW {
  355. bw = wfmStreamMinBW
  356. }
  357. } else if bw < 20000 {
  358. bw = 20000
  359. }
  360. ps := phaseState[sig.ID]
  361. if ps == nil {
  362. ps = &streamExtractState{}
  363. phaseState[sig.ID] = ps
  364. }
  365. // PhaseStart is where the NEW data begins. But gpuIQ has overlap
  366. // prepended, so the GPU kernel starts processing at the overlap.
  367. // We need to rewind the phase by overlapLen samples so that the
  368. // overlap region gets the correct phase, and the new data region
  369. // starts at ps.phase exactly.
  370. phaseInc := -2.0 * math.Pi * (sig.CenterHz - centerHz) / float64(sampleRate)
  371. gpuPhaseStart := ps.phase - phaseInc*float64(overlapLen)
  372. jobs[i] = gpudemod.ExtractJob{
  373. OffsetHz: sig.CenterHz - centerHz,
  374. BW: bw,
  375. OutRate: jobOutRate,
  376. PhaseStart: gpuPhaseStart,
  377. }
  378. if coll != nil {
  379. tags := telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sig.ID), "path", "gpu")
  380. inputHead := probeHead(gpuIQ, 16, 1e-6)
  381. coll.SetGauge("iq.extract.input_head.zero_count", float64(inputHead.zeroCount), tags)
  382. coll.SetGauge("iq.extract.input_head.first_nonzero_index", float64(inputHead.firstNonZeroIndex), tags)
  383. coll.SetGauge("iq.extract.input_head.max_step", inputHead.maxStep, tags)
  384. coll.Event("extract_input_head_probe", "info", "extractor input head probe", tags, map[string]any{
  385. "mags": inputHead.mags,
  386. "zero_count": inputHead.zeroCount,
  387. "first_nonzero_index": inputHead.firstNonZeroIndex,
  388. "head_max_step": inputHead.maxStep,
  389. "center_offset_hz": jobs[i].OffsetHz,
  390. "bandwidth_hz": bw,
  391. "out_rate": jobOutRate,
  392. "trim_samples": (overlapLen + int(math.Max(1, math.Round(float64(sampleRate)/float64(jobOutRate)))) - 1) / int(math.Max(1, math.Round(float64(sampleRate)/float64(jobOutRate)))),
  393. })
  394. }
  395. }
  396. // Try GPU BatchRunner with phase unless CPU-only debug is forced.
  397. var runner *gpudemod.BatchRunner
  398. if forceCPUStreamExtract {
  399. logging.Warn("boundary", "force_cpu_stream_extract", "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "signals", len(signals))
  400. } else {
  401. runner = extractMgr.get(len(gpuIQ), sampleRate)
  402. }
  403. if runner != nil {
  404. if coll != nil && len(gpuIQ) > 0 {
  405. inputProbe := probeHead(gpuIQ, 16, 1e-6)
  406. coll.Event("gpu_kernel_input_head_probe", "info", "gpu kernel input head probe", nil, map[string]any{
  407. "mags": inputProbe.mags,
  408. "zero_count": inputProbe.zeroCount,
  409. "first_nonzero_index": inputProbe.firstNonZeroIndex,
  410. "head_max_step": inputProbe.maxStep,
  411. "gpuIQ_len": len(gpuIQ),
  412. "sample_rate": sampleRate,
  413. "signals": len(signals),
  414. })
  415. }
  416. results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
  417. if err == nil && len(results) == len(signals) {
  418. for i, res := range results {
  419. outRate := res.Rate
  420. if outRate <= 0 {
  421. outRate = decimTarget
  422. }
  423. sigMHz := signals[i].CenterHz / 1e6
  424. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (signals[i].Class != nil && (signals[i].Class.ModType == "WFM" || signals[i].Class.ModType == "WFM_STEREO"))
  425. if isWFM {
  426. outRate = wfmStreamOutRate
  427. }
  428. decim := sampleRate / outRate
  429. if decim < 1 {
  430. decim = 1
  431. }
  432. trimSamples := (overlapLen + decim - 1) / decim
  433. if i == 0 {
  434. logging.Debug("extract", "gpu_result", "rate", res.Rate, "outRate", outRate, "decim", decim, "trim", trimSamples)
  435. }
  436. // Update phase state — advance only by NEW data length, not overlap
  437. phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate)
  438. phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ))
  439. // Normalize to [-π, π) to prevent float64 drift over long runs
  440. phaseState[signals[i].ID].phase = math.Remainder(phaseState[signals[i].ID].phase, 2*math.Pi)
  441. // Trim overlap from output
  442. iq := res.IQ
  443. rawLen := len(iq)
  444. if trimSamples > 0 && trimSamples < len(iq) {
  445. iq = iq[trimSamples:]
  446. }
  447. if i == 0 {
  448. 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)
  449. logExtractorHeadComparison(signals[i].ID, "gpu", overlapLen, res.IQ, trimSamples, iq)
  450. }
  451. if coll != nil {
  452. tags := telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", signals[i].ID), "path", "gpu")
  453. kernelProbe := probeHead(res.IQ, 16, 1e-6)
  454. coll.Event("gpu_kernel_output_head_probe", "info", "gpu kernel output head probe", tags, map[string]any{
  455. "mags": kernelProbe.mags,
  456. "zero_count": kernelProbe.zeroCount,
  457. "first_nonzero_index": kernelProbe.firstNonZeroIndex,
  458. "head_max_step": kernelProbe.maxStep,
  459. "raw_len": rawLen,
  460. "out_rate": outRate,
  461. "trim_samples": trimSamples,
  462. })
  463. stats := computeIQHeadStats(iq, 64)
  464. coll.SetGauge("iq.extract.output.length", float64(len(iq)), tags)
  465. coll.Observe("iq.extract.output.head_mean_mag", stats.meanMag, tags)
  466. coll.Observe("iq.extract.output.head_min_mag", stats.minMag, tags)
  467. coll.Observe("iq.extract.output.head_max_step", stats.maxStep, tags)
  468. coll.Observe("iq.extract.output.head_p95_step", stats.p95Step, tags)
  469. coll.Observe("iq.extract.output.head_tail_ratio", stats.headTail, tags)
  470. coll.SetGauge("iq.extract.output.head_low_magnitude_count", float64(stats.lowMag), tags)
  471. coll.SetGauge("iq.extract.raw.length", float64(rawLen), tags)
  472. coll.SetGauge("iq.extract.trim.trim_samples", float64(trimSamples), tags)
  473. if rawLen > 0 {
  474. coll.SetGauge("iq.extract.raw.head_mag", math.Hypot(float64(real(res.IQ[0])), float64(imag(res.IQ[0]))), tags)
  475. coll.SetGauge("iq.extract.raw.tail_mag", math.Hypot(float64(real(res.IQ[rawLen-1])), float64(imag(res.IQ[rawLen-1]))), tags)
  476. rawHead := probeHead(res.IQ, 16, 1e-6)
  477. coll.SetGauge("iq.extract.raw.head_zero_count", float64(rawHead.zeroCount), tags)
  478. coll.SetGauge("iq.extract.raw.first_nonzero_index", float64(rawHead.firstNonZeroIndex), tags)
  479. coll.SetGauge("iq.extract.raw.head_max_step", rawHead.maxStep, tags)
  480. coll.Event("extract_raw_head_probe", "info", "raw extractor head probe", tags, map[string]any{
  481. "mags": rawHead.mags,
  482. "zero_count": rawHead.zeroCount,
  483. "first_nonzero_index": rawHead.firstNonZeroIndex,
  484. "head_max_step": rawHead.maxStep,
  485. "trim_samples": trimSamples,
  486. })
  487. }
  488. if len(iq) > 0 {
  489. coll.SetGauge("iq.extract.trimmed.head_mag", math.Hypot(float64(real(iq[0])), float64(imag(iq[0]))), tags)
  490. coll.SetGauge("iq.extract.trimmed.tail_mag", math.Hypot(float64(real(iq[len(iq)-1])), float64(imag(iq[len(iq)-1]))), tags)
  491. trimmedHead := probeHead(iq, 16, 1e-6)
  492. coll.SetGauge("iq.extract.trimmed.head_zero_count", float64(trimmedHead.zeroCount), tags)
  493. coll.SetGauge("iq.extract.trimmed.first_nonzero_index", float64(trimmedHead.firstNonZeroIndex), tags)
  494. coll.SetGauge("iq.extract.trimmed.head_max_step", trimmedHead.maxStep, tags)
  495. coll.Event("extract_trimmed_head_probe", "info", "trimmed extractor head probe", tags, map[string]any{
  496. "mags": trimmedHead.mags,
  497. "zero_count": trimmedHead.zeroCount,
  498. "first_nonzero_index": trimmedHead.firstNonZeroIndex,
  499. "head_max_step": trimmedHead.maxStep,
  500. "trim_samples": trimSamples,
  501. })
  502. }
  503. if rb := rawBoundary[signals[i].ID]; rb.set && rawLen > 0 {
  504. prevMag := math.Hypot(float64(real(rb.last)), float64(imag(rb.last)))
  505. currMag := math.Hypot(float64(real(res.IQ[0])), float64(imag(res.IQ[0])))
  506. coll.SetGauge("iq.extract.raw.boundary.prev_tail_mag", prevMag, tags)
  507. coll.SetGauge("iq.extract.raw.boundary.curr_head_mag", currMag, tags)
  508. coll.Event("extract_raw_boundary", "info", "raw extractor boundary", tags, map[string]any{
  509. "delta_mag": math.Abs(currMag - prevMag),
  510. "trim_samples": trimSamples,
  511. "raw_len": rawLen,
  512. })
  513. }
  514. if tb := trimmedBoundary[signals[i].ID]; tb.set && len(iq) > 0 {
  515. prevMag := math.Hypot(float64(real(tb.last)), float64(imag(tb.last)))
  516. currMag := math.Hypot(float64(real(iq[0])), float64(imag(iq[0])))
  517. coll.SetGauge("iq.extract.trimmed.boundary.prev_tail_mag", prevMag, tags)
  518. coll.SetGauge("iq.extract.trimmed.boundary.curr_head_mag", currMag, tags)
  519. coll.Event("extract_trimmed_boundary", "info", "trimmed extractor boundary", tags, map[string]any{
  520. "delta_mag": math.Abs(currMag - prevMag),
  521. "trim_samples": trimSamples,
  522. "out_len": len(iq),
  523. })
  524. }
  525. }
  526. if rawLen > 0 {
  527. rawBoundary[signals[i].ID] = boundaryProbeState{last: res.IQ[rawLen-1], set: true}
  528. }
  529. if len(iq) > 0 {
  530. trimmedBoundary[signals[i].ID] = boundaryProbeState{last: iq[len(iq)-1], set: true}
  531. }
  532. out[i] = iq
  533. rates[i] = res.Rate
  534. }
  535. return out, rates
  536. } else if err != nil {
  537. log.Printf("gpudemod: stream batch extraction failed: %v", err)
  538. }
  539. }
  540. // CPU fallback (with phase tracking)
  541. for i, sig := range signals {
  542. offset := sig.CenterHz - centerHz
  543. bw := jobs[i].BW
  544. ps := phaseState[sig.ID]
  545. // Phase-continuous FreqShift — rewind by overlap so new data starts at ps.phase
  546. shifted := make([]complex64, len(gpuIQ))
  547. inc := -2.0 * math.Pi * offset / float64(sampleRate)
  548. phase := ps.phase - inc*float64(overlapLen)
  549. for k, v := range gpuIQ {
  550. phase += inc
  551. re := math.Cos(phase)
  552. im := math.Sin(phase)
  553. shifted[k] = complex(
  554. float32(float64(real(v))*re-float64(imag(v))*im),
  555. float32(float64(real(v))*im+float64(imag(v))*re),
  556. )
  557. }
  558. // Advance phase by NEW data length only
  559. ps.phase += inc * float64(len(allIQ))
  560. ps.phase = math.Remainder(ps.phase, 2*math.Pi)
  561. cutoff := bw / 2
  562. if cutoff < 200 {
  563. cutoff = 200
  564. }
  565. if cutoff > float64(sampleRate)/2-1 {
  566. cutoff = float64(sampleRate)/2 - 1
  567. }
  568. firTaps := 101
  569. if aqCfg.firTaps > 0 {
  570. firTaps = aqCfg.firTaps
  571. }
  572. taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps)
  573. filtered := dsp.ApplyFIR(shifted, taps)
  574. sigMHz := sig.CenterHz / 1e6
  575. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  576. outRate := decimTarget
  577. if isWFM {
  578. outRate = wfmStreamOutRate
  579. }
  580. decim := sampleRate / outRate
  581. if decim < 1 {
  582. decim = 1
  583. }
  584. decimated := dsp.Decimate(filtered, decim)
  585. rates[i] = sampleRate / decim
  586. // Trim overlap — use ceil to ensure ALL overlap samples are removed.
  587. // Floor trim (overlapLen/decim) leaves a remainder for non-divisible
  588. // factors (e.g. 512/20=25 trims only 500 of 512 samples → 12 leak).
  589. trimSamples := (overlapLen + decim - 1) / decim
  590. if i == 0 {
  591. logging.Debug("extract", "cpu_result", "outRate", outRate, "decim", decim, "trim", trimSamples)
  592. }
  593. rawIQ := decimated
  594. rawLen := len(rawIQ)
  595. if trimSamples > 0 && trimSamples < len(decimated) {
  596. decimated = decimated[trimSamples:]
  597. }
  598. if i == 0 {
  599. 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)
  600. logExtractorHeadComparison(signals[i].ID, "cpu", overlapLen, decimated, trimSamples, decimated)
  601. }
  602. if coll != nil {
  603. tags := telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", signals[i].ID), "path", "cpu")
  604. stats := computeIQHeadStats(decimated, 64)
  605. coll.SetGauge("iq.extract.output.length", float64(len(decimated)), tags)
  606. coll.Observe("iq.extract.output.head_mean_mag", stats.meanMag, tags)
  607. coll.Observe("iq.extract.output.head_min_mag", stats.minMag, tags)
  608. coll.Observe("iq.extract.output.head_max_step", stats.maxStep, tags)
  609. coll.Observe("iq.extract.output.head_p95_step", stats.p95Step, tags)
  610. coll.Observe("iq.extract.output.head_tail_ratio", stats.headTail, tags)
  611. coll.SetGauge("iq.extract.output.head_low_magnitude_count", float64(stats.lowMag), tags)
  612. coll.SetGauge("iq.extract.raw.length", float64(rawLen), tags)
  613. coll.SetGauge("iq.extract.trim.trim_samples", float64(trimSamples), tags)
  614. if rb := rawBoundary[signals[i].ID]; rb.set && rawLen > 0 {
  615. observeBoundarySample(coll, "iq.extract.raw.boundary", tags, rb.last, rawIQ[0])
  616. }
  617. if tb := trimmedBoundary[signals[i].ID]; tb.set && len(decimated) > 0 {
  618. observeBoundarySample(coll, "iq.extract.trimmed.boundary", tags, tb.last, decimated[0])
  619. }
  620. }
  621. if rawLen > 0 {
  622. rawBoundary[signals[i].ID] = boundaryProbeState{last: rawIQ[rawLen-1], set: true}
  623. }
  624. if len(decimated) > 0 {
  625. trimmedBoundary[signals[i].ID] = boundaryProbeState{last: decimated[len(decimated)-1], set: true}
  626. }
  627. out[i] = decimated
  628. }
  629. return out, rates
  630. }
  631. type iqHeadStats struct {
  632. length int
  633. minMag float64
  634. maxMag float64
  635. meanMag float64
  636. lowMag int
  637. maxStep float64
  638. maxStepIdx int
  639. p95Step float64
  640. headTail float64
  641. headMinIdx int
  642. stepSamples []float64
  643. }
  644. type boundaryProbeState struct {
  645. last complex64
  646. set bool
  647. }
  648. type headProbe struct {
  649. zeroCount int
  650. firstNonZeroIndex int
  651. maxStep float64
  652. mags []float64
  653. }
  654. func probeHead(samples []complex64, n int, zeroThreshold float64) headProbe {
  655. if n <= 0 || len(samples) == 0 {
  656. return headProbe{firstNonZeroIndex: -1}
  657. }
  658. if len(samples) < n {
  659. n = len(samples)
  660. }
  661. if zeroThreshold <= 0 {
  662. zeroThreshold = 1e-6
  663. }
  664. out := headProbe{firstNonZeroIndex: -1, mags: make([]float64, 0, n)}
  665. for i := 0; i < n; i++ {
  666. v := samples[i]
  667. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  668. out.mags = append(out.mags, mag)
  669. if mag <= zeroThreshold {
  670. out.zeroCount++
  671. } else if out.firstNonZeroIndex < 0 {
  672. out.firstNonZeroIndex = i
  673. }
  674. if i > 0 {
  675. p := samples[i-1]
  676. num := float64(real(p))*float64(imag(v)) - float64(imag(p))*float64(real(v))
  677. den := float64(real(p))*float64(real(v)) + float64(imag(p))*float64(imag(v))
  678. step := math.Abs(math.Atan2(num, den))
  679. if step > out.maxStep {
  680. out.maxStep = step
  681. }
  682. }
  683. }
  684. return out
  685. }
  686. func observeBoundarySample(coll *telemetry.Collector, metricPrefix string, tags map[string]string, prev complex64, curr complex64) {
  687. prevMag := math.Hypot(float64(real(prev)), float64(imag(prev)))
  688. currMag := math.Hypot(float64(real(curr)), float64(imag(curr)))
  689. deltaMag := math.Abs(currMag - prevMag)
  690. num := float64(real(prev))*float64(imag(curr)) - float64(imag(prev))*float64(real(curr))
  691. den := float64(real(prev))*float64(real(curr)) + float64(imag(prev))*float64(imag(curr))
  692. deltaPhase := math.Abs(math.Atan2(num, den))
  693. d2 := float64(real(curr-prev))*float64(real(curr-prev)) + float64(imag(curr-prev))*float64(imag(curr-prev))
  694. coll.Observe(metricPrefix+".delta_mag", deltaMag, tags)
  695. coll.Observe(metricPrefix+".delta_phase", deltaPhase, tags)
  696. coll.Observe(metricPrefix+".d2", d2, tags)
  697. coll.Observe(metricPrefix+".discontinuity_score", deltaMag+deltaPhase, tags)
  698. }
  699. func computeIQHeadStats(iq []complex64, headLen int) iqHeadStats {
  700. stats := iqHeadStats{minMag: math.MaxFloat64, headMinIdx: -1, maxStepIdx: -1}
  701. if len(iq) == 0 {
  702. stats.minMag = 0
  703. return stats
  704. }
  705. n := len(iq)
  706. if headLen > 0 && headLen < n {
  707. n = headLen
  708. }
  709. stats.length = n
  710. stats.stepSamples = make([]float64, 0, max(0, n-1))
  711. sumMag := 0.0
  712. headSum := 0.0
  713. tailSum := 0.0
  714. tailCount := 0
  715. for i := 0; i < n; i++ {
  716. v := iq[i]
  717. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  718. if mag < stats.minMag {
  719. stats.minMag = mag
  720. stats.headMinIdx = i
  721. }
  722. if mag > stats.maxMag {
  723. stats.maxMag = mag
  724. }
  725. sumMag += mag
  726. if mag < 0.05 {
  727. stats.lowMag++
  728. }
  729. if i < min(16, n) {
  730. headSum += mag
  731. }
  732. if i >= max(0, n-16) {
  733. tailSum += mag
  734. tailCount++
  735. }
  736. if i > 0 {
  737. p := iq[i-1]
  738. num := float64(real(p))*float64(imag(v)) - float64(imag(p))*float64(real(v))
  739. den := float64(real(p))*float64(real(v)) + float64(imag(p))*float64(imag(v))
  740. step := math.Abs(math.Atan2(num, den))
  741. if step > stats.maxStep {
  742. stats.maxStep = step
  743. stats.maxStepIdx = i - 1
  744. }
  745. stats.stepSamples = append(stats.stepSamples, step)
  746. }
  747. }
  748. stats.meanMag = sumMag / float64(n)
  749. if len(stats.stepSamples) > 0 {
  750. sorted := append([]float64(nil), stats.stepSamples...)
  751. sort.Float64s(sorted)
  752. idx := int(float64(len(sorted)-1) * 0.95)
  753. stats.p95Step = sorted[idx]
  754. } else {
  755. stats.p95Step = stats.maxStep
  756. }
  757. if headSum > 0 && tailCount > 0 {
  758. headMean := headSum / float64(min(16, n))
  759. tailMean := tailSum / float64(tailCount)
  760. if tailMean > 0 {
  761. stats.headTail = headMean / tailMean
  762. }
  763. }
  764. return stats
  765. }
  766. func observeIQStats(coll *telemetry.Collector, stage string, iq []complex64, tags telemetry.Tags) {
  767. if coll == nil || len(iq) == 0 {
  768. return
  769. }
  770. stats := computeIQHeadStats(iq, len(iq))
  771. stageTags := telemetry.TagsWith(tags, "stage", stage)
  772. coll.Observe("iq.magnitude.min", stats.minMag, stageTags)
  773. coll.Observe("iq.magnitude.max", stats.maxMag, stageTags)
  774. coll.Observe("iq.magnitude.mean", stats.meanMag, stageTags)
  775. coll.Observe("iq.phase_step.max", stats.maxStep, stageTags)
  776. coll.Observe("iq.phase_step.p95", stats.p95Step, stageTags)
  777. coll.Observe("iq.low_magnitude.count", float64(stats.lowMag), stageTags)
  778. coll.SetGauge("iq.length", float64(stats.length), stageTags)
  779. }
  780. func logExtractorHeadComparison(signalID int64, path string, overlapLen int, raw []complex64, trimSamples int, out []complex64) {
  781. rawStats := computeIQHeadStats(raw, 96)
  782. trimmedStats := computeIQHeadStats(out, 96)
  783. logging.Debug("boundary", "extract_head_compare",
  784. "signal", signalID,
  785. "path", path,
  786. "raw_len", len(raw),
  787. "trim", trimSamples,
  788. "out_len", len(out),
  789. "overlap_len", overlapLen,
  790. "raw_min_mag", rawStats.minMag,
  791. "raw_min_idx", rawStats.headMinIdx,
  792. "raw_max_step", rawStats.maxStep,
  793. "raw_max_step_idx", rawStats.maxStepIdx,
  794. "raw_head_tail", rawStats.headTail,
  795. "trimmed_min_mag", trimmedStats.minMag,
  796. "trimmed_min_idx", trimmedStats.headMinIdx,
  797. "trimmed_max_step", trimmedStats.maxStep,
  798. "trimmed_max_step_idx", trimmedStats.maxStepIdx,
  799. "trimmed_head_tail", trimmedStats.headTail,
  800. )
  801. for _, off := range []int{2, 4, 8, 16} {
  802. if len(out) <= off+8 {
  803. continue
  804. }
  805. offStats := computeIQHeadStats(out[off:], 96)
  806. logging.Debug("boundary", "extract_head_offset_compare",
  807. "signal", signalID,
  808. "path", path,
  809. "offset", off,
  810. "base_min_mag", trimmedStats.minMag,
  811. "base_min_idx", trimmedStats.headMinIdx,
  812. "base_max_step", trimmedStats.maxStep,
  813. "base_max_step_idx", trimmedStats.maxStepIdx,
  814. "offset_min_mag", offStats.minMag,
  815. "offset_min_idx", offStats.headMinIdx,
  816. "offset_max_step", offStats.maxStep,
  817. "offset_max_step_idx", offStats.maxStepIdx,
  818. "offset_head_tail", offStats.headTail,
  819. )
  820. }
  821. }