From 66f7b1d6823c9d1ad1ffef46372c24363de3591d Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 19 Mar 2026 06:55:15 +0100 Subject: [PATCH] feat: expose and render CFAR thresholds --- cmd/sdrd/main.go | 30 +++++++++++++++++------------ internal/detector/detector.go | 31 +++++++++++++++++++++++------- web/app.js | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index fbf6fac..1fb3c98 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -35,12 +35,14 @@ import ( ) type SpectrumFrame struct { - Timestamp int64 `json:"ts"` - CenterHz float64 `json:"center_hz"` - SampleHz int `json:"sample_rate"` - FFTSize int `json:"fft_size"` - Spectrum []float64 `json:"spectrum_db"` - Signals []detector.Signal `json:"signals"` + Timestamp int64 `json:"ts"` + CenterHz float64 `json:"center_hz"` + SampleHz int `json:"sample_rate"` + FFTSize int `json:"fft_size"` + Spectrum []float64 `json:"spectrum_db"` + Thresholds []float64 `json:"thresholds,omitempty"` + NoiseFloor float64 `json:"noise_floor,omitempty"` + Signals []detector.Signal `json:"signals"` } type client struct { @@ -837,6 +839,8 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * } now := time.Now() finished, signals := det.Process(now, spectrum, cfg.CenterHz) + thresholds := det.LastThresholds() + noiseFloor := det.LastNoiseFloor() // enrich classification with temporal IQ features on per-signal snippet if len(iq) > 0 { for i := range signals { @@ -860,12 +864,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * go rec.OnEvents(evCopy) } h.broadcast(SpectrumFrame{ - Timestamp: now.UnixMilli(), - CenterHz: cfg.CenterHz, - SampleHz: cfg.SampleRate, - FFTSize: cfg.FFTSize, - Spectrum: spectrum, - Signals: signals, + Timestamp: now.UnixMilli(), + CenterHz: cfg.CenterHz, + SampleHz: cfg.SampleRate, + FFTSize: cfg.FFTSize, + Spectrum: spectrum, + Thresholds: thresholds, + NoiseFloor: noiseFloor, + Signals: signals, }) } } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 2866fba..56be4aa 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -31,14 +31,16 @@ type Detector struct { MinStableFrames int GapTolerance time.Duration CFARScaleDb float64 - binWidth float64 - nbins int - sampleRate int + binWidth float64 + nbins int + sampleRate int - ema []float64 - active map[int64]*activeEvent - nextID int64 - cfarEngine cfar.CFAR + ema []float64 + active map[int64]*activeEvent + nextID int64 + cfarEngine cfar.CFAR + lastThresholds []float64 + lastNoiseFloor float64 } type activeEvent struct { @@ -62,6 +64,7 @@ type Signal struct { BWHz float64 `json:"bw_hz"` PeakDb float64 `json:"peak_db"` SNRDb float64 `json:"snr_db"` + NoiseDb float64 `json:"noise_db,omitempty"` Class *classifier.Classification `json:"class,omitempty"` } @@ -135,6 +138,17 @@ func (d *Detector) Process(now time.Time, spectrum []float64, centerHz float64) return finished, signals } +func (d *Detector) LastThresholds() []float64 { + if len(d.lastThresholds) == 0 { + return nil + } + return append([]float64(nil), d.lastThresholds...) +} + +func (d *Detector) LastNoiseFloor() float64 { + return d.lastNoiseFloor +} + // UpdateClasses refreshes active event classes from current signals. func (d *Detector) UpdateClasses(signals []Signal) { for _, s := range signals { @@ -160,7 +174,9 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal if d.cfarEngine != nil { thresholds = d.cfarEngine.Thresholds(smooth) } + d.lastThresholds = append(d.lastThresholds[:0], thresholds...) noiseGlobal := median(smooth) + d.lastNoiseFloor = noiseGlobal var signals []Signal in := false start := 0 @@ -214,6 +230,7 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise BWHz: bw, PeakDb: peak, SNRDb: snr, + NoiseDb: noise, } } diff --git a/web/app.js b/web/app.js index c30bf18..e279081 100644 --- a/web/app.js +++ b/web/app.js @@ -211,6 +211,41 @@ function maxInBinRange(spectrum, b0, b1) { return max; } +function sampleOverlayAtX(overlay, x, width, centerHz, sampleRate) { + if (!Array.isArray(overlay) || overlay.length === 0 || width <= 0) return null; + const n = overlay.length; + const span = sampleRate / zoom; + const startHz = centerHz - span / 2 + pan * span; + const endHz = centerHz + span / 2 + pan * span; + const f1 = startHz + (x / width) * (endHz - startHz); + const f2 = startHz + ((x + 1) / width) * (endHz - startHz); + const b0 = binForFreq(f1, centerHz, sampleRate, n); + const b1 = binForFreq(f2, centerHz, sampleRate, n); + return maxInBinRange(overlay, b0, b1); +} + +function drawThresholdOverlay(ctx, w, h, minDb, maxDb) { + if (!latest?.thresholds?.length) return; + ctx.save(); + ctx.strokeStyle = 'rgba(255, 196, 92, 0.9)'; + ctx.lineWidth = 1.25; + if (ctx.setLineDash) ctx.setLineDash([6, 4]); + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const v = sampleOverlayAtX(latest.thresholds, x, w, latest.center_hz, latest.sample_rate); + if (v == null || Number.isNaN(v)) continue; + const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 18) - 6; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + if (ctx.setLineDash) ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255, 196, 92, 0.95)'; + ctx.font = '11px Inter, sans-serif'; + ctx.fillText('CFAR', 8, 14); + ctx.restore(); +} + function markSpectrumDirty() { processingDirty = true; } @@ -645,6 +680,7 @@ function renderSpectrum() { else ctx.lineTo(x, y); } ctx.stroke(); + drawThresholdOverlay(ctx, w, h, minDb, maxDb); if (Array.isArray(latest.signals)) { latest.signals.forEach((s, index) => {