diff --git a/README.md b/README.md index 49ab1ed..45b484b 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ Edit `config.yaml`: - `agc`: enable automatic gain control - `dc_block`: enable DC blocking filter - `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 - `recorder.*`: enable IQ/audio recording, preroll, output_dir, max_disk_mb - `decoder.*`: external decode commands (use `{iq}`, `{audio}`, `{sr}` placeholders) diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index 2523dc3..a312b3c 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -319,7 +319,12 @@ func main() { cfg.Detector.EmaAlpha, cfg.Detector.HysteresisDb, 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) h := newHub() @@ -439,6 +444,11 @@ func main() { prev.Detector.HysteresisDb != next.Detector.HysteresisDb || prev.Detector.MinStableFrames != next.Detector.MinStableFrames || 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.FFTSize != next.FFTSize windowChanged := prev.FFTSize != next.FFTSize @@ -451,7 +461,12 @@ func main() { next.Detector.EmaAlpha, next.Detector.HysteresisDb, 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 { newWindow = fftutil.Hann(next.FFTSize) diff --git a/config.yaml b/config.yaml index ee504cb..92bbb4f 100644 --- a/config.yaml +++ b/config.yaml @@ -19,6 +19,11 @@ detector: hysteresis_db: 3 min_stable_frames: 3 gap_tolerance_ms: 500 + cfar_enabled: true + cfar_guard_cells: 2 + cfar_train_cells: 16 + cfar_rank: 24 + cfar_scale_db: 6 recorder: enabled: true min_snr_db: 10 diff --git a/internal/config/config.go b/internal/config/config.go index eb0ac21..43578ef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "math" "os" "time" @@ -21,6 +22,11 @@ type DetectorConfig struct { 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"` + 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 { @@ -83,7 +89,7 @@ func Default() Config { AGC: false, DCBlock: 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{ Enabled: false, MinSNRDb: 10, @@ -128,6 +134,21 @@ func Load(path string) (Config, error) { if cfg.Detector.GapToleranceMs <= 0 { 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 { cfg.FrameRate = 15 } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index bc74f56..8f1f469 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -29,6 +29,11 @@ type Detector struct { HysteresisDb float64 MinStableFrames int GapTolerance time.Duration + CFAREnabled bool + CFARGuardCells int + CFARTrainCells int + CFARRank int + CFARScaleDb float64 binWidth float64 nbins int @@ -63,7 +68,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, 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 { minDur = 250 * time.Millisecond } @@ -82,6 +87,21 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur if gapTolerance <= 0 { 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{ ThresholdDb: thresholdDb, MinDuration: minDur, @@ -90,6 +110,11 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur HysteresisDb: hysteresis, MinStableFrames: minStable, GapTolerance: gapTolerance, + CFAREnabled: cfarEnabled, + CFARGuardCells: cfarGuard, + CFARTrainCells: cfarTrain, + CFARRank: cfarRank, + CFARScaleDb: cfarScaleDb, binWidth: float64(sampleRate) / float64(fftSize), nbins: fftSize, sampleRate: sampleRate, @@ -126,9 +151,8 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal return nil } 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 in := false start := 0 @@ -136,6 +160,11 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal peakBin := 0 for i := 0; i < n; 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 !in { in = true @@ -147,11 +176,19 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal peakBin = i } } 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)) in = false } } 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)) } 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 { if d.ema == nil || len(d.ema) != len(spectrum) { d.ema = make([]float64, len(spectrum)) diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 6fae075..7291a6d 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, 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 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 93b1844..428594f 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -26,6 +26,11 @@ type DetectorUpdate struct { HysteresisDb *float64 `json:"hysteresis_db"` MinStableFrames *int `json:"min_stable_frames"` 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 { @@ -137,6 +142,33 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { if update.Detector.GapToleranceMs != nil { 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.Enabled != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index d020de2..9bed40b 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -15,6 +15,11 @@ func TestApplyConfigUpdate(t *testing.T) { fftSize := 4096 threshold := -35.0 bw := 1536 + cfarEnabled := true + cfarGuard := 2 + cfarTrain := 12 + cfarRank := 18 + cfarScale := 5.5 updated, err := mgr.ApplyConfig(ConfigUpdate{ CenterHz: ¢er, @@ -22,7 +27,12 @@ func TestApplyConfigUpdate(t *testing.T) { FFTSize: &fftSize, TunerBwKHz: &bw, Detector: &DetectorUpdate{ - ThresholdDb: &threshold, + ThresholdDb: &threshold, + CFAREnabled: &cfarEnabled, + CFARGuardCells: &cfarGuard, + CFARTrainCells: &cfarTrain, + CFARRank: &cfarRank, + CFARScaleDb: &cfarScale, }, }) if err != nil { @@ -40,6 +50,21 @@ func TestApplyConfigUpdate(t *testing.T) { if updated.Detector.ThresholdDb != threshold { 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 { t.Fatalf("tuner bw: %v", updated.TunerBwKHz) } diff --git a/web/app.js b/web/app.js index 39f416d..37bf491 100644 --- a/web/app.js +++ b/web/app.js @@ -29,6 +29,11 @@ const gainRange = qs('gainRange'); const gainInput = qs('gainInput'); const thresholdRange = qs('thresholdRange'); 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 hysteresisInput = qs('hysteresisInput'); const stableFramesInput = qs('stableFramesInput'); @@ -282,6 +287,11 @@ function applyConfigToUI(cfg) { gainInput.value = uiGain; thresholdRange.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 (holdInput) holdInput.value = cfg.detector.hold_ms; 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 })); dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.checked })); diff --git a/web/index.html b/web/index.html index d723ac0..13a2c32 100644 --- a/web/index.html +++ b/web/index.html @@ -173,6 +173,10 @@ Threshold
+ + + +