You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

729 line
20KB

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