| @@ -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, | |||
| }) | |||
| } | |||
| } | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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) => { | |||