| @@ -314,19 +314,7 @@ func main() { | |||||
| defer eventFile.Close() | defer eventFile.Close() | ||||
| eventMu := &sync.RWMutex{} | 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) | window := fftutil.Hann(cfg.FFTSize) | ||||
| h := newHub() | h := newHub() | ||||
| @@ -458,19 +446,7 @@ func main() { | |||||
| var newDet *detector.Detector | var newDet *detector.Detector | ||||
| var newWindow []float64 | var newWindow []float64 | ||||
| if detChanged { | 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 { | if windowChanged { | ||||
| newWindow = fftutil.Hann(next.FFTSize) | newWindow = fftutil.Hann(next.FFTSize) | ||||
| @@ -114,15 +114,27 @@ func top2(scores map[SignalClass]float64) (SignalClass, float64, SignalClass, fl | |||||
| var best, second SignalClass | var best, second SignalClass | ||||
| bestScore := 0.0 | bestScore := 0.0 | ||||
| secondScore := 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 { | for k, v := range scores { | ||||
| if v > bestScore { | |||||
| if better(k, v, best, bestScore) { | |||||
| second = best | second = best | ||||
| secondScore = bestScore | secondScore = bestScore | ||||
| best = k | best = k | ||||
| bestScore = v | bestScore = v | ||||
| continue | continue | ||||
| } | } | ||||
| if v > secondScore { | |||||
| if k != best && better(k, v, second, secondScore) { | |||||
| second = k | second = k | ||||
| secondScore = v | secondScore = v | ||||
| } | } | ||||
| @@ -7,6 +7,7 @@ import ( | |||||
| "sdr-visual-suite/internal/cfar" | "sdr-visual-suite/internal/cfar" | ||||
| "sdr-visual-suite/internal/classifier" | "sdr-visual-suite/internal/classifier" | ||||
| "sdr-visual-suite/internal/config" | |||||
| ) | ) | ||||
| type Event struct { | type Event struct { | ||||
| @@ -68,7 +69,21 @@ type Signal struct { | |||||
| Class *classifier.Classification `json:"class,omitempty"` | 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 { | if minDur <= 0 { | ||||
| minDur = 250 * time.Millisecond | minDur = 250 * time.Millisecond | ||||
| } | } | ||||
| @@ -3,10 +3,26 @@ package detector | |||||
| import ( | import ( | ||||
| "testing" | "testing" | ||||
| "time" | "time" | ||||
| "sdr-visual-suite/internal/config" | |||||
| ) | ) | ||||
| func TestDetectorCreatesEvent(t *testing.T) { | 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 | center := 0.0 | ||||
| spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} | spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} | ||||
| now := time.Now() | 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'; | 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'); | 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`; | heroSubtitle.textContent = `${latest.signals?.length || 0} live signals · ${events.length} recent events tracked`; | ||||
| healthBuffer.textContent = String(stats.buffer_samples ?? '-'); | healthBuffer.textContent = String(stats.buffer_samples ?? '-'); | ||||