| @@ -35,12 +35,14 @@ import ( | |||||
| ) | ) | ||||
| type SpectrumFrame struct { | 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 { | type client struct { | ||||
| @@ -837,6 +839,8 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| } | } | ||||
| now := time.Now() | now := time.Now() | ||||
| finished, signals := det.Process(now, spectrum, cfg.CenterHz) | finished, signals := det.Process(now, spectrum, cfg.CenterHz) | ||||
| thresholds := det.LastThresholds() | |||||
| noiseFloor := det.LastNoiseFloor() | |||||
| // enrich classification with temporal IQ features on per-signal snippet | // enrich classification with temporal IQ features on per-signal snippet | ||||
| if len(iq) > 0 { | if len(iq) > 0 { | ||||
| for i := range signals { | for i := range signals { | ||||
| @@ -860,12 +864,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| go rec.OnEvents(evCopy) | go rec.OnEvents(evCopy) | ||||
| } | } | ||||
| h.broadcast(SpectrumFrame{ | 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, | |||||
| }) | }) | ||||
| } | } | ||||
| } | } | ||||
| @@ -31,14 +31,16 @@ type Detector struct { | |||||
| MinStableFrames int | MinStableFrames int | ||||
| GapTolerance time.Duration | GapTolerance time.Duration | ||||
| CFARScaleDb float64 | 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 { | type activeEvent struct { | ||||
| @@ -62,6 +64,7 @@ type Signal struct { | |||||
| BWHz float64 `json:"bw_hz"` | BWHz float64 `json:"bw_hz"` | ||||
| PeakDb float64 `json:"peak_db"` | PeakDb float64 `json:"peak_db"` | ||||
| SNRDb float64 `json:"snr_db"` | SNRDb float64 `json:"snr_db"` | ||||
| NoiseDb float64 `json:"noise_db,omitempty"` | |||||
| Class *classifier.Classification `json:"class,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 | 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. | // UpdateClasses refreshes active event classes from current signals. | ||||
| func (d *Detector) UpdateClasses(signals []Signal) { | func (d *Detector) UpdateClasses(signals []Signal) { | ||||
| for _, s := range signals { | for _, s := range signals { | ||||
| @@ -160,7 +174,9 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||||
| if d.cfarEngine != nil { | if d.cfarEngine != nil { | ||||
| thresholds = d.cfarEngine.Thresholds(smooth) | thresholds = d.cfarEngine.Thresholds(smooth) | ||||
| } | } | ||||
| d.lastThresholds = append(d.lastThresholds[:0], thresholds...) | |||||
| noiseGlobal := median(smooth) | noiseGlobal := median(smooth) | ||||
| d.lastNoiseFloor = noiseGlobal | |||||
| var signals []Signal | var signals []Signal | ||||
| in := false | in := false | ||||
| start := 0 | start := 0 | ||||
| @@ -214,6 +230,7 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||||
| BWHz: bw, | BWHz: bw, | ||||
| PeakDb: peak, | PeakDb: peak, | ||||
| SNRDb: snr, | SNRDb: snr, | ||||
| NoiseDb: noise, | |||||
| } | } | ||||
| } | } | ||||
| @@ -211,6 +211,41 @@ function maxInBinRange(spectrum, b0, b1) { | |||||
| return max; | 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() { | function markSpectrumDirty() { | ||||
| processingDirty = true; | processingDirty = true; | ||||
| } | } | ||||
| @@ -645,6 +680,7 @@ function renderSpectrum() { | |||||
| else ctx.lineTo(x, y); | else ctx.lineTo(x, y); | ||||
| } | } | ||||
| ctx.stroke(); | ctx.stroke(); | ||||
| drawThresholdOverlay(ctx, w, h, minDb, maxDb); | |||||
| if (Array.isArray(latest.signals)) { | if (Array.isArray(latest.signals)) { | ||||
| latest.signals.forEach((s, index) => { | latest.signals.forEach((s, index) => { | ||||