| @@ -64,7 +64,9 @@ Edit `config.yaml`: | |||||
| - `agc`: enable automatic gain control | - `agc`: enable automatic gain control | ||||
| - `dc_block`: enable DC blocking filter | - `dc_block`: enable DC blocking filter | ||||
| - `iq_balance`: enable basic IQ imbalance correction | - `iq_balance`: enable basic IQ imbalance correction | ||||
| - `detector.threshold_db`: power threshold in dB | |||||
| - `detector.threshold_db`: power threshold in dB (fallback if CFAR disabled) | |||||
| - `detector.cfar_enabled`: enable OS-CFAR detection | |||||
| - `detector.cfar_guard_cells`, `detector.cfar_train_cells`, `detector.cfar_rank`, `detector.cfar_scale_db`: OS-CFAR window + ordered-statistic parameters | |||||
| - `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge | - `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge | ||||
| - `recorder.*`: enable IQ/audio recording, preroll, output_dir, max_disk_mb | - `recorder.*`: enable IQ/audio recording, preroll, output_dir, max_disk_mb | ||||
| - `decoder.*`: external decode commands (use `{iq}`, `{audio}`, `{sr}` placeholders) | - `decoder.*`: external decode commands (use `{iq}`, `{audio}`, `{sr}` placeholders) | ||||
| @@ -319,7 +319,12 @@ func main() { | |||||
| cfg.Detector.EmaAlpha, | cfg.Detector.EmaAlpha, | ||||
| cfg.Detector.HysteresisDb, | cfg.Detector.HysteresisDb, | ||||
| cfg.Detector.MinStableFrames, | cfg.Detector.MinStableFrames, | ||||
| time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond) | |||||
| time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond, | |||||
| cfg.Detector.CFAREnabled, | |||||
| cfg.Detector.CFARGuardCells, | |||||
| cfg.Detector.CFARTrainCells, | |||||
| cfg.Detector.CFARRank, | |||||
| cfg.Detector.CFARScaleDb) | |||||
| window := fftutil.Hann(cfg.FFTSize) | window := fftutil.Hann(cfg.FFTSize) | ||||
| h := newHub() | h := newHub() | ||||
| @@ -439,6 +444,11 @@ func main() { | |||||
| prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | ||||
| prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | ||||
| prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | ||||
| prev.Detector.CFAREnabled != next.Detector.CFAREnabled || | |||||
| prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || | |||||
| prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || | |||||
| prev.Detector.CFARRank != next.Detector.CFARRank || | |||||
| prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || | |||||
| 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 | ||||
| @@ -451,7 +461,12 @@ func main() { | |||||
| next.Detector.EmaAlpha, | next.Detector.EmaAlpha, | ||||
| next.Detector.HysteresisDb, | next.Detector.HysteresisDb, | ||||
| next.Detector.MinStableFrames, | next.Detector.MinStableFrames, | ||||
| time.Duration(next.Detector.GapToleranceMs)*time.Millisecond) | |||||
| time.Duration(next.Detector.GapToleranceMs)*time.Millisecond, | |||||
| next.Detector.CFAREnabled, | |||||
| next.Detector.CFARGuardCells, | |||||
| next.Detector.CFARTrainCells, | |||||
| next.Detector.CFARRank, | |||||
| next.Detector.CFARScaleDb) | |||||
| } | } | ||||
| if windowChanged { | if windowChanged { | ||||
| newWindow = fftutil.Hann(next.FFTSize) | newWindow = fftutil.Hann(next.FFTSize) | ||||
| @@ -19,6 +19,11 @@ detector: | |||||
| hysteresis_db: 3 | hysteresis_db: 3 | ||||
| min_stable_frames: 3 | min_stable_frames: 3 | ||||
| gap_tolerance_ms: 500 | gap_tolerance_ms: 500 | ||||
| cfar_enabled: true | |||||
| cfar_guard_cells: 2 | |||||
| cfar_train_cells: 16 | |||||
| cfar_rank: 24 | |||||
| cfar_scale_db: 6 | |||||
| recorder: | recorder: | ||||
| enabled: true | enabled: true | ||||
| min_snr_db: 10 | min_snr_db: 10 | ||||
| @@ -1,6 +1,7 @@ | |||||
| package config | package config | ||||
| import ( | import ( | ||||
| "math" | |||||
| "os" | "os" | ||||
| "time" | "time" | ||||
| @@ -21,6 +22,11 @@ type DetectorConfig struct { | |||||
| HysteresisDb float64 `yaml:"hysteresis_db" json:"hysteresis_db"` | HysteresisDb float64 `yaml:"hysteresis_db" json:"hysteresis_db"` | ||||
| MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` | MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` | ||||
| GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"` | GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"` | ||||
| CFAREnabled bool `yaml:"cfar_enabled" json:"cfar_enabled"` | |||||
| CFARGuardCells int `yaml:"cfar_guard_cells" json:"cfar_guard_cells"` | |||||
| CFARTrainCells int `yaml:"cfar_train_cells" json:"cfar_train_cells"` | |||||
| CFARRank int `yaml:"cfar_rank" json:"cfar_rank"` | |||||
| CFARScaleDb float64 `yaml:"cfar_scale_db" json:"cfar_scale_db"` | |||||
| } | } | ||||
| type RecorderConfig struct { | type RecorderConfig struct { | ||||
| @@ -83,7 +89,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, MinStableFrames: 3, GapToleranceMs: 500}, | |||||
| Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3, MinStableFrames: 3, GapToleranceMs: 500, CFAREnabled: true, CFARGuardCells: 2, CFARTrainCells: 16, CFARRank: 24, CFARScaleDb: 6}, | |||||
| Recorder: RecorderConfig{ | Recorder: RecorderConfig{ | ||||
| Enabled: false, | Enabled: false, | ||||
| MinSNRDb: 10, | MinSNRDb: 10, | ||||
| @@ -128,6 +134,21 @@ func Load(path string) (Config, error) { | |||||
| if cfg.Detector.GapToleranceMs <= 0 { | if cfg.Detector.GapToleranceMs <= 0 { | ||||
| cfg.Detector.GapToleranceMs = cfg.Detector.HoldMs | cfg.Detector.GapToleranceMs = cfg.Detector.HoldMs | ||||
| } | } | ||||
| if cfg.Detector.CFARGuardCells <= 0 { | |||||
| cfg.Detector.CFARGuardCells = 2 | |||||
| } | |||||
| if cfg.Detector.CFARTrainCells <= 0 { | |||||
| cfg.Detector.CFARTrainCells = 16 | |||||
| } | |||||
| if cfg.Detector.CFARRank <= 0 || cfg.Detector.CFARRank > 2*cfg.Detector.CFARTrainCells { | |||||
| cfg.Detector.CFARRank = int(math.Round(0.75 * float64(2*cfg.Detector.CFARTrainCells))) | |||||
| if cfg.Detector.CFARRank <= 0 { | |||||
| cfg.Detector.CFARRank = 1 | |||||
| } | |||||
| } | |||||
| if cfg.Detector.CFARScaleDb <= 0 { | |||||
| cfg.Detector.CFARScaleDb = 6 | |||||
| } | |||||
| if cfg.FrameRate <= 0 { | if cfg.FrameRate <= 0 { | ||||
| cfg.FrameRate = 15 | cfg.FrameRate = 15 | ||||
| } | } | ||||
| @@ -29,6 +29,11 @@ type Detector struct { | |||||
| HysteresisDb float64 | HysteresisDb float64 | ||||
| MinStableFrames int | MinStableFrames int | ||||
| GapTolerance time.Duration | GapTolerance time.Duration | ||||
| CFAREnabled bool | |||||
| CFARGuardCells int | |||||
| CFARTrainCells int | |||||
| CFARRank int | |||||
| CFARScaleDb float64 | |||||
| binWidth float64 | binWidth float64 | ||||
| nbins int | nbins int | ||||
| @@ -63,7 +68,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, minStable int, gapTolerance time.Duration) *Detector { | |||||
| func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64, minStable int, gapTolerance time.Duration, cfarEnabled bool, cfarGuard, cfarTrain, cfarRank int, cfarScaleDb float64) *Detector { | |||||
| if minDur <= 0 { | if minDur <= 0 { | ||||
| minDur = 250 * time.Millisecond | minDur = 250 * time.Millisecond | ||||
| } | } | ||||
| @@ -82,6 +87,21 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||||
| if gapTolerance <= 0 { | if gapTolerance <= 0 { | ||||
| gapTolerance = hold | gapTolerance = hold | ||||
| } | } | ||||
| if cfarGuard < 0 { | |||||
| cfarGuard = 2 | |||||
| } | |||||
| if cfarTrain <= 0 { | |||||
| cfarTrain = 16 | |||||
| } | |||||
| if cfarScaleDb <= 0 { | |||||
| cfarScaleDb = 6 | |||||
| } | |||||
| if cfarRank <= 0 || cfarRank > 2*cfarTrain { | |||||
| cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) | |||||
| if cfarRank <= 0 { | |||||
| cfarRank = 1 | |||||
| } | |||||
| } | |||||
| return &Detector{ | return &Detector{ | ||||
| ThresholdDb: thresholdDb, | ThresholdDb: thresholdDb, | ||||
| MinDuration: minDur, | MinDuration: minDur, | ||||
| @@ -90,6 +110,11 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||||
| HysteresisDb: hysteresis, | HysteresisDb: hysteresis, | ||||
| MinStableFrames: minStable, | MinStableFrames: minStable, | ||||
| GapTolerance: gapTolerance, | GapTolerance: gapTolerance, | ||||
| CFAREnabled: cfarEnabled, | |||||
| CFARGuardCells: cfarGuard, | |||||
| CFARTrainCells: cfarTrain, | |||||
| CFARRank: cfarRank, | |||||
| CFARScaleDb: cfarScaleDb, | |||||
| binWidth: float64(sampleRate) / float64(fftSize), | binWidth: float64(sampleRate) / float64(fftSize), | ||||
| nbins: fftSize, | nbins: fftSize, | ||||
| sampleRate: sampleRate, | sampleRate: sampleRate, | ||||
| @@ -126,9 +151,8 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||||
| return nil | return nil | ||||
| } | } | ||||
| smooth := d.smoothSpectrum(spectrum) | smooth := d.smoothSpectrum(spectrum) | ||||
| thresholdOn := d.ThresholdDb | |||||
| thresholdOff := d.ThresholdDb - d.HysteresisDb | |||||
| noise := median(smooth) | |||||
| thresholds := d.cfarThresholds(smooth) | |||||
| noiseGlobal := median(smooth) | |||||
| var signals []Signal | var signals []Signal | ||||
| in := false | in := false | ||||
| start := 0 | start := 0 | ||||
| @@ -136,6 +160,11 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||||
| peakBin := 0 | peakBin := 0 | ||||
| for i := 0; i < n; i++ { | for i := 0; i < n; i++ { | ||||
| v := smooth[i] | v := smooth[i] | ||||
| thresholdOn := d.ThresholdDb | |||||
| if thresholds != nil && !math.IsNaN(thresholds[i]) { | |||||
| thresholdOn = thresholds[i] | |||||
| } | |||||
| thresholdOff := thresholdOn - d.HysteresisDb | |||||
| if v >= thresholdOn { | if v >= thresholdOn { | ||||
| if !in { | if !in { | ||||
| in = true | in = true | ||||
| @@ -147,11 +176,19 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||||
| peakBin = i | peakBin = i | ||||
| } | } | ||||
| } else if in && v < thresholdOff { | } else if in && v < thresholdOff { | ||||
| noise := noiseGlobal | |||||
| if thresholds != nil && peakBin >= 0 && peakBin < len(thresholds) && !math.IsNaN(thresholds[peakBin]) { | |||||
| noise = thresholds[peakBin] - d.CFARScaleDb | |||||
| } | |||||
| signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, smooth)) | signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, smooth)) | ||||
| in = false | in = false | ||||
| } | } | ||||
| } | } | ||||
| if in { | if in { | ||||
| noise := noiseGlobal | |||||
| if thresholds != nil && peakBin >= 0 && peakBin < len(thresholds) && !math.IsNaN(thresholds[peakBin]) { | |||||
| noise = thresholds[peakBin] - d.CFARScaleDb | |||||
| } | |||||
| signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, smooth)) | signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, smooth)) | ||||
| } | } | ||||
| return signals | return signals | ||||
| @@ -172,6 +209,43 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||||
| } | } | ||||
| } | } | ||||
| func (d *Detector) cfarThresholds(spectrum []float64) []float64 { | |||||
| if !d.CFAREnabled || d.CFARTrainCells <= 0 { | |||||
| return nil | |||||
| } | |||||
| n := len(spectrum) | |||||
| train := d.CFARTrainCells | |||||
| guard := d.CFARGuardCells | |||||
| totalTrain := 2 * train | |||||
| if totalTrain <= 0 { | |||||
| return nil | |||||
| } | |||||
| rank := d.CFARRank | |||||
| if rank <= 0 || rank > totalTrain { | |||||
| rank = int(math.Round(0.75 * float64(totalTrain))) | |||||
| if rank <= 0 { | |||||
| rank = 1 | |||||
| } | |||||
| } | |||||
| rankIdx := rank - 1 | |||||
| thresholds := make([]float64, n) | |||||
| buf := make([]float64, totalTrain) | |||||
| for i := 0; i < n; i++ { | |||||
| leftStart := i - guard - train | |||||
| rightEnd := i + guard + train | |||||
| if leftStart < 0 || rightEnd >= n { | |||||
| thresholds[i] = math.NaN() | |||||
| continue | |||||
| } | |||||
| copy(buf[:train], spectrum[leftStart:leftStart+train]) | |||||
| copy(buf[train:], spectrum[i+guard+1:i+guard+1+train]) | |||||
| sort.Float64s(buf) | |||||
| noise := buf[rankIdx] | |||||
| thresholds[i] = noise + d.CFARScaleDb | |||||
| } | |||||
| return thresholds | |||||
| } | |||||
| func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | ||||
| if d.ema == nil || len(d.ema) != len(spectrum) { | if d.ema == nil || len(d.ema) != len(spectrum) { | ||||
| d.ema = make([]float64, len(spectrum)) | d.ema = make([]float64, len(spectrum)) | ||||
| @@ -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, 1, 10*time.Millisecond) | |||||
| d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3, 1, 10*time.Millisecond, false, 2, 16, 24, 6) | |||||
| 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() | ||||
| @@ -26,6 +26,11 @@ type DetectorUpdate struct { | |||||
| HysteresisDb *float64 `json:"hysteresis_db"` | HysteresisDb *float64 `json:"hysteresis_db"` | ||||
| MinStableFrames *int `json:"min_stable_frames"` | MinStableFrames *int `json:"min_stable_frames"` | ||||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | GapToleranceMs *int `json:"gap_tolerance_ms"` | ||||
| CFAREnabled *bool `json:"cfar_enabled"` | |||||
| CFARGuardCells *int `json:"cfar_guard_cells"` | |||||
| CFARTrainCells *int `json:"cfar_train_cells"` | |||||
| CFARRank *int `json:"cfar_rank"` | |||||
| CFARScaleDb *float64 `json:"cfar_scale_db"` | |||||
| } | } | ||||
| type SettingsUpdate struct { | type SettingsUpdate struct { | ||||
| @@ -137,6 +142,33 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.Detector.GapToleranceMs != nil { | if update.Detector.GapToleranceMs != nil { | ||||
| next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs | next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs | ||||
| } | } | ||||
| if update.Detector.CFAREnabled != nil { | |||||
| next.Detector.CFAREnabled = *update.Detector.CFAREnabled | |||||
| } | |||||
| if update.Detector.CFARGuardCells != nil { | |||||
| if *update.Detector.CFARGuardCells < 0 { | |||||
| return m.cfg, errors.New("cfar_guard_cells must be >= 0") | |||||
| } | |||||
| next.Detector.CFARGuardCells = *update.Detector.CFARGuardCells | |||||
| } | |||||
| if update.Detector.CFARTrainCells != nil { | |||||
| if *update.Detector.CFARTrainCells <= 0 { | |||||
| return m.cfg, errors.New("cfar_train_cells must be > 0") | |||||
| } | |||||
| next.Detector.CFARTrainCells = *update.Detector.CFARTrainCells | |||||
| } | |||||
| if update.Detector.CFARRank != nil { | |||||
| if *update.Detector.CFARRank <= 0 { | |||||
| return m.cfg, errors.New("cfar_rank must be > 0") | |||||
| } | |||||
| if next.Detector.CFARTrainCells > 0 && *update.Detector.CFARRank > 2*next.Detector.CFARTrainCells { | |||||
| return m.cfg, errors.New("cfar_rank must be <= 2 * cfar_train_cells") | |||||
| } | |||||
| next.Detector.CFARRank = *update.Detector.CFARRank | |||||
| } | |||||
| if update.Detector.CFARScaleDb != nil { | |||||
| next.Detector.CFARScaleDb = *update.Detector.CFARScaleDb | |||||
| } | |||||
| } | } | ||||
| if update.Recorder != nil { | if update.Recorder != nil { | ||||
| if update.Recorder.Enabled != nil { | if update.Recorder.Enabled != nil { | ||||
| @@ -15,6 +15,11 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| fftSize := 4096 | fftSize := 4096 | ||||
| threshold := -35.0 | threshold := -35.0 | ||||
| bw := 1536 | bw := 1536 | ||||
| cfarEnabled := true | |||||
| cfarGuard := 2 | |||||
| cfarTrain := 12 | |||||
| cfarRank := 18 | |||||
| cfarScale := 5.5 | |||||
| updated, err := mgr.ApplyConfig(ConfigUpdate{ | updated, err := mgr.ApplyConfig(ConfigUpdate{ | ||||
| CenterHz: ¢er, | CenterHz: ¢er, | ||||
| @@ -22,7 +27,12 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| FFTSize: &fftSize, | FFTSize: &fftSize, | ||||
| TunerBwKHz: &bw, | TunerBwKHz: &bw, | ||||
| Detector: &DetectorUpdate{ | Detector: &DetectorUpdate{ | ||||
| ThresholdDb: &threshold, | |||||
| ThresholdDb: &threshold, | |||||
| CFAREnabled: &cfarEnabled, | |||||
| CFARGuardCells: &cfarGuard, | |||||
| CFARTrainCells: &cfarTrain, | |||||
| CFARRank: &cfarRank, | |||||
| CFARScaleDb: &cfarScale, | |||||
| }, | }, | ||||
| }) | }) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -40,6 +50,21 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| if updated.Detector.ThresholdDb != threshold { | if updated.Detector.ThresholdDb != threshold { | ||||
| t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | ||||
| } | } | ||||
| if updated.Detector.CFAREnabled != cfarEnabled { | |||||
| t.Fatalf("cfar enabled: %v", updated.Detector.CFAREnabled) | |||||
| } | |||||
| if updated.Detector.CFARGuardCells != cfarGuard { | |||||
| t.Fatalf("cfar guard: %v", updated.Detector.CFARGuardCells) | |||||
| } | |||||
| if updated.Detector.CFARTrainCells != cfarTrain { | |||||
| t.Fatalf("cfar train: %v", updated.Detector.CFARTrainCells) | |||||
| } | |||||
| if updated.Detector.CFARRank != cfarRank { | |||||
| t.Fatalf("cfar rank: %v", updated.Detector.CFARRank) | |||||
| } | |||||
| if updated.Detector.CFARScaleDb != cfarScale { | |||||
| t.Fatalf("cfar scale: %v", updated.Detector.CFARScaleDb) | |||||
| } | |||||
| if updated.TunerBwKHz != bw { | if updated.TunerBwKHz != bw { | ||||
| t.Fatalf("tuner bw: %v", updated.TunerBwKHz) | t.Fatalf("tuner bw: %v", updated.TunerBwKHz) | ||||
| } | } | ||||
| @@ -29,6 +29,11 @@ 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 cfarToggle = qs('cfarToggle'); | |||||
| const cfarGuardInput = qs('cfarGuardInput'); | |||||
| const cfarTrainInput = qs('cfarTrainInput'); | |||||
| const cfarRankInput = qs('cfarRankInput'); | |||||
| const cfarScaleInput = qs('cfarScaleInput'); | |||||
| const emaAlphaInput = qs('emaAlphaInput'); | const emaAlphaInput = qs('emaAlphaInput'); | ||||
| const hysteresisInput = qs('hysteresisInput'); | const hysteresisInput = qs('hysteresisInput'); | ||||
| const stableFramesInput = qs('stableFramesInput'); | const stableFramesInput = qs('stableFramesInput'); | ||||
| @@ -282,6 +287,11 @@ 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 (cfarToggle) cfarToggle.checked = !!cfg.detector.cfar_enabled; | |||||
| if (cfarGuardInput) cfarGuardInput.value = cfg.detector.cfar_guard_cells ?? 2; | |||||
| if (cfarTrainInput) cfarTrainInput.value = cfg.detector.cfar_train_cells ?? 16; | |||||
| if (cfarRankInput) cfarRankInput.value = cfg.detector.cfar_rank ?? 24; | |||||
| if (cfarScaleInput) cfarScaleInput.value = cfg.detector.cfar_scale_db ?? 6; | |||||
| if (minDurationInput) minDurationInput.value = cfg.detector.min_duration_ms; | if (minDurationInput) minDurationInput.value = cfg.detector.min_duration_ms; | ||||
| if (holdInput) holdInput.value = cfg.detector.hold_ms; | if (holdInput) holdInput.value = cfg.detector.hold_ms; | ||||
| if (emaAlphaInput) emaAlphaInput.value = cfg.detector.ema_alpha ?? 0.2; | if (emaAlphaInput) emaAlphaInput.value = cfg.detector.ema_alpha ?? 0.2; | ||||
| @@ -1136,6 +1146,24 @@ thresholdInput.addEventListener('change', () => { | |||||
| } | } | ||||
| }); | }); | ||||
| if (cfarToggle) cfarToggle.addEventListener('change', () => queueConfigUpdate({ detector: { cfar_enabled: cfarToggle.checked } })); | |||||
| if (cfarGuardInput) cfarGuardInput.addEventListener('change', () => { | |||||
| const v = parseInt(cfarGuardInput.value, 10); | |||||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_guard_cells: v } }); | |||||
| }); | |||||
| if (cfarTrainInput) cfarTrainInput.addEventListener('change', () => { | |||||
| const v = parseInt(cfarTrainInput.value, 10); | |||||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_train_cells: v } }); | |||||
| }); | |||||
| if (cfarRankInput) cfarRankInput.addEventListener('change', () => { | |||||
| const v = parseInt(cfarRankInput.value, 10); | |||||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_rank: v } }); | |||||
| }); | |||||
| if (cfarScaleInput) cfarScaleInput.addEventListener('change', () => { | |||||
| const v = parseFloat(cfarScaleInput.value); | |||||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_scale_db: v } }); | |||||
| }); | |||||
| agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); | agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); | ||||
| dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); | dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); | ||||
| iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.checked })); | iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.checked })); | ||||
| @@ -173,6 +173,10 @@ | |||||
| <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>CFAR Guard Cells</span><input id="cfarGuardInput" type="number" step="1" min="0" /></label> | |||||
| <label class="field"><span>CFAR Train Cells</span><input id="cfarTrainInput" type="number" step="1" min="1" /></label> | |||||
| <label class="field"><span>CFAR Rank</span><input id="cfarRankInput" type="number" step="1" min="1" /></label> | |||||
| <label class="field"><span>CFAR Scale (dB)</span><input id="cfarScaleInput" type="number" step="0.5" min="0" /></label> | |||||
| <label class="field"><span>Min Duration (ms)</span><input id="minDurationInput" type="number" step="50" min="50" /></label> | <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>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> | ||||
| @@ -186,6 +190,7 @@ | |||||
| <label class="field"><span>Min Stable Frames</span><input id="stableFramesInput" type="number" step="1" min="1" /></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> | <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="cfarToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">CFAR</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="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> | ||||
| <label class="pill-toggle"><input id="iqToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">IQ Bal</span></label> | <label class="pill-toggle"><input id="iqToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">IQ Bal</span></label> | ||||