| @@ -316,7 +316,9 @@ func main() { | |||
| time.Duration(cfg.Detector.MinDurationMs)*time.Millisecond, | |||
| time.Duration(cfg.Detector.HoldMs)*time.Millisecond, | |||
| cfg.Detector.EmaAlpha, | |||
| cfg.Detector.HysteresisDb) | |||
| cfg.Detector.HysteresisDb, | |||
| cfg.Detector.MinStableFrames, | |||
| time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond) | |||
| window := fftutil.Hann(cfg.FFTSize) | |||
| h := newHub() | |||
| @@ -429,6 +431,10 @@ func main() { | |||
| detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || | |||
| prev.Detector.MinDurationMs != next.Detector.MinDurationMs || | |||
| prev.Detector.HoldMs != next.Detector.HoldMs || | |||
| prev.Detector.EmaAlpha != next.Detector.EmaAlpha || | |||
| prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | |||
| prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | |||
| prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | |||
| prev.SampleRate != next.SampleRate || | |||
| prev.FFTSize != next.FFTSize | |||
| windowChanged := prev.FFTSize != next.FFTSize | |||
| @@ -439,7 +445,9 @@ func main() { | |||
| time.Duration(next.Detector.MinDurationMs)*time.Millisecond, | |||
| time.Duration(next.Detector.HoldMs)*time.Millisecond, | |||
| next.Detector.EmaAlpha, | |||
| next.Detector.HysteresisDb) | |||
| next.Detector.HysteresisDb, | |||
| next.Detector.MinStableFrames, | |||
| time.Duration(next.Detector.GapToleranceMs)*time.Millisecond) | |||
| } | |||
| if windowChanged { | |||
| newWindow = fftutil.Hann(next.FFTSize) | |||
| @@ -17,6 +17,8 @@ detector: | |||
| hold_ms: 500 | |||
| ema_alpha: 0.2 | |||
| hysteresis_db: 3 | |||
| min_stable_frames: 3 | |||
| gap_tolerance_ms: 500 | |||
| recorder: | |||
| enabled: true | |||
| min_snr_db: 10 | |||
| @@ -14,11 +14,13 @@ type Band struct { | |||
| } | |||
| 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"` | |||
| 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"` | |||
| MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` | |||
| GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"` | |||
| } | |||
| type RecorderConfig struct { | |||
| @@ -81,7 +83,7 @@ func Default() Config { | |||
| AGC: false, | |||
| DCBlock: false, | |||
| IQBalance: false, | |||
| Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3}, | |||
| Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3, MinStableFrames: 3, GapToleranceMs: 500}, | |||
| Recorder: RecorderConfig{ | |||
| Enabled: false, | |||
| MinSNRDb: 10, | |||
| @@ -120,6 +122,12 @@ func Load(path string) (Config, error) { | |||
| if cfg.Detector.HoldMs <= 0 { | |||
| cfg.Detector.HoldMs = 500 | |||
| } | |||
| if cfg.Detector.MinStableFrames <= 0 { | |||
| cfg.Detector.MinStableFrames = 3 | |||
| } | |||
| if cfg.Detector.GapToleranceMs <= 0 { | |||
| cfg.Detector.GapToleranceMs = cfg.Detector.HoldMs | |||
| } | |||
| if cfg.FrameRate <= 0 { | |||
| cfg.FrameRate = 15 | |||
| } | |||
| @@ -22,11 +22,13 @@ type Event struct { | |||
| } | |||
| type Detector struct { | |||
| ThresholdDb float64 | |||
| MinDuration time.Duration | |||
| Hold time.Duration | |||
| EmaAlpha float64 | |||
| HysteresisDb float64 | |||
| ThresholdDb float64 | |||
| MinDuration time.Duration | |||
| Hold time.Duration | |||
| EmaAlpha float64 | |||
| HysteresisDb float64 | |||
| MinStableFrames int | |||
| GapTolerance time.Duration | |||
| binWidth float64 | |||
| nbins int | |||
| @@ -38,16 +40,17 @@ type Detector struct { | |||
| } | |||
| type activeEvent struct { | |||
| id int64 | |||
| start time.Time | |||
| lastSeen time.Time | |||
| centerHz float64 | |||
| bwHz float64 | |||
| peakDb float64 | |||
| snrDb float64 | |||
| firstBin int | |||
| lastBin int | |||
| class *classifier.Classification | |||
| id int64 | |||
| start time.Time | |||
| lastSeen time.Time | |||
| centerHz float64 | |||
| bwHz float64 | |||
| peakDb float64 | |||
| snrDb float64 | |||
| firstBin int | |||
| lastBin int | |||
| class *classifier.Classification | |||
| stableHits int | |||
| } | |||
| type Signal struct { | |||
| @@ -60,7 +63,7 @@ type Signal struct { | |||
| Class *classifier.Classification `json:"class,omitempty"` | |||
| } | |||
| func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64) *Detector { | |||
| func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64, minStable int, gapTolerance time.Duration) *Detector { | |||
| if minDur <= 0 { | |||
| minDur = 250 * time.Millisecond | |||
| } | |||
| @@ -73,18 +76,26 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||
| if hysteresis <= 0 { | |||
| hysteresis = 3 | |||
| } | |||
| if minStable <= 0 { | |||
| minStable = 3 | |||
| } | |||
| if gapTolerance <= 0 { | |||
| gapTolerance = hold | |||
| } | |||
| return &Detector{ | |||
| 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, | |||
| ThresholdDb: thresholdDb, | |||
| MinDuration: minDur, | |||
| Hold: hold, | |||
| EmaAlpha: emaAlpha, | |||
| HysteresisDb: hysteresis, | |||
| MinStableFrames: minStable, | |||
| GapTolerance: gapTolerance, | |||
| binWidth: float64(sampleRate) / float64(fftSize), | |||
| nbins: fftSize, | |||
| sampleRate: sampleRate, | |||
| ema: make([]float64, fftSize), | |||
| active: map[int64]*activeEvent{}, | |||
| nextID: 1, | |||
| } | |||
| } | |||
| @@ -199,21 +210,23 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||
| id := d.nextID | |||
| d.nextID++ | |||
| d.active[id] = &activeEvent{ | |||
| id: id, | |||
| start: now, | |||
| lastSeen: now, | |||
| centerHz: s.CenterHz, | |||
| bwHz: s.BWHz, | |||
| peakDb: s.PeakDb, | |||
| snrDb: s.SNRDb, | |||
| firstBin: s.FirstBin, | |||
| lastBin: s.LastBin, | |||
| class: s.Class, | |||
| id: id, | |||
| start: now, | |||
| lastSeen: now, | |||
| centerHz: s.CenterHz, | |||
| bwHz: s.BWHz, | |||
| peakDb: s.PeakDb, | |||
| snrDb: s.SNRDb, | |||
| firstBin: s.FirstBin, | |||
| lastBin: s.LastBin, | |||
| class: s.Class, | |||
| stableHits: 1, | |||
| } | |||
| continue | |||
| } | |||
| used[best.id] = true | |||
| best.lastSeen = now | |||
| best.stableHits++ | |||
| best.centerHz = (best.centerHz + s.CenterHz) / 2.0 | |||
| if s.BWHz > best.bwHz { | |||
| best.bwHz = s.BWHz | |||
| @@ -242,11 +255,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||
| if used[id] { | |||
| continue | |||
| } | |||
| if now.Sub(ev.lastSeen) < d.Hold { | |||
| if now.Sub(ev.lastSeen) < d.GapTolerance { | |||
| continue | |||
| } | |||
| duration := ev.lastSeen.Sub(ev.start) | |||
| if duration < d.MinDuration { | |||
| if duration < d.MinDuration || ev.stableHits < d.MinStableFrames { | |||
| delete(d.active, id) | |||
| continue | |||
| } | |||
| @@ -6,7 +6,7 @@ import ( | |||
| ) | |||
| func TestDetectorCreatesEvent(t *testing.T) { | |||
| d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3) | |||
| d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3, 1, 10*time.Millisecond) | |||
| center := 0.0 | |||
| spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} | |||
| now := time.Now() | |||
| @@ -19,11 +19,13 @@ type ConfigUpdate struct { | |||
| } | |||
| type DetectorUpdate struct { | |||
| 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"` | |||
| 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"` | |||
| MinStableFrames *int `json:"min_stable_frames"` | |||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | |||
| } | |||
| type SettingsUpdate struct { | |||
| @@ -129,6 +131,12 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| if update.Detector.HysteresisDb != nil { | |||
| next.Detector.HysteresisDb = *update.Detector.HysteresisDb | |||
| } | |||
| if update.Detector.MinStableFrames != nil { | |||
| next.Detector.MinStableFrames = *update.Detector.MinStableFrames | |||
| } | |||
| if update.Detector.GapToleranceMs != nil { | |||
| next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs | |||
| } | |||
| } | |||
| if update.Recorder != nil { | |||
| if update.Recorder.Enabled != nil { | |||
| @@ -29,6 +29,10 @@ const gainRange = qs('gainRange'); | |||
| const gainInput = qs('gainInput'); | |||
| const thresholdRange = qs('thresholdRange'); | |||
| const thresholdInput = qs('thresholdInput'); | |||
| const emaAlphaInput = qs('emaAlphaInput'); | |||
| const hysteresisInput = qs('hysteresisInput'); | |||
| const stableFramesInput = qs('stableFramesInput'); | |||
| const gapToleranceInput = qs('gapToleranceInput'); | |||
| const agcToggle = qs('agcToggle'); | |||
| const dcToggle = qs('dcToggle'); | |||
| const iqToggle = qs('iqToggle'); | |||
| @@ -278,6 +282,12 @@ function applyConfigToUI(cfg) { | |||
| gainInput.value = uiGain; | |||
| thresholdRange.value = cfg.detector.threshold_db; | |||
| thresholdInput.value = cfg.detector.threshold_db; | |||
| if (minDurationInput) minDurationInput.value = cfg.detector.min_duration_ms; | |||
| if (holdInput) holdInput.value = cfg.detector.hold_ms; | |||
| if (emaAlphaInput) emaAlphaInput.value = cfg.detector.ema_alpha ?? 0.2; | |||
| if (hysteresisInput) hysteresisInput.value = cfg.detector.hysteresis_db ?? 3; | |||
| if (stableFramesInput) stableFramesInput.value = cfg.detector.min_stable_frames ?? 3; | |||
| if (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; | |||
| agcToggle.checked = !!cfg.agc; | |||
| dcToggle.checked = !!cfg.dc_block; | |||
| iqToggle.checked = !!cfg.iq_balance; | |||
| @@ -173,12 +173,18 @@ | |||
| <span>Threshold</span> | |||
| <div class="slider-row"><input id="thresholdRange" type="range" min="-120" max="0" step="1" class="range--warn" /><input id="thresholdInput" type="number" min="-120" max="0" step="1" class="slider-num" /><em>dB</em></div> | |||
| </div> | |||
| <label class="field"><span>Min Duration (ms)</span><input id="minDurationInput" type="number" step="50" min="50" /></label> | |||
| <label class="field"><span>Hold (ms)</span><input id="holdInput" type="number" step="50" min="50" /></label> | |||
| <label class="field"><span>Averaging</span> | |||
| <select id="avgSelect"> | |||
| <option value="0">Off</option><option value="0.4">Fast</option> | |||
| <option value="0.2">Medium</option><option value="0.1">Slow</option> | |||
| </select> | |||
| </label> | |||
| <label class="field"><span>EMA Alpha</span><input id="emaAlphaInput" type="number" step="0.05" min="0" max="1" /></label> | |||
| <label class="field"><span>Hysteresis (dB)</span><input id="hysteresisInput" type="number" step="1" min="0" /></label> | |||
| <label class="field"><span>Min Stable Frames</span><input id="stableFramesInput" type="number" step="1" min="1" /></label> | |||
| <label class="field"><span>Gap Tolerance (ms)</span><input id="gapToleranceInput" type="number" step="50" min="0" /></label> | |||
| <div class="toggle-grid"> | |||
| <label class="pill-toggle"><input id="agcToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">AGC</span></label> | |||
| <label class="pill-toggle"><input id="dcToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">DC Block</span></label> | |||