From 81c481bb64163d495ee344baaea964442b3c7ccb Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Wed, 18 Mar 2026 14:16:46 +0100 Subject: [PATCH] Add EMA smoothing and hysteresis to detector --- cmd/sdrd/main.go | 8 +++- config.yaml | 2 + internal/config/config.go | 4 +- internal/detector/detector.go | 66 +++++++++++++++++++++--------- internal/detector/detector_test.go | 2 +- internal/runtime/runtime.go | 14 +++++-- 6 files changed, 70 insertions(+), 26 deletions(-) diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index b9ff711..0290924 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -314,7 +314,9 @@ func main() { det := detector.New(cfg.Detector.ThresholdDb, cfg.SampleRate, cfg.FFTSize, time.Duration(cfg.Detector.MinDurationMs)*time.Millisecond, - time.Duration(cfg.Detector.HoldMs)*time.Millisecond) + time.Duration(cfg.Detector.HoldMs)*time.Millisecond, + cfg.Detector.EmaAlpha, + cfg.Detector.HysteresisDb) window := fftutil.Hann(cfg.FFTSize) h := newHub() @@ -435,7 +437,9 @@ func main() { 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) + time.Duration(next.Detector.HoldMs)*time.Millisecond, + next.Detector.EmaAlpha, + next.Detector.HysteresisDb) } if windowChanged { newWindow = fftutil.Hann(next.FFTSize) diff --git a/config.yaml b/config.yaml index 1662bd8..76ebef3 100644 --- a/config.yaml +++ b/config.yaml @@ -15,6 +15,8 @@ detector: threshold_db: -20 min_duration_ms: 250 hold_ms: 500 + ema_alpha: 0.2 + hysteresis_db: 3 recorder: enabled: true min_snr_db: 10 diff --git a/internal/config/config.go b/internal/config/config.go index 6581cfb..d51ad23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,8 @@ type DetectorConfig struct { ThresholdDb float64 `yaml:"threshold_db" json:"threshold_db"` MinDurationMs int `yaml:"min_duration_ms" json:"min_duration_ms"` HoldMs int `yaml:"hold_ms" json:"hold_ms"` + EmaAlpha float64 `yaml:"ema_alpha" json:"ema_alpha"` + HysteresisDb float64 `yaml:"hysteresis_db" json:"hysteresis_db"` } type RecorderConfig struct { @@ -79,7 +81,7 @@ func Default() Config { AGC: false, DCBlock: false, IQBalance: false, - Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500}, + Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3}, Recorder: RecorderConfig{ Enabled: false, MinSNRDb: 10, diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 5dd99e3..cbca7b5 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -22,14 +22,17 @@ type Event struct { } type Detector struct { - ThresholdDb float64 - MinDuration time.Duration - Hold time.Duration + ThresholdDb float64 + MinDuration time.Duration + Hold time.Duration + EmaAlpha float64 + HysteresisDb float64 binWidth float64 nbins int sampleRate int + ema []float64 active map[int64]*activeEvent nextID int64 } @@ -57,22 +60,31 @@ type Signal struct { Class *classifier.Classification `json:"class,omitempty"` } -func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration) *Detector { +func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64) *Detector { if minDur <= 0 { minDur = 250 * time.Millisecond } if hold <= 0 { hold = 500 * time.Millisecond } + if emaAlpha <= 0 || emaAlpha > 1 { + emaAlpha = 0.2 + } + if hysteresis <= 0 { + hysteresis = 3 + } return &Detector{ - ThresholdDb: thresholdDb, - MinDuration: minDur, - Hold: hold, - binWidth: float64(sampleRate) / float64(fftSize), - nbins: fftSize, - sampleRate: sampleRate, - active: map[int64]*activeEvent{}, - nextID: 1, + ThresholdDb: thresholdDb, + MinDuration: minDur, + Hold: hold, + EmaAlpha: emaAlpha, + HysteresisDb: hysteresis, + binWidth: float64(sampleRate) / float64(fftSize), + nbins: fftSize, + sampleRate: sampleRate, + ema: make([]float64, fftSize), + active: map[int64]*activeEvent{}, + nextID: 1, } } @@ -102,16 +114,18 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal if n == 0 { return nil } - threshold := d.ThresholdDb - noise := median(spectrum) + smooth := d.smoothSpectrum(spectrum) + thresholdOn := d.ThresholdDb + thresholdOff := d.ThresholdDb - d.HysteresisDb + noise := median(smooth) var signals []Signal in := false start := 0 peak := -1e9 peakBin := 0 for i := 0; i < n; i++ { - v := spectrum[i] - if v >= threshold { + v := smooth[i] + if v >= thresholdOn { if !in { in = true start = i @@ -121,13 +135,13 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal peak = v peakBin = i } - } else if in { - signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, spectrum)) + } else if in && v < thresholdOff { + signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, smooth)) in = false } } if in { - signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, spectrum)) + signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, smooth)) } return signals } @@ -147,6 +161,20 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise } } +func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { + if d.ema == nil || len(d.ema) != len(spectrum) { + d.ema = make([]float64, len(spectrum)) + copy(d.ema, spectrum) + return d.ema + } + alpha := d.EmaAlpha + for i := range spectrum { + v := spectrum[i] + d.ema[i] = alpha*v + (1-alpha)*d.ema[i] + } + return d.ema +} + func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { used := make(map[int64]bool, len(d.active)) for _, s := range signals { diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 3e137d1..563d2da 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -6,7 +6,7 @@ import ( ) func TestDetectorCreatesEvent(t *testing.T) { - d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond) + d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3) center := 0.0 spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} now := time.Now() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 0b72d77..9d3fa84 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -19,9 +19,11 @@ type ConfigUpdate struct { } type DetectorUpdate struct { - ThresholdDb *float64 `json:"threshold_db"` - MinDuration *int `json:"min_duration_ms"` - HoldMs *int `json:"hold_ms"` + ThresholdDb *float64 `json:"threshold_db"` + MinDuration *int `json:"min_duration_ms"` + HoldMs *int `json:"hold_ms"` + EmaAlpha *float64 `json:"ema_alpha"` + HysteresisDb *float64 `json:"hysteresis_db"` } type SettingsUpdate struct { @@ -121,6 +123,12 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { } next.Detector.HoldMs = *update.Detector.HoldMs } + if update.Detector.EmaAlpha != nil { + next.Detector.EmaAlpha = *update.Detector.EmaAlpha + } + if update.Detector.HysteresisDb != nil { + next.Detector.HysteresisDb = *update.Detector.HysteresisDb + } } if update.Recorder != nil { if update.Recorder.Enabled != nil {