Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

604 lines
18KB

  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. "sdr-wideband-suite/internal/telemetry"
  16. )
  17. func mustParseDuration(raw string, fallback time.Duration) time.Duration {
  18. if raw == "" {
  19. return fallback
  20. }
  21. if d, err := time.ParseDuration(raw); err == nil {
  22. return d
  23. }
  24. return fallback
  25. }
  26. func buildDecoderMap(cfg config.Config) map[string]string {
  27. out := map[string]string{}
  28. if cfg.Decoder.FT8Cmd != "" {
  29. out["FT8"] = cfg.Decoder.FT8Cmd
  30. }
  31. if cfg.Decoder.WSPRCmd != "" {
  32. out["WSPR"] = cfg.Decoder.WSPRCmd
  33. }
  34. if cfg.Decoder.DMRCmd != "" {
  35. out["DMR"] = cfg.Decoder.DMRCmd
  36. }
  37. if cfg.Decoder.DStarCmd != "" {
  38. out["D-STAR"] = cfg.Decoder.DStarCmd
  39. }
  40. if cfg.Decoder.FSKCmd != "" {
  41. out["FSK"] = cfg.Decoder.FSKCmd
  42. }
  43. if cfg.Decoder.PSKCmd != "" {
  44. out["PSK"] = cfg.Decoder.PSKCmd
  45. }
  46. return out
  47. }
  48. func decoderKeys(cfg config.Config) []string {
  49. m := buildDecoderMap(cfg)
  50. keys := make([]string, 0, len(m))
  51. for k := range m {
  52. keys = append(keys, k)
  53. }
  54. sort.Strings(keys)
  55. return keys
  56. }
  57. func (m *extractionManager) reset() {
  58. if m == nil {
  59. return
  60. }
  61. m.mu.Lock()
  62. defer m.mu.Unlock()
  63. if m.runner != nil {
  64. m.runner.Close()
  65. m.runner = nil
  66. }
  67. }
  68. func (m *extractionManager) get(sampleCount int, sampleRate int) *gpudemod.BatchRunner {
  69. if m == nil || sampleCount <= 0 || sampleRate <= 0 || !gpudemod.Available() {
  70. return nil
  71. }
  72. m.mu.Lock()
  73. defer m.mu.Unlock()
  74. if m.runner != nil && sampleCount > m.maxSamples {
  75. m.runner.Close()
  76. m.runner = nil
  77. }
  78. if m.runner == nil {
  79. // Allocate generously: enough for full allIQ (sampleRate/10 ≈ 100ms)
  80. // so the runner never needs re-allocation when used for both
  81. // classification (FFT-block ~65k) and streaming (allIQ ~273k+).
  82. allocSize := sampleCount
  83. generous := sampleRate/10 + 1024 // ~400k at 4MHz — covers any scenario
  84. if generous > allocSize {
  85. allocSize = generous
  86. }
  87. if r, err := gpudemod.NewBatchRunner(allocSize, sampleRate); err == nil {
  88. m.runner = r
  89. m.maxSamples = allocSize
  90. } else {
  91. log.Printf("gpudemod: batch runner init failed: %v", err)
  92. }
  93. return m.runner
  94. }
  95. return m.runner
  96. }
  97. func extractSignalIQ(iq []complex64, sampleRate int, centerHz float64, sigHz float64, bwHz float64) []complex64 {
  98. if len(iq) == 0 || sampleRate <= 0 {
  99. return nil
  100. }
  101. results, _ := extractSignalIQBatch(nil, iq, sampleRate, centerHz, []detector.Signal{{CenterHz: sigHz, BWHz: bwHz}})
  102. if len(results) == 0 {
  103. return nil
  104. }
  105. return results[0]
  106. }
  107. func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleRate int, centerHz float64, signals []detector.Signal) ([][]complex64, []int) {
  108. out := make([][]complex64, len(signals))
  109. rates := make([]int, len(signals))
  110. if len(iq) == 0 || sampleRate <= 0 || len(signals) == 0 {
  111. return out, rates
  112. }
  113. decimTarget := 200000
  114. if decimTarget <= 0 {
  115. decimTarget = sampleRate
  116. }
  117. runner := extractMgr.get(len(iq), sampleRate)
  118. if runner != nil {
  119. jobs := make([]gpudemod.ExtractJob, len(signals))
  120. for i, sig := range signals {
  121. bw := sig.BWHz
  122. sigMHz := sig.CenterHz / 1e6
  123. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  124. jobOutRate := decimTarget
  125. if isWFM {
  126. jobOutRate = wfmStreamOutRate
  127. }
  128. // Minimum extraction BW: ensure enough bandwidth for demod features
  129. // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo pilot + RDS at 57kHz
  130. // Also widen for any signal classified as WFM (in case of re-extraction)
  131. if isWFM {
  132. if bw < wfmStreamMinBW {
  133. bw = wfmStreamMinBW
  134. }
  135. } else if bw < 20000 {
  136. bw = 20000
  137. }
  138. jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: jobOutRate}
  139. }
  140. if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) {
  141. // batch extraction OK (silent)
  142. for i := range gpuOuts {
  143. out[i] = gpuOuts[i]
  144. if i < len(gpuRates) {
  145. rates[i] = gpuRates[i]
  146. }
  147. }
  148. return out, rates
  149. } else if err != nil {
  150. log.Printf("gpudemod: batch extraction failed for %d signals: %v", len(signals), err)
  151. }
  152. }
  153. // CPU extraction fallback (silent — see batch extraction failed above if applicable)
  154. for i, sig := range signals {
  155. offset := sig.CenterHz - centerHz
  156. shifted := dsp.FreqShift(iq, sampleRate, offset)
  157. bw := sig.BWHz
  158. // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo + RDS
  159. sigMHz := sig.CenterHz / 1e6
  160. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  161. if isWFM {
  162. if bw < wfmStreamMinBW {
  163. bw = wfmStreamMinBW
  164. }
  165. } else if bw < 20000 {
  166. bw = 20000
  167. }
  168. cutoff := bw / 2
  169. if cutoff < 200 {
  170. cutoff = 200
  171. }
  172. if cutoff > float64(sampleRate)/2-1 {
  173. cutoff = float64(sampleRate)/2 - 1
  174. }
  175. taps := dsp.LowpassFIR(cutoff, sampleRate, 101)
  176. filtered := dsp.ApplyFIR(shifted, taps)
  177. decim := sampleRate / decimTarget
  178. if decim < 1 {
  179. decim = 1
  180. }
  181. out[i] = dsp.Decimate(filtered, decim)
  182. rates[i] = sampleRate / decim
  183. }
  184. return out, rates
  185. }
  186. func parseSince(raw string) (time.Time, error) {
  187. if raw == "" {
  188. return time.Time{}, nil
  189. }
  190. if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
  191. if ms > 1e12 {
  192. return time.UnixMilli(ms), nil
  193. }
  194. return time.Unix(ms, 0), nil
  195. }
  196. if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
  197. return t, nil
  198. }
  199. return time.Parse(time.RFC3339, raw)
  200. }
  201. // streamExtractState holds per-signal persistent state for phase-continuous
  202. // GPU extraction. Stored in the DSP loop, keyed by signal ID.
  203. type streamExtractState struct {
  204. phase float64 // FreqShift phase accumulator
  205. }
  206. // streamIQOverlap holds the tail of the previous allIQ for FIR halo prepend.
  207. type streamIQOverlap struct {
  208. tail []complex64
  209. }
  210. // extractionConfig holds audio quality settings for signal extraction.
  211. type extractionConfig struct {
  212. firTaps int // AQ-3: FIR tap count (default 101)
  213. bwMult float64 // AQ-5: BW multiplier (default 1.2)
  214. }
  215. const streamOverlapLen = 512 // must be >= FIR tap count with margin
  216. const (
  217. wfmStreamOutRate = 500000
  218. wfmStreamMinBW = 250000
  219. )
  220. var forceCPUStreamExtract = func() bool {
  221. raw := strings.TrimSpace(os.Getenv("SDR_FORCE_CPU_STREAM_EXTRACT"))
  222. if raw == "" {
  223. return false
  224. }
  225. v, err := strconv.ParseBool(raw)
  226. if err != nil {
  227. return false
  228. }
  229. return v
  230. }()
  231. // extractForStreaming performs GPU-accelerated extraction with:
  232. // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob)
  233. // - IQ overlap prepended to allIQ so FIR kernel has real data in halo
  234. //
  235. // Returns extracted snippets with overlap trimmed, and updates phase state.
  236. func extractForStreaming(
  237. extractMgr *extractionManager,
  238. allIQ []complex64,
  239. sampleRate int,
  240. centerHz float64,
  241. signals []detector.Signal,
  242. phaseState map[int64]*streamExtractState,
  243. overlap *streamIQOverlap,
  244. aqCfg extractionConfig,
  245. ) ([][]complex64, []int) {
  246. out := make([][]complex64, len(signals))
  247. rates := make([]int, len(signals))
  248. if len(allIQ) == 0 || sampleRate <= 0 || len(signals) == 0 {
  249. return out, rates
  250. }
  251. // AQ-3: Use configured overlap length (must cover FIR taps)
  252. overlapNeeded := streamOverlapLen
  253. if aqCfg.firTaps > 0 && aqCfg.firTaps+64 > overlapNeeded {
  254. overlapNeeded = aqCfg.firTaps + 64
  255. }
  256. // Prepend overlap from previous frame so FIR kernel has real halo data
  257. var gpuIQ []complex64
  258. overlapLen := len(overlap.tail)
  259. logging.Debug("extract", "overlap", "len", overlapLen, "needed", overlapNeeded, "allIQ", len(allIQ))
  260. if overlapLen > 0 {
  261. gpuIQ = make([]complex64, overlapLen+len(allIQ))
  262. copy(gpuIQ, overlap.tail)
  263. copy(gpuIQ[overlapLen:], allIQ)
  264. } else {
  265. gpuIQ = allIQ
  266. overlapLen = 0
  267. }
  268. // Save tail for next frame (sized to cover configured FIR taps)
  269. if len(allIQ) > overlapNeeded {
  270. overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-overlapNeeded:]...)
  271. } else {
  272. overlap.tail = append(overlap.tail[:0], allIQ...)
  273. }
  274. decimTarget := 200000
  275. // AQ-5: BW multiplier for extraction (wider = better S/N for weak signals)
  276. bwMult := aqCfg.bwMult
  277. if bwMult <= 0 {
  278. bwMult = 1.0
  279. }
  280. // Build jobs with per-signal phase
  281. jobs := make([]gpudemod.ExtractJob, len(signals))
  282. for i, sig := range signals {
  283. bw := sig.BWHz * bwMult // AQ-5: widen extraction BW
  284. sigMHz := sig.CenterHz / 1e6
  285. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) ||
  286. (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  287. jobOutRate := decimTarget
  288. if isWFM {
  289. jobOutRate = wfmStreamOutRate
  290. if bw < wfmStreamMinBW {
  291. bw = wfmStreamMinBW
  292. }
  293. } else if bw < 20000 {
  294. bw = 20000
  295. }
  296. ps := phaseState[sig.ID]
  297. if ps == nil {
  298. ps = &streamExtractState{}
  299. phaseState[sig.ID] = ps
  300. }
  301. // PhaseStart is where the NEW data begins. But gpuIQ has overlap
  302. // prepended, so the GPU kernel starts processing at the overlap.
  303. // We need to rewind the phase by overlapLen samples so that the
  304. // overlap region gets the correct phase, and the new data region
  305. // starts at ps.phase exactly.
  306. phaseInc := -2.0 * math.Pi * (sig.CenterHz - centerHz) / float64(sampleRate)
  307. gpuPhaseStart := ps.phase - phaseInc*float64(overlapLen)
  308. jobs[i] = gpudemod.ExtractJob{
  309. OffsetHz: sig.CenterHz - centerHz,
  310. BW: bw,
  311. OutRate: jobOutRate,
  312. PhaseStart: gpuPhaseStart,
  313. }
  314. }
  315. // Try GPU BatchRunner with phase unless CPU-only debug is forced.
  316. var runner *gpudemod.BatchRunner
  317. if forceCPUStreamExtract {
  318. logging.Warn("boundary", "force_cpu_stream_extract", "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "signals", len(signals))
  319. } else {
  320. runner = extractMgr.get(len(gpuIQ), sampleRate)
  321. }
  322. if runner != nil {
  323. results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
  324. if err == nil && len(results) == len(signals) {
  325. for i, res := range results {
  326. outRate := res.Rate
  327. if outRate <= 0 {
  328. outRate = decimTarget
  329. }
  330. sigMHz := signals[i].CenterHz / 1e6
  331. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (signals[i].Class != nil && (signals[i].Class.ModType == "WFM" || signals[i].Class.ModType == "WFM_STEREO"))
  332. if isWFM {
  333. outRate = wfmStreamOutRate
  334. }
  335. decim := sampleRate / outRate
  336. if decim < 1 {
  337. decim = 1
  338. }
  339. trimSamples := (overlapLen + decim - 1) / decim
  340. if i == 0 {
  341. logging.Debug("extract", "gpu_result", "rate", res.Rate, "outRate", outRate, "decim", decim, "trim", trimSamples)
  342. }
  343. // Update phase state — advance only by NEW data length, not overlap
  344. phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate)
  345. phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ))
  346. // Normalize to [-π, π) to prevent float64 drift over long runs
  347. phaseState[signals[i].ID].phase = math.Remainder(phaseState[signals[i].ID].phase, 2*math.Pi)
  348. // Trim overlap from output
  349. iq := res.IQ
  350. rawLen := len(iq)
  351. if trimSamples > 0 && trimSamples < len(iq) {
  352. iq = iq[trimSamples:]
  353. }
  354. if i == 0 {
  355. 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)
  356. logExtractorHeadComparison(signals[i].ID, "gpu", overlapLen, res.IQ, trimSamples, iq)
  357. }
  358. out[i] = iq
  359. rates[i] = res.Rate
  360. }
  361. return out, rates
  362. } else if err != nil {
  363. log.Printf("gpudemod: stream batch extraction failed: %v", err)
  364. }
  365. }
  366. // CPU fallback (with phase tracking)
  367. for i, sig := range signals {
  368. offset := sig.CenterHz - centerHz
  369. bw := jobs[i].BW
  370. ps := phaseState[sig.ID]
  371. // Phase-continuous FreqShift — rewind by overlap so new data starts at ps.phase
  372. shifted := make([]complex64, len(gpuIQ))
  373. inc := -2.0 * math.Pi * offset / float64(sampleRate)
  374. phase := ps.phase - inc*float64(overlapLen)
  375. for k, v := range gpuIQ {
  376. phase += inc
  377. re := math.Cos(phase)
  378. im := math.Sin(phase)
  379. shifted[k] = complex(
  380. float32(float64(real(v))*re-float64(imag(v))*im),
  381. float32(float64(real(v))*im+float64(imag(v))*re),
  382. )
  383. }
  384. // Advance phase by NEW data length only
  385. ps.phase += inc * float64(len(allIQ))
  386. ps.phase = math.Remainder(ps.phase, 2*math.Pi)
  387. cutoff := bw / 2
  388. if cutoff < 200 {
  389. cutoff = 200
  390. }
  391. if cutoff > float64(sampleRate)/2-1 {
  392. cutoff = float64(sampleRate)/2 - 1
  393. }
  394. firTaps := 101
  395. if aqCfg.firTaps > 0 {
  396. firTaps = aqCfg.firTaps
  397. }
  398. taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps)
  399. filtered := dsp.ApplyFIR(shifted, taps)
  400. sigMHz := sig.CenterHz / 1e6
  401. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  402. outRate := decimTarget
  403. if isWFM {
  404. outRate = wfmStreamOutRate
  405. }
  406. decim := sampleRate / outRate
  407. if decim < 1 {
  408. decim = 1
  409. }
  410. decimated := dsp.Decimate(filtered, decim)
  411. rates[i] = sampleRate / decim
  412. // Trim overlap — use ceil to ensure ALL overlap samples are removed.
  413. // Floor trim (overlapLen/decim) leaves a remainder for non-divisible
  414. // factors (e.g. 512/20=25 trims only 500 of 512 samples → 12 leak).
  415. trimSamples := (overlapLen + decim - 1) / decim
  416. if i == 0 {
  417. logging.Debug("extract", "cpu_result", "outRate", outRate, "decim", decim, "trim", trimSamples)
  418. }
  419. rawLen := len(decimated)
  420. if trimSamples > 0 && trimSamples < len(decimated) {
  421. decimated = decimated[trimSamples:]
  422. }
  423. if i == 0 {
  424. 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)
  425. logExtractorHeadComparison(signals[i].ID, "cpu", overlapLen, decimated, trimSamples, decimated)
  426. }
  427. out[i] = decimated
  428. }
  429. return out, rates
  430. }
  431. type iqHeadStats struct {
  432. length int
  433. minMag float64
  434. maxMag float64
  435. meanMag float64
  436. lowMag int
  437. maxStep float64
  438. maxStepIdx int
  439. p95Step float64
  440. headTail float64
  441. headMinIdx int
  442. stepSamples []float64
  443. }
  444. func computeIQHeadStats(iq []complex64, headLen int) iqHeadStats {
  445. stats := iqHeadStats{minMag: math.MaxFloat64, headMinIdx: -1, maxStepIdx: -1}
  446. if len(iq) == 0 {
  447. stats.minMag = 0
  448. return stats
  449. }
  450. n := len(iq)
  451. if headLen > 0 && headLen < n {
  452. n = headLen
  453. }
  454. stats.length = n
  455. stats.stepSamples = make([]float64, 0, max(0, n-1))
  456. sumMag := 0.0
  457. headSum := 0.0
  458. tailSum := 0.0
  459. tailCount := 0
  460. for i := 0; i < n; i++ {
  461. v := iq[i]
  462. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  463. if mag < stats.minMag {
  464. stats.minMag = mag
  465. stats.headMinIdx = i
  466. }
  467. if mag > stats.maxMag {
  468. stats.maxMag = mag
  469. }
  470. sumMag += mag
  471. if mag < 0.05 {
  472. stats.lowMag++
  473. }
  474. if i < min(16, n) {
  475. headSum += mag
  476. }
  477. if i >= max(0, n-16) {
  478. tailSum += mag
  479. tailCount++
  480. }
  481. if i > 0 {
  482. p := iq[i-1]
  483. num := float64(real(p))*float64(imag(v)) - float64(imag(p))*float64(real(v))
  484. den := float64(real(p))*float64(real(v)) + float64(imag(p))*float64(imag(v))
  485. step := math.Abs(math.Atan2(num, den))
  486. if step > stats.maxStep {
  487. stats.maxStep = step
  488. stats.maxStepIdx = i - 1
  489. }
  490. stats.stepSamples = append(stats.stepSamples, step)
  491. }
  492. }
  493. stats.meanMag = sumMag / float64(n)
  494. if len(stats.stepSamples) > 0 {
  495. sorted := append([]float64(nil), stats.stepSamples...)
  496. sort.Float64s(sorted)
  497. idx := int(float64(len(sorted)-1) * 0.95)
  498. stats.p95Step = sorted[idx]
  499. } else {
  500. stats.p95Step = stats.maxStep
  501. }
  502. if headSum > 0 && tailCount > 0 {
  503. headMean := headSum / float64(min(16, n))
  504. tailMean := tailSum / float64(tailCount)
  505. if tailMean > 0 {
  506. stats.headTail = headMean / tailMean
  507. }
  508. }
  509. return stats
  510. }
  511. func observeIQStats(coll *telemetry.Collector, stage string, iq []complex64, tags telemetry.Tags) {
  512. if coll == nil || len(iq) == 0 {
  513. return
  514. }
  515. stats := computeIQHeadStats(iq, len(iq))
  516. stageTags := telemetry.TagsWith(tags, "stage", stage)
  517. coll.Observe("iq.magnitude.min", stats.minMag, stageTags)
  518. coll.Observe("iq.magnitude.max", stats.maxMag, stageTags)
  519. coll.Observe("iq.magnitude.mean", stats.meanMag, stageTags)
  520. coll.Observe("iq.phase_step.max", stats.maxStep, stageTags)
  521. coll.Observe("iq.phase_step.p95", stats.p95Step, stageTags)
  522. coll.Observe("iq.low_magnitude.count", float64(stats.lowMag), stageTags)
  523. coll.SetGauge("iq.length", float64(stats.length), stageTags)
  524. }
  525. func logExtractorHeadComparison(signalID int64, path string, overlapLen int, raw []complex64, trimSamples int, out []complex64) {
  526. rawStats := computeIQHeadStats(raw, 96)
  527. trimmedStats := computeIQHeadStats(out, 96)
  528. logging.Debug("boundary", "extract_head_compare",
  529. "signal", signalID,
  530. "path", path,
  531. "raw_len", len(raw),
  532. "trim", trimSamples,
  533. "out_len", len(out),
  534. "overlap_len", overlapLen,
  535. "raw_min_mag", rawStats.minMag,
  536. "raw_min_idx", rawStats.headMinIdx,
  537. "raw_max_step", rawStats.maxStep,
  538. "raw_max_step_idx", rawStats.maxStepIdx,
  539. "raw_head_tail", rawStats.headTail,
  540. "trimmed_min_mag", trimmedStats.minMag,
  541. "trimmed_min_idx", trimmedStats.headMinIdx,
  542. "trimmed_max_step", trimmedStats.maxStep,
  543. "trimmed_max_step_idx", trimmedStats.maxStepIdx,
  544. "trimmed_head_tail", trimmedStats.headTail,
  545. )
  546. for _, off := range []int{2, 4, 8, 16} {
  547. if len(out) <= off+8 {
  548. continue
  549. }
  550. offStats := computeIQHeadStats(out[off:], 96)
  551. logging.Debug("boundary", "extract_head_offset_compare",
  552. "signal", signalID,
  553. "path", path,
  554. "offset", off,
  555. "base_min_mag", trimmedStats.minMag,
  556. "base_min_idx", trimmedStats.headMinIdx,
  557. "base_max_step", trimmedStats.maxStep,
  558. "base_max_step_idx", trimmedStats.maxStepIdx,
  559. "offset_min_mag", offStats.minMag,
  560. "offset_min_idx", offStats.headMinIdx,
  561. "offset_max_step", offStats.maxStep,
  562. "offset_max_step_idx", offStats.maxStepIdx,
  563. "offset_head_tail", offStats.headTail,
  564. )
  565. }
  566. }