| @@ -316,7 +316,9 @@ func main() { | |||||
| time.Duration(cfg.Detector.MinDurationMs)*time.Millisecond, | 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.EmaAlpha, | ||||
| cfg.Detector.HysteresisDb) | |||||
| cfg.Detector.HysteresisDb, | |||||
| cfg.Detector.MinStableFrames, | |||||
| time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond) | |||||
| window := fftutil.Hann(cfg.FFTSize) | window := fftutil.Hann(cfg.FFTSize) | ||||
| h := newHub() | h := newHub() | ||||
| @@ -429,6 +431,10 @@ func main() { | |||||
| detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || | detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || | ||||
| prev.Detector.MinDurationMs != next.Detector.MinDurationMs || | prev.Detector.MinDurationMs != next.Detector.MinDurationMs || | ||||
| prev.Detector.HoldMs != next.Detector.HoldMs || | 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.SampleRate != next.SampleRate || | ||||
| prev.FFTSize != next.FFTSize | prev.FFTSize != next.FFTSize | ||||
| windowChanged := 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.MinDurationMs)*time.Millisecond, | ||||
| time.Duration(next.Detector.HoldMs)*time.Millisecond, | time.Duration(next.Detector.HoldMs)*time.Millisecond, | ||||
| next.Detector.EmaAlpha, | next.Detector.EmaAlpha, | ||||
| next.Detector.HysteresisDb) | |||||
| next.Detector.HysteresisDb, | |||||
| next.Detector.MinStableFrames, | |||||
| time.Duration(next.Detector.GapToleranceMs)*time.Millisecond) | |||||
| } | } | ||||
| if windowChanged { | if windowChanged { | ||||
| newWindow = fftutil.Hann(next.FFTSize) | newWindow = fftutil.Hann(next.FFTSize) | ||||
| @@ -17,6 +17,8 @@ detector: | |||||
| hold_ms: 500 | hold_ms: 500 | ||||
| ema_alpha: 0.2 | ema_alpha: 0.2 | ||||
| hysteresis_db: 3 | hysteresis_db: 3 | ||||
| min_stable_frames: 3 | |||||
| gap_tolerance_ms: 500 | |||||
| recorder: | recorder: | ||||
| enabled: true | enabled: true | ||||
| min_snr_db: 10 | min_snr_db: 10 | ||||
| @@ -14,11 +14,13 @@ type Band struct { | |||||
| } | } | ||||
| type DetectorConfig 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 { | type RecorderConfig struct { | ||||
| @@ -81,7 +83,7 @@ func Default() Config { | |||||
| AGC: false, | AGC: false, | ||||
| DCBlock: false, | DCBlock: false, | ||||
| IQBalance: 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{ | Recorder: RecorderConfig{ | ||||
| Enabled: false, | Enabled: false, | ||||
| MinSNRDb: 10, | MinSNRDb: 10, | ||||
| @@ -120,6 +122,12 @@ func Load(path string) (Config, error) { | |||||
| if cfg.Detector.HoldMs <= 0 { | if cfg.Detector.HoldMs <= 0 { | ||||
| cfg.Detector.HoldMs = 500 | 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 { | if cfg.FrameRate <= 0 { | ||||
| cfg.FrameRate = 15 | cfg.FrameRate = 15 | ||||
| } | } | ||||
| @@ -22,11 +22,13 @@ type Event struct { | |||||
| } | } | ||||
| type Detector 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 | binWidth float64 | ||||
| nbins int | nbins int | ||||
| @@ -38,16 +40,17 @@ type Detector struct { | |||||
| } | } | ||||
| type activeEvent 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 { | type Signal struct { | ||||
| @@ -60,7 +63,7 @@ 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) *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 { | if minDur <= 0 { | ||||
| minDur = 250 * time.Millisecond | minDur = 250 * time.Millisecond | ||||
| } | } | ||||
| @@ -73,18 +76,26 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||||
| if hysteresis <= 0 { | if hysteresis <= 0 { | ||||
| hysteresis = 3 | hysteresis = 3 | ||||
| } | } | ||||
| if minStable <= 0 { | |||||
| minStable = 3 | |||||
| } | |||||
| if gapTolerance <= 0 { | |||||
| gapTolerance = hold | |||||
| } | |||||
| return &Detector{ | 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 | id := d.nextID | ||||
| d.nextID++ | d.nextID++ | ||||
| d.active[id] = &activeEvent{ | 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 | continue | ||||
| } | } | ||||
| used[best.id] = true | used[best.id] = true | ||||
| best.lastSeen = now | best.lastSeen = now | ||||
| best.stableHits++ | |||||
| best.centerHz = (best.centerHz + s.CenterHz) / 2.0 | best.centerHz = (best.centerHz + s.CenterHz) / 2.0 | ||||
| if s.BWHz > best.bwHz { | if s.BWHz > best.bwHz { | ||||
| best.bwHz = s.BWHz | best.bwHz = s.BWHz | ||||
| @@ -242,11 +255,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||||
| if used[id] { | if used[id] { | ||||
| continue | continue | ||||
| } | } | ||||
| if now.Sub(ev.lastSeen) < d.Hold { | |||||
| if now.Sub(ev.lastSeen) < d.GapTolerance { | |||||
| continue | continue | ||||
| } | } | ||||
| duration := ev.lastSeen.Sub(ev.start) | duration := ev.lastSeen.Sub(ev.start) | ||||
| if duration < d.MinDuration { | |||||
| if duration < d.MinDuration || ev.stableHits < d.MinStableFrames { | |||||
| delete(d.active, id) | delete(d.active, id) | ||||
| continue | continue | ||||
| } | } | ||||
| @@ -6,7 +6,7 @@ import ( | |||||
| ) | ) | ||||
| func TestDetectorCreatesEvent(t *testing.T) { | 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 | 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() | ||||
| @@ -19,11 +19,13 @@ type ConfigUpdate struct { | |||||
| } | } | ||||
| type DetectorUpdate 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 { | type SettingsUpdate struct { | ||||
| @@ -129,6 +131,12 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.Detector.HysteresisDb != nil { | if update.Detector.HysteresisDb != nil { | ||||
| next.Detector.HysteresisDb = *update.Detector.HysteresisDb | 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 != nil { | ||||
| if update.Recorder.Enabled != nil { | if update.Recorder.Enabled != nil { | ||||
| @@ -29,6 +29,10 @@ const gainRange = qs('gainRange'); | |||||
| const gainInput = qs('gainInput'); | const gainInput = qs('gainInput'); | ||||
| const thresholdRange = qs('thresholdRange'); | const thresholdRange = qs('thresholdRange'); | ||||
| const thresholdInput = qs('thresholdInput'); | 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 agcToggle = qs('agcToggle'); | ||||
| const dcToggle = qs('dcToggle'); | const dcToggle = qs('dcToggle'); | ||||
| const iqToggle = qs('iqToggle'); | const iqToggle = qs('iqToggle'); | ||||
| @@ -278,6 +282,12 @@ function applyConfigToUI(cfg) { | |||||
| gainInput.value = uiGain; | gainInput.value = uiGain; | ||||
| thresholdRange.value = cfg.detector.threshold_db; | thresholdRange.value = cfg.detector.threshold_db; | ||||
| thresholdInput.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; | agcToggle.checked = !!cfg.agc; | ||||
| dcToggle.checked = !!cfg.dc_block; | dcToggle.checked = !!cfg.dc_block; | ||||
| iqToggle.checked = !!cfg.iq_balance; | iqToggle.checked = !!cfg.iq_balance; | ||||
| @@ -173,12 +173,18 @@ | |||||
| <span>Threshold</span> | <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 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> | </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> | <label class="field"><span>Averaging</span> | ||||
| <select id="avgSelect"> | <select id="avgSelect"> | ||||
| <option value="0">Off</option><option value="0.4">Fast</option> | <option value="0">Off</option><option value="0.4">Fast</option> | ||||
| <option value="0.2">Medium</option><option value="0.1">Slow</option> | <option value="0.2">Medium</option><option value="0.1">Slow</option> | ||||
| </select> | </select> | ||||
| </label> | </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"> | <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="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> | <label class="pill-toggle"><input id="dcToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">DC Block</span></label> | ||||