| @@ -13,10 +13,14 @@ func RuleClassify(feat Features) Classification { | |||
| conf := 0.3 | |||
| switch { | |||
| case bw > 100e3: | |||
| case bw >= 80e3: | |||
| best = ClassWFM | |||
| conf = 0.9 | |||
| case bw >= 6e3 && bw <= 16e3: | |||
| case bw >= 25e3 && bw < 80e3: | |||
| best = ClassWFM | |||
| second = ClassNFM | |||
| conf = 0.65 | |||
| case bw >= 6e3 && bw < 25e3: | |||
| best = ClassNFM | |||
| conf = 0.8 | |||
| if flat > 0.7 { | |||
| @@ -63,6 +67,12 @@ func RuleClassify(feat Features) Classification { | |||
| if feat.EnvVariance < 0.4 && feat.InstFreqStd < 0.5 { | |||
| best = ClassWSPR | |||
| conf = 0.55 | |||
| } else if feat.InstFreqStd > 0.9 { | |||
| best = ClassFSK | |||
| conf = 0.45 | |||
| } else if feat.InstFreqStd < 0.25 { | |||
| best = ClassPSK | |||
| conf = 0.45 | |||
| } | |||
| case bw < 150: | |||
| best = ClassCW | |||
| @@ -229,23 +229,75 @@ func (d *Detector) cfarThresholds(spectrum []float64) []float64 { | |||
| } | |||
| rankIdx := rank - 1 | |||
| thresholds := make([]float64, n) | |||
| buf := make([]float64, totalTrain) | |||
| buf := make([]float64, 0, totalTrain) | |||
| firstValid := guard + train | |||
| lastValid := n - guard - train - 1 | |||
| for i := 0; i < n; i++ { | |||
| leftStart := i - guard - train | |||
| rightEnd := i + guard + train | |||
| if leftStart < 0 || rightEnd >= n { | |||
| if i < firstValid || i > lastValid { | |||
| 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 | |||
| } | |||
| if firstValid > lastValid { | |||
| return thresholds | |||
| } | |||
| // Build initial sorted window for first valid bin. | |||
| leftStart := firstValid - guard - train | |||
| buf = append(buf, spectrum[leftStart:leftStart+train]...) | |||
| buf = append(buf, spectrum[firstValid+guard+1:firstValid+guard+1+train]...) | |||
| sort.Float64s(buf) | |||
| thresholds[firstValid] = buf[rankIdx] + d.CFARScaleDb | |||
| // Slide window: remove outgoing bins and insert incoming bins (O(train) per step). | |||
| for i := firstValid + 1; i <= lastValid; i++ { | |||
| outLeft := spectrum[i-guard-train-1] | |||
| outRight := spectrum[i+guard] | |||
| inLeft := spectrum[i-guard-1] | |||
| inRight := spectrum[i+guard+train] | |||
| buf = removeValue(buf, outLeft) | |||
| buf = removeValue(buf, outRight) | |||
| buf = insertValue(buf, inLeft) | |||
| buf = insertValue(buf, inRight) | |||
| thresholds[i] = buf[rankIdx] + d.CFARScaleDb | |||
| } | |||
| return thresholds | |||
| } | |||
| func removeValue(sorted []float64, v float64) []float64 { | |||
| if len(sorted) == 0 { | |||
| return sorted | |||
| } | |||
| idx := sort.SearchFloat64s(sorted, v) | |||
| if idx < len(sorted) && sorted[idx] == v { | |||
| return append(sorted[:idx], sorted[idx+1:]...) | |||
| } | |||
| for i := idx - 1; i >= 0; i-- { | |||
| if sorted[i] == v { | |||
| return append(sorted[:i], sorted[i+1:]...) | |||
| } | |||
| if sorted[i] < v { | |||
| break | |||
| } | |||
| } | |||
| for i := idx + 1; i < len(sorted); i++ { | |||
| if sorted[i] == v { | |||
| return append(sorted[:i], sorted[i+1:]...) | |||
| } | |||
| if sorted[i] > v { | |||
| break | |||
| } | |||
| } | |||
| return sorted | |||
| } | |||
| func insertValue(sorted []float64, v float64) []float64 { | |||
| idx := sort.SearchFloat64s(sorted, v) | |||
| sorted = append(sorted, 0) | |||
| copy(sorted[idx+1:], sorted[idx:]) | |||
| sorted[idx] = v | |||
| return sorted | |||
| } | |||
| func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | |||
| if d.ema == nil || len(d.ema) != len(spectrum) { | |||
| d.ema = make([]float64, len(spectrum)) | |||
| @@ -542,6 +542,44 @@ function drawSpectrumGrid(ctx, w, h, startHz, endHz) { | |||
| } | |||
| } | |||
| function drawCfarEdgeOverlay(ctx, w, h, startHz, endHz) { | |||
| if (!latest || !currentConfig?.detector?.cfar_enabled) return; | |||
| const guard = currentConfig.detector.cfar_guard_cells ?? 0; | |||
| const train = currentConfig.detector.cfar_train_cells ?? 0; | |||
| const bins = guard + train; | |||
| if (bins <= 0) return; | |||
| const fftSize = latest.fft_size || latest.spectrum_db?.length; | |||
| if (!fftSize || fftSize <= 0) return; | |||
| const binHz = latest.sample_rate / fftSize; | |||
| const edgeHz = bins * binHz; | |||
| const bandStart = latest.center_hz - latest.sample_rate / 2; | |||
| const bandEnd = latest.center_hz + latest.sample_rate / 2; | |||
| const leftEdgeEnd = bandStart + edgeHz; | |||
| const rightEdgeStart = bandEnd - edgeHz; | |||
| ctx.fillStyle = 'rgba(255, 204, 102, 0.08)'; | |||
| ctx.strokeStyle = 'rgba(255, 204, 102, 0.18)'; | |||
| ctx.lineWidth = 1; | |||
| const leftStart = Math.max(startHz, bandStart); | |||
| const leftEnd = Math.min(endHz, leftEdgeEnd); | |||
| if (leftEnd > leftStart) { | |||
| const x1 = ((leftStart - startHz) / (endHz - startHz)) * w; | |||
| const x2 = ((leftEnd - startHz) / (endHz - startHz)) * w; | |||
| ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); | |||
| ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); | |||
| } | |||
| const rightStart = Math.max(startHz, rightEdgeStart); | |||
| const rightEnd = Math.min(endHz, bandEnd); | |||
| if (rightEnd > rightStart) { | |||
| const x1 = ((rightStart - startHz) / (endHz - startHz)) * w; | |||
| const x2 = ((rightEnd - startHz) / (endHz - startHz)) * w; | |||
| ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); | |||
| ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); | |||
| } | |||
| } | |||
| function renderSpectrum() { | |||
| if (!latest) return; | |||
| const ctx = spectrumCanvas.getContext('2d'); | |||
| @@ -558,6 +596,7 @@ function renderSpectrum() { | |||
| spanInput.value = (span / 1e6).toFixed(3); | |||
| drawSpectrumGrid(ctx, w, h, startHz, endHz); | |||
| drawCfarEdgeOverlay(ctx, w, h, startHz, endHz); | |||
| const minDb = -120; | |||
| const maxDb = 0; | |||