diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index 1fb3c98..7a3df1e 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -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) diff --git a/internal/classifier/rules.go b/internal/classifier/rules.go index 1381d7d..6af06e2 100644 --- a/internal/classifier/rules.go +++ b/internal/classifier/rules.go @@ -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 } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 56be4aa..d5b3a37 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -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 } diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 3f2e8ed..d202d6f 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -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() diff --git a/web/app.js b/web/app.js index e279081..be74ab1 100644 --- a/web/app.js +++ b/web/app.js @@ -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 ?? '-');