| @@ -314,19 +314,7 @@ func main() { | |||
| defer eventFile.Close() | |||
| eventMu := &sync.RWMutex{} | |||
| det := detector.New(cfg.Detector.ThresholdDb, cfg.SampleRate, cfg.FFTSize, | |||
| time.Duration(cfg.Detector.MinDurationMs)*time.Millisecond, | |||
| time.Duration(cfg.Detector.HoldMs)*time.Millisecond, | |||
| cfg.Detector.EmaAlpha, | |||
| cfg.Detector.HysteresisDb, | |||
| cfg.Detector.MinStableFrames, | |||
| time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond, | |||
| cfg.Detector.CFARMode, | |||
| cfg.Detector.CFARGuardCells, | |||
| cfg.Detector.CFARTrainCells, | |||
| cfg.Detector.CFARRank, | |||
| cfg.Detector.CFARScaleDb, | |||
| cfg.Detector.CFARWrapAround) | |||
| det := detector.New(cfg.Detector, cfg.SampleRate, cfg.FFTSize) | |||
| window := fftutil.Hann(cfg.FFTSize) | |||
| h := newHub() | |||
| @@ -458,19 +446,7 @@ func main() { | |||
| var newDet *detector.Detector | |||
| var newWindow []float64 | |||
| if detChanged { | |||
| newDet = detector.New(next.Detector.ThresholdDb, next.SampleRate, next.FFTSize, | |||
| time.Duration(next.Detector.MinDurationMs)*time.Millisecond, | |||
| time.Duration(next.Detector.HoldMs)*time.Millisecond, | |||
| next.Detector.EmaAlpha, | |||
| next.Detector.HysteresisDb, | |||
| next.Detector.MinStableFrames, | |||
| time.Duration(next.Detector.GapToleranceMs)*time.Millisecond, | |||
| next.Detector.CFARMode, | |||
| next.Detector.CFARGuardCells, | |||
| next.Detector.CFARTrainCells, | |||
| next.Detector.CFARRank, | |||
| next.Detector.CFARScaleDb, | |||
| next.Detector.CFARWrapAround) | |||
| newDet = detector.New(next.Detector, next.SampleRate, next.FFTSize) | |||
| } | |||
| if windowChanged { | |||
| newWindow = fftutil.Hann(next.FFTSize) | |||
| @@ -114,15 +114,27 @@ func top2(scores map[SignalClass]float64) (SignalClass, float64, SignalClass, fl | |||
| var best, second SignalClass | |||
| bestScore := 0.0 | |||
| secondScore := 0.0 | |||
| better := func(k SignalClass, v float64, cur SignalClass, curV float64) bool { | |||
| if v > curV { | |||
| return true | |||
| } | |||
| if v < curV { | |||
| return false | |||
| } | |||
| if cur == "" { | |||
| return true | |||
| } | |||
| return string(k) < string(cur) | |||
| } | |||
| for k, v := range scores { | |||
| if v > bestScore { | |||
| if better(k, v, best, bestScore) { | |||
| second = best | |||
| secondScore = bestScore | |||
| best = k | |||
| bestScore = v | |||
| continue | |||
| } | |||
| if v > secondScore { | |||
| if k != best && better(k, v, second, secondScore) { | |||
| second = k | |||
| secondScore = v | |||
| } | |||
| @@ -7,6 +7,7 @@ import ( | |||
| "sdr-visual-suite/internal/cfar" | |||
| "sdr-visual-suite/internal/classifier" | |||
| "sdr-visual-suite/internal/config" | |||
| ) | |||
| type Event struct { | |||
| @@ -68,7 +69,21 @@ type Signal struct { | |||
| Class *classifier.Classification `json:"class,omitempty"` | |||
| } | |||
| func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64, minStable int, gapTolerance time.Duration, cfarMode string, cfarGuard, cfarTrain, cfarRank int, cfarScaleDb float64, cfarWrap bool) *Detector { | |||
| func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||
| minDur := time.Duration(detCfg.MinDurationMs) * time.Millisecond | |||
| hold := time.Duration(detCfg.HoldMs) * time.Millisecond | |||
| gapTolerance := time.Duration(detCfg.GapToleranceMs) * time.Millisecond | |||
| emaAlpha := detCfg.EmaAlpha | |||
| hysteresis := detCfg.HysteresisDb | |||
| minStable := detCfg.MinStableFrames | |||
| cfarMode := detCfg.CFARMode | |||
| cfarGuard := detCfg.CFARGuardCells | |||
| cfarTrain := detCfg.CFARTrainCells | |||
| cfarRank := detCfg.CFARRank | |||
| cfarScaleDb := detCfg.CFARScaleDb | |||
| cfarWrap := detCfg.CFARWrapAround | |||
| thresholdDb := detCfg.ThresholdDb | |||
| if minDur <= 0 { | |||
| minDur = 250 * time.Millisecond | |||
| } | |||
| @@ -3,10 +3,26 @@ package detector | |||
| import ( | |||
| "testing" | |||
| "time" | |||
| "sdr-visual-suite/internal/config" | |||
| ) | |||
| func TestDetectorCreatesEvent(t *testing.T) { | |||
| d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3, 1, 10*time.Millisecond, "OFF", 2, 16, 24, 6, true) | |||
| d := New(config.DetectorConfig{ | |||
| ThresholdDb: -10, | |||
| MinDurationMs: 1, | |||
| HoldMs: 10, | |||
| EmaAlpha: 0.2, | |||
| HysteresisDb: 3, | |||
| MinStableFrames: 1, | |||
| GapToleranceMs: 10, | |||
| CFARMode: "OFF", | |||
| CFARGuardCells: 2, | |||
| CFARTrainCells: 16, | |||
| CFARRank: 24, | |||
| CFARScaleDb: 6, | |||
| CFARWrapAround: true, | |||
| }, 1000, 10) | |||
| center := 0.0 | |||
| spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} | |||
| now := time.Now() | |||
| @@ -507,7 +507,10 @@ function updateHeroMetrics() { | |||
| metricSource.textContent = stats.last_sample_ago_ms >= 0 ? `${stats.last_sample_ago_ms} ms` : 'n/a'; | |||
| const gpuText = gpuInfo.active ? 'GPU active' : (gpuInfo.available ? 'GPU ready' : 'GPU n/a'); | |||
| metaLine.textContent = `${fmtMHz(latest.center_hz, 3)} · ${fmtHz(span)} span · ${gpuText}`; | |||
| const thresholdInfo = Array.isArray(latest.thresholds) && latest.thresholds.length | |||
| ? `CFAR on · noise ${(Number.isFinite(latest.noise_floor) ? latest.noise_floor.toFixed(1) : 'n/a')} dB` | |||
| : `CFAR off · noise ${(Number.isFinite(latest.noise_floor) ? latest.noise_floor.toFixed(1) : 'n/a')} dB`; | |||
| metaLine.textContent = `${fmtMHz(latest.center_hz, 3)} · ${fmtHz(span)} span · ${thresholdInfo} · ${gpuText}`; | |||
| heroSubtitle.textContent = `${latest.signals?.length || 0} live signals · ${events.length} recent events tracked`; | |||
| healthBuffer.textContent = String(stats.buffer_samples ?? '-'); | |||