Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

732 linhas
20KB

  1. package detector
  2. import (
  3. "math"
  4. "sort"
  5. "time"
  6. "sdr-wideband-suite/internal/cfar"
  7. "sdr-wideband-suite/internal/classifier"
  8. "sdr-wideband-suite/internal/config"
  9. )
  10. type Event struct {
  11. ID int64 `json:"id"`
  12. Start time.Time `json:"start"`
  13. End time.Time `json:"end"`
  14. CenterHz float64 `json:"center_hz"`
  15. Bandwidth float64 `json:"bandwidth_hz"`
  16. PeakDb float64 `json:"peak_db"`
  17. SNRDb float64 `json:"snr_db"`
  18. FirstBin int `json:"first_bin"`
  19. LastBin int `json:"last_bin"`
  20. Class *classifier.Classification `json:"class,omitempty"`
  21. }
  22. type Detector struct {
  23. ThresholdDb float64
  24. MinDuration time.Duration
  25. Hold time.Duration
  26. EmaAlpha float64
  27. HysteresisDb float64
  28. MinStableFrames int
  29. GapTolerance time.Duration
  30. CFARScaleDb float64
  31. EdgeMarginDb float64
  32. MaxSignalBwHz float64
  33. MergeGapHz float64
  34. classHistorySize int
  35. classSwitchRatio float64
  36. binWidth float64
  37. nbins int
  38. sampleRate int
  39. ema []float64
  40. active map[int64]*activeEvent
  41. nextID int64
  42. cfarEngine cfar.CFAR
  43. lastThresholds []float64
  44. lastNoiseFloor float64
  45. lastProcessTime time.Time
  46. }
  47. type activeEvent struct {
  48. id int64
  49. start time.Time
  50. lastSeen time.Time
  51. centerHz float64
  52. bwHz float64
  53. peakDb float64
  54. snrDb float64
  55. firstBin int
  56. lastBin int
  57. class *classifier.Classification
  58. stableHits int
  59. missedFrames int // Consecutive frames without a matching raw signal
  60. classHistory []classifier.SignalClass
  61. classIdx int
  62. }
  63. type Signal struct {
  64. ID int64 `json:"id"`
  65. FirstBin int `json:"first_bin"`
  66. LastBin int `json:"last_bin"`
  67. CenterHz float64 `json:"center_hz"`
  68. BWHz float64 `json:"bw_hz"`
  69. PeakDb float64 `json:"peak_db"`
  70. SNRDb float64 `json:"snr_db"`
  71. NoiseDb float64 `json:"noise_db,omitempty"`
  72. Class *classifier.Classification `json:"class,omitempty"`
  73. PLL *classifier.PLLResult `json:"pll,omitempty"`
  74. DemodName string `json:"demod,omitempty"`
  75. PlaybackMode string `json:"playback_mode,omitempty"`
  76. StereoState string `json:"stereo_state,omitempty"`
  77. }
  78. func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector {
  79. minDur := time.Duration(detCfg.MinDurationMs) * time.Millisecond
  80. hold := time.Duration(detCfg.HoldMs) * time.Millisecond
  81. gapTolerance := time.Duration(detCfg.GapToleranceMs) * time.Millisecond
  82. emaAlpha := detCfg.EmaAlpha
  83. hysteresis := detCfg.HysteresisDb
  84. minStable := detCfg.MinStableFrames
  85. cfarMode := detCfg.CFARMode
  86. binWidth := float64(sampleRate) / float64(fftSize)
  87. cfarGuard := int(math.Ceil(detCfg.CFARGuardHz / binWidth))
  88. if cfarGuard < 0 {
  89. cfarGuard = 0
  90. }
  91. cfarTrain := int(math.Ceil(detCfg.CFARTrainHz / binWidth))
  92. if cfarTrain < 1 {
  93. cfarTrain = 1
  94. }
  95. cfarRank := detCfg.CFARRank
  96. cfarScaleDb := detCfg.CFARScaleDb
  97. cfarWrap := detCfg.CFARWrapAround
  98. thresholdDb := detCfg.ThresholdDb
  99. edgeMarginDb := detCfg.EdgeMarginDb
  100. maxSignalBwHz := detCfg.MaxSignalBwHz
  101. mergeGapHz := detCfg.MergeGapHz
  102. classHistorySize := detCfg.ClassHistorySize
  103. classSwitchRatio := detCfg.ClassSwitchRatio
  104. if minDur <= 0 {
  105. minDur = 250 * time.Millisecond
  106. }
  107. if hold <= 0 {
  108. hold = 500 * time.Millisecond
  109. }
  110. if emaAlpha <= 0 || emaAlpha > 1 {
  111. emaAlpha = 0.2
  112. }
  113. if hysteresis <= 0 {
  114. hysteresis = 3
  115. }
  116. if minStable <= 0 {
  117. minStable = 3
  118. }
  119. if gapTolerance <= 0 {
  120. gapTolerance = hold
  121. }
  122. if cfarScaleDb <= 0 {
  123. cfarScaleDb = 6
  124. }
  125. if edgeMarginDb <= 0 {
  126. edgeMarginDb = 3.0
  127. }
  128. if maxSignalBwHz <= 0 {
  129. maxSignalBwHz = 150000
  130. }
  131. if mergeGapHz <= 0 {
  132. mergeGapHz = 5000
  133. }
  134. if classHistorySize <= 0 {
  135. classHistorySize = 10
  136. }
  137. if classSwitchRatio <= 0 || classSwitchRatio > 1 {
  138. classSwitchRatio = 0.6
  139. }
  140. if cfarRank <= 0 || cfarRank > 2*cfarTrain {
  141. cfarRank = int(math.Round(0.75 * float64(2*cfarTrain)))
  142. if cfarRank <= 0 {
  143. cfarRank = 1
  144. }
  145. }
  146. var cfarEngine cfar.CFAR
  147. if cfarMode != "" && cfarMode != "OFF" {
  148. cfarEngine = cfar.New(cfar.Config{
  149. Mode: cfar.Mode(cfarMode),
  150. GuardCells: cfarGuard,
  151. TrainCells: cfarTrain,
  152. Rank: cfarRank,
  153. ScaleDb: cfarScaleDb,
  154. WrapAround: cfarWrap,
  155. })
  156. }
  157. return &Detector{
  158. ThresholdDb: thresholdDb,
  159. MinDuration: minDur,
  160. Hold: hold,
  161. EmaAlpha: emaAlpha,
  162. HysteresisDb: hysteresis,
  163. MinStableFrames: minStable,
  164. GapTolerance: gapTolerance,
  165. CFARScaleDb: cfarScaleDb,
  166. EdgeMarginDb: edgeMarginDb,
  167. MaxSignalBwHz: maxSignalBwHz,
  168. MergeGapHz: mergeGapHz,
  169. classHistorySize: classHistorySize,
  170. classSwitchRatio: classSwitchRatio,
  171. binWidth: float64(sampleRate) / float64(fftSize),
  172. nbins: fftSize,
  173. sampleRate: sampleRate,
  174. ema: make([]float64, fftSize),
  175. active: map[int64]*activeEvent{},
  176. nextID: 1,
  177. cfarEngine: cfarEngine,
  178. }
  179. }
  180. func (d *Detector) Process(now time.Time, spectrum []float64, centerHz float64) ([]Event, []Signal) {
  181. // Compute frame-rate adaptive alpha for consistent smoothing regardless of fps
  182. dt := now.Sub(d.lastProcessTime).Seconds()
  183. if d.lastProcessTime.IsZero() || dt <= 0 || dt > 1.0 {
  184. dt = 1.0 / 15.0
  185. }
  186. d.lastProcessTime = now
  187. dtRef := 1.0 / 15.0
  188. ratio := dt / dtRef
  189. adaptiveAlpha := 1.0 - math.Pow(1.0-d.EmaAlpha, ratio)
  190. if adaptiveAlpha < 0.01 {
  191. adaptiveAlpha = 0.01
  192. }
  193. if adaptiveAlpha > 0.99 {
  194. adaptiveAlpha = 0.99
  195. }
  196. signals := d.detectSignals(spectrum, centerHz, adaptiveAlpha)
  197. finished := d.matchSignals(now, signals, adaptiveAlpha)
  198. return finished, signals
  199. }
  200. func (d *Detector) LastThresholds() []float64 {
  201. if len(d.lastThresholds) == 0 {
  202. return nil
  203. }
  204. return append([]float64(nil), d.lastThresholds...)
  205. }
  206. func (d *Detector) LastNoiseFloor() float64 {
  207. return d.lastNoiseFloor
  208. }
  209. func (ev *activeEvent) updateClass(newCls *classifier.Classification, historySize int, switchRatio float64) {
  210. if newCls == nil {
  211. return
  212. }
  213. if historySize <= 0 {
  214. historySize = 10
  215. }
  216. if switchRatio <= 0 || switchRatio > 1 {
  217. switchRatio = 0.6
  218. }
  219. if len(ev.classHistory) != historySize {
  220. ev.classHistory = make([]classifier.SignalClass, historySize)
  221. ev.classIdx = 0
  222. }
  223. ev.classHistory[ev.classIdx%len(ev.classHistory)] = newCls.ModType
  224. ev.classIdx++
  225. if ev.class == nil {
  226. clone := *newCls
  227. ev.class = &clone
  228. return
  229. }
  230. counts := map[classifier.SignalClass]int{}
  231. filled := ev.classIdx
  232. if filled > len(ev.classHistory) {
  233. filled = len(ev.classHistory)
  234. }
  235. for i := 0; i < filled; i++ {
  236. c := ev.classHistory[i]
  237. if c != "" {
  238. counts[c]++
  239. }
  240. }
  241. var majority classifier.SignalClass
  242. majorityCount := 0
  243. for c, n := range counts {
  244. if n > majorityCount {
  245. majority = c
  246. majorityCount = n
  247. }
  248. }
  249. threshold := int(math.Ceil(float64(filled) * switchRatio))
  250. if threshold < 1 {
  251. threshold = 1
  252. }
  253. if majorityCount >= threshold && majority != ev.class.ModType {
  254. clone := *newCls
  255. clone.ModType = majority
  256. ev.class = &clone
  257. } else if majority == ev.class.ModType && newCls.Confidence > ev.class.Confidence {
  258. ev.class.Confidence = newCls.Confidence
  259. ev.class.Features = newCls.Features
  260. ev.class.SecondBest = newCls.SecondBest
  261. ev.class.Scores = newCls.Scores
  262. }
  263. // Always update PLL — RDS station name accumulates over time
  264. if newCls.PLL != nil {
  265. ev.class.PLL = newCls.PLL
  266. }
  267. }
  268. func (d *Detector) UpdateClasses(signals []Signal) {
  269. for _, s := range signals {
  270. for _, ev := range d.active {
  271. if overlapHz(s.CenterHz, s.BWHz, ev.centerHz, ev.bwHz) && math.Abs(s.CenterHz-ev.centerHz) < (s.BWHz+ev.bwHz)/2.0 {
  272. if s.Class != nil {
  273. ev.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio)
  274. }
  275. }
  276. }
  277. }
  278. }
  279. // StableSignals returns the smoothed active events as a Signal list for frontend display.
  280. // Only events that have been seen for at least MinStableFrames are included.
  281. // Output is sorted by CenterHz for consistent ordering across frames.
  282. func (d *Detector) StableSignals() []Signal {
  283. var out []Signal
  284. for _, ev := range d.active {
  285. if ev.stableHits < d.MinStableFrames {
  286. continue
  287. }
  288. sig := Signal{
  289. ID: ev.id,
  290. FirstBin: ev.firstBin,
  291. LastBin: ev.lastBin,
  292. CenterHz: ev.centerHz,
  293. BWHz: ev.bwHz,
  294. PeakDb: ev.peakDb,
  295. SNRDb: ev.snrDb,
  296. Class: ev.class,
  297. }
  298. if ev.class != nil && ev.class.PLL != nil {
  299. sig.PLL = ev.class.PLL
  300. }
  301. out = append(out, sig)
  302. }
  303. sort.Slice(out, func(i, j int) bool { return out[i].CenterHz < out[j].CenterHz })
  304. return out
  305. }
  306. func (d *Detector) detectSignals(spectrum []float64, centerHz float64, adaptiveAlpha float64) []Signal {
  307. n := len(spectrum)
  308. if n == 0 {
  309. return nil
  310. }
  311. smooth := d.smoothSpectrum(spectrum, adaptiveAlpha)
  312. var thresholds []float64
  313. if d.cfarEngine != nil {
  314. thresholds = d.cfarEngine.Thresholds(smooth)
  315. }
  316. d.lastThresholds = append(d.lastThresholds[:0], thresholds...)
  317. noiseGlobal := median(smooth)
  318. d.lastNoiseFloor = noiseGlobal
  319. var signals []Signal
  320. in := false
  321. start := 0
  322. peak := -1e9
  323. peakBin := 0
  324. for i := 0; i < n; i++ {
  325. v := smooth[i]
  326. thresholdOn := d.ThresholdDb
  327. if thresholds != nil && !math.IsNaN(thresholds[i]) {
  328. thresholdOn = thresholds[i]
  329. }
  330. thresholdOff := thresholdOn - d.HysteresisDb
  331. if v >= thresholdOn {
  332. if !in {
  333. in = true
  334. start = i
  335. peak = v
  336. peakBin = i
  337. } else if v > peak {
  338. peak = v
  339. peakBin = i
  340. }
  341. } else if in && v < thresholdOff {
  342. noise := noiseGlobal
  343. if thresholds != nil && peakBin >= 0 && peakBin < len(thresholds) && !math.IsNaN(thresholds[peakBin]) {
  344. noise = thresholds[peakBin] - d.CFARScaleDb
  345. }
  346. signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, smooth))
  347. in = false
  348. }
  349. }
  350. if in {
  351. noise := noiseGlobal
  352. if thresholds != nil && peakBin >= 0 && peakBin < len(thresholds) && !math.IsNaN(thresholds[peakBin]) {
  353. noise = thresholds[peakBin] - d.CFARScaleDb
  354. }
  355. signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, smooth))
  356. }
  357. signals = d.expandSignalEdges(signals, smooth, noiseGlobal, centerHz)
  358. for i := range signals {
  359. // Use power-weighted centroid for accurate center frequency
  360. signals[i].CenterHz = d.powerWeightedCenter(smooth, signals[i].FirstBin, signals[i].LastBin, centerHz)
  361. signals[i].BWHz = float64(signals[i].LastBin-signals[i].FirstBin+1) * d.binWidth
  362. }
  363. return signals
  364. }
  365. func (d *Detector) expandSignalEdges(signals []Signal, smooth []float64, noiseFloor float64, centerHz float64) []Signal {
  366. n := len(smooth)
  367. if n == 0 || len(signals) == 0 {
  368. return signals
  369. }
  370. margin := d.EdgeMarginDb
  371. if margin <= 0 {
  372. margin = 3.0
  373. }
  374. maxExpansionBins := int(d.MaxSignalBwHz / d.binWidth)
  375. if maxExpansionBins < 10 {
  376. maxExpansionBins = 10
  377. }
  378. for i := range signals {
  379. seed := signals[i]
  380. peakDb := seed.PeakDb
  381. localNoise := noiseFloor
  382. leftProbe := seed.FirstBin - 50
  383. rightProbe := seed.LastBin + 50
  384. if leftProbe >= 0 && rightProbe < n {
  385. leftNoise := minInRange(smooth, maxInt(0, leftProbe), maxInt(0, seed.FirstBin-5))
  386. rightNoise := minInRange(smooth, minInt(n-1, seed.LastBin+5), minInt(n-1, rightProbe))
  387. localNoise = math.Min(leftNoise, rightNoise)
  388. }
  389. edgeThreshold := localNoise + margin
  390. newFirst := seed.FirstBin
  391. prevVal := smooth[newFirst]
  392. for j := 0; j < maxExpansionBins; j++ {
  393. next := newFirst - 1
  394. if next < 0 {
  395. break
  396. }
  397. val := smooth[next]
  398. if val <= edgeThreshold {
  399. break
  400. }
  401. if val > prevVal+1.0 && val < peakDb-6.0 {
  402. break
  403. }
  404. prevVal = val
  405. newFirst = next
  406. }
  407. newLast := seed.LastBin
  408. prevVal = smooth[newLast]
  409. for j := 0; j < maxExpansionBins; j++ {
  410. next := newLast + 1
  411. if next >= n {
  412. break
  413. }
  414. val := smooth[next]
  415. if val <= edgeThreshold {
  416. break
  417. }
  418. if val > prevVal+1.0 && val < peakDb-6.0 {
  419. break
  420. }
  421. prevVal = val
  422. newLast = next
  423. }
  424. signals[i].FirstBin = newFirst
  425. signals[i].LastBin = newLast
  426. // CenterHz will be recalculated with power-weighted centroid after expansion
  427. signals[i].BWHz = float64(newLast-newFirst+1) * d.binWidth
  428. }
  429. signals = d.mergeOverlapping(signals, centerHz)
  430. return signals
  431. }
  432. func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal {
  433. if len(signals) <= 1 {
  434. return signals
  435. }
  436. gapBins := 0
  437. if d.MergeGapHz > 0 && d.binWidth > 0 {
  438. gapBins = int(math.Ceil(d.MergeGapHz / d.binWidth))
  439. }
  440. sort.Slice(signals, func(i, j int) bool {
  441. return signals[i].FirstBin < signals[j].FirstBin
  442. })
  443. merged := []Signal{signals[0]}
  444. for i := 1; i < len(signals); i++ {
  445. last := &merged[len(merged)-1]
  446. cur := signals[i]
  447. gap := cur.FirstBin - last.LastBin - 1
  448. if gap <= gapBins {
  449. if cur.LastBin > last.LastBin {
  450. last.LastBin = cur.LastBin
  451. }
  452. if cur.PeakDb > last.PeakDb {
  453. last.PeakDb = cur.PeakDb
  454. }
  455. if cur.SNRDb > last.SNRDb {
  456. last.SNRDb = cur.SNRDb
  457. }
  458. centerBin := float64(last.FirstBin+last.LastBin) / 2.0
  459. last.BWHz = float64(last.LastBin-last.FirstBin+1) * d.binWidth
  460. last.CenterHz = d.centerFreqForBin(centerBin, centerHz)
  461. if cur.NoiseDb < last.NoiseDb || last.NoiseDb == 0 {
  462. last.NoiseDb = cur.NoiseDb
  463. }
  464. } else {
  465. merged = append(merged, cur)
  466. }
  467. }
  468. return merged
  469. }
  470. func (d *Detector) centerFreqForBin(bin float64, centerHz float64) float64 {
  471. return centerHz + (bin-float64(d.nbins)/2.0)*d.binWidth
  472. }
  473. // powerWeightedCenter computes the power-weighted centroid frequency within [first, last].
  474. // This is more accurate than the midpoint because it accounts for asymmetric signal shapes.
  475. func (d *Detector) powerWeightedCenter(spectrum []float64, first, last int, centerHz float64) float64 {
  476. if first > last || first < 0 || last >= len(spectrum) {
  477. centerBin := float64(first+last) / 2.0
  478. return d.centerFreqForBin(centerBin, centerHz)
  479. }
  480. // Convert dB to linear, compute weighted average bin
  481. var sumPower, sumWeighted float64
  482. for i := first; i <= last; i++ {
  483. p := math.Pow(10, spectrum[i]/10.0)
  484. sumPower += p
  485. sumWeighted += p * float64(i)
  486. }
  487. if sumPower <= 0 {
  488. centerBin := float64(first+last) / 2.0
  489. return d.centerFreqForBin(centerBin, centerHz)
  490. }
  491. centroidBin := sumWeighted / sumPower
  492. return d.centerFreqForBin(centroidBin, centerHz)
  493. }
  494. func minInRange(s []float64, from, to int) float64 {
  495. if len(s) == 0 {
  496. return 0
  497. }
  498. if from < 0 {
  499. from = 0
  500. }
  501. if to >= len(s) {
  502. to = len(s) - 1
  503. }
  504. if from > to {
  505. return s[minInt(maxInt(from, 0), len(s)-1)]
  506. }
  507. m := s[from]
  508. for i := from + 1; i <= to; i++ {
  509. if s[i] < m {
  510. m = s[i]
  511. }
  512. }
  513. return m
  514. }
  515. func maxInt(a, b int) int {
  516. if a > b {
  517. return a
  518. }
  519. return b
  520. }
  521. func minInt(a, b int) int {
  522. if a < b {
  523. return a
  524. }
  525. return b
  526. }
  527. func overlapHz(center1, bw1, center2, bw2 float64) bool {
  528. left1 := center1 - bw1/2
  529. right1 := center1 + bw1/2
  530. left2 := center2 - bw2/2
  531. right2 := center2 + bw2/2
  532. return left1 <= right2 && left2 <= right1
  533. }
  534. func median(vals []float64) float64 {
  535. if len(vals) == 0 {
  536. return 0
  537. }
  538. cp := append([]float64(nil), vals...)
  539. sort.Float64s(cp)
  540. mid := len(cp) / 2
  541. if len(cp)%2 == 0 {
  542. return (cp[mid-1] + cp[mid]) / 2
  543. }
  544. return cp[mid]
  545. }
  546. func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64, spectrum []float64) Signal {
  547. // Use peak bin for center frequency — more accurate than midpoint of first/last
  548. // because edge expansion can be asymmetric
  549. centerFreq := centerHz + (float64(peakBin)-float64(d.nbins)/2.0)*d.binWidth
  550. bw := float64(last-first+1) * d.binWidth
  551. snr := peak - noise
  552. return Signal{
  553. FirstBin: first,
  554. LastBin: last,
  555. CenterHz: centerFreq,
  556. BWHz: bw,
  557. PeakDb: peak,
  558. SNRDb: snr,
  559. NoiseDb: noise,
  560. }
  561. }
  562. func (d *Detector) smoothSpectrum(spectrum []float64, alpha float64) []float64 {
  563. if d.ema == nil || len(d.ema) != len(spectrum) {
  564. d.ema = make([]float64, len(spectrum))
  565. copy(d.ema, spectrum)
  566. return d.ema
  567. }
  568. for i := range spectrum {
  569. v := spectrum[i]
  570. d.ema[i] = alpha*v + (1-alpha)*d.ema[i]
  571. }
  572. return d.ema
  573. }
  574. func (d *Detector) matchSignals(now time.Time, signals []Signal, adaptiveAlpha float64) []Event {
  575. used := make(map[int64]bool, len(d.active))
  576. signalUsed := make([]bool, len(signals))
  577. smoothAlpha := adaptiveAlpha
  578. // Sort active events by maturity (stableHits descending).
  579. // Mature events match FIRST, preventing ghost/new events from stealing their signals.
  580. // Without this, Go map iteration is random and a 1-frame-old ghost can steal
  581. // a signal from a 1000-frame-old stable event.
  582. type eventEntry struct {
  583. id int64
  584. ev *activeEvent
  585. }
  586. sortedEvents := make([]eventEntry, 0, len(d.active))
  587. for id, ev := range d.active {
  588. sortedEvents = append(sortedEvents, eventEntry{id, ev})
  589. }
  590. sort.Slice(sortedEvents, func(i, j int) bool {
  591. return sortedEvents[i].ev.stableHits > sortedEvents[j].ev.stableHits
  592. })
  593. // Event-first matching: for each active event (mature first), find the closest unmatched raw signal.
  594. for _, entry := range sortedEvents {
  595. id, ev := entry.id, entry.ev
  596. bestIdx := -1
  597. bestDist := math.MaxFloat64
  598. for i, s := range signals {
  599. if signalUsed[i] {
  600. continue
  601. }
  602. // Use wider of raw and event BW for matching tolerance
  603. matchBW := math.Max(s.BWHz, ev.bwHz)
  604. if matchBW < 20000 {
  605. matchBW = 20000 // Minimum 20 kHz matching window
  606. }
  607. dist := math.Abs(s.CenterHz - ev.centerHz)
  608. if dist < matchBW && dist < bestDist {
  609. bestIdx = i
  610. bestDist = dist
  611. }
  612. }
  613. if bestIdx < 0 {
  614. continue
  615. }
  616. signalUsed[bestIdx] = true
  617. used[id] = true
  618. s := signals[bestIdx]
  619. ev.lastSeen = now
  620. ev.stableHits++
  621. ev.missedFrames = 0 // Reset miss counter on successful match
  622. ev.centerHz = smoothAlpha*s.CenterHz + (1-smoothAlpha)*ev.centerHz
  623. if ev.bwHz <= 0 {
  624. ev.bwHz = s.BWHz
  625. } else {
  626. ev.bwHz = smoothAlpha*s.BWHz + (1-smoothAlpha)*ev.bwHz
  627. }
  628. ev.peakDb = smoothAlpha*s.PeakDb + (1-smoothAlpha)*ev.peakDb
  629. ev.snrDb = smoothAlpha*s.SNRDb + (1-smoothAlpha)*ev.snrDb
  630. ev.firstBin = int(math.Round(smoothAlpha*float64(s.FirstBin) + (1-smoothAlpha)*float64(ev.firstBin)))
  631. ev.lastBin = int(math.Round(smoothAlpha*float64(s.LastBin) + (1-smoothAlpha)*float64(ev.lastBin)))
  632. if s.Class != nil {
  633. ev.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio)
  634. }
  635. }
  636. // Create new events for unmatched raw signals
  637. for i, s := range signals {
  638. if signalUsed[i] {
  639. continue
  640. }
  641. id := d.nextID
  642. d.nextID++
  643. d.active[id] = &activeEvent{
  644. id: id,
  645. start: now,
  646. lastSeen: now,
  647. centerHz: s.CenterHz,
  648. bwHz: s.BWHz,
  649. peakDb: s.PeakDb,
  650. snrDb: s.SNRDb,
  651. firstBin: s.FirstBin,
  652. lastBin: s.LastBin,
  653. class: s.Class,
  654. stableHits: 1,
  655. }
  656. }
  657. var finished []Event
  658. for id, ev := range d.active {
  659. if used[id] {
  660. continue
  661. }
  662. // Event was NOT matched this frame — increment miss counter
  663. ev.missedFrames++
  664. // Proportional gap tolerance: mature events are much harder to kill.
  665. // A new event (stableHits=3) dies after GapTolerance (e.g. 500ms).
  666. // A mature event (stableHits=300, i.e. ~10 seconds) gets 10x GapTolerance.
  667. // This prevents FM broadcast events from dying during brief CFAR dips.
  668. maturityFactor := 1.0 + math.Log1p(float64(ev.stableHits)/10.0)
  669. if maturityFactor > 20.0 {
  670. maturityFactor = 20.0 // Cap at 20x base tolerance
  671. }
  672. effectiveTolerance := time.Duration(float64(d.GapTolerance) * maturityFactor)
  673. if now.Sub(ev.lastSeen) < effectiveTolerance {
  674. continue
  675. }
  676. duration := ev.lastSeen.Sub(ev.start)
  677. if duration < d.MinDuration || ev.stableHits < d.MinStableFrames {
  678. delete(d.active, id)
  679. continue
  680. }
  681. finished = append(finished, Event{
  682. ID: ev.id,
  683. Start: ev.start,
  684. End: ev.lastSeen,
  685. CenterHz: ev.centerHz,
  686. Bandwidth: ev.bwHz,
  687. PeakDb: ev.peakDb,
  688. SNRDb: ev.snrDb,
  689. FirstBin: ev.firstBin,
  690. LastBin: ev.lastBin,
  691. Class: ev.class,
  692. })
  693. delete(d.active, id)
  694. }
  695. return finished
  696. }