diff --git a/internal/classifier/classifier.go b/internal/classifier/classifier.go new file mode 100644 index 0000000..9bb54b0 --- /dev/null +++ b/internal/classifier/classifier.go @@ -0,0 +1,11 @@ +package classifier + +// Classify builds features and applies the rule-based classifier. +func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int) *Classification { + if len(spectrum) == 0 || input.FirstBin < 0 || input.LastBin < 0 { + return nil + } + feat := ExtractFeatures(input, spectrum, sampleRate, fftSize) + cls := RuleClassify(feat) + return &cls +} diff --git a/internal/classifier/classifier_test.go b/internal/classifier/classifier_test.go new file mode 100644 index 0000000..dc9625b --- /dev/null +++ b/internal/classifier/classifier_test.go @@ -0,0 +1,21 @@ +package classifier + +import "testing" + +func TestRuleClassifyWFM(t *testing.T) { + sampleRate := 1_000_000 + fftSize := 1024 + spectrum := make([]float64, fftSize) + for i := range spectrum { + spectrum[i] = -100 + } + start := 100 + end := 350 // ~244 bins -> ~238 kHz + for i := start; i <= end; i++ { + spectrum[i] = -10 + } + cls := Classify(SignalInput{FirstBin: start, LastBin: end}, spectrum, sampleRate, fftSize) + if cls == nil || cls.ModType != ClassWFM { + t.Fatalf("expected WFM, got %+v", cls) + } +} diff --git a/internal/classifier/features.go b/internal/classifier/features.go new file mode 100644 index 0000000..a30159a --- /dev/null +++ b/internal/classifier/features.go @@ -0,0 +1,167 @@ +package classifier + +import ( + "math" +) + +// ExtractFeatures computes spectral features for a signal slice. +// spectrum is full-band power in dB (length fftSize). +func ExtractFeatures(s SignalInput, spectrum []float64, sampleRate int, fftSize int) Features { + if fftSize <= 0 { + fftSize = len(spectrum) + } + if len(spectrum) == 0 || s.FirstBin < 0 || s.LastBin < s.FirstBin || s.FirstBin >= len(spectrum) { + return Features{} + } + if s.LastBin >= len(spectrum) { + s.LastBin = len(spectrum) - 1 + } + binHz := float64(sampleRate) / float64(max(1, fftSize)) + // slice + start := s.FirstBin + end := s.LastBin + peakDb := -1e9 + peakIdx := start + sumLin := 0.0 + geoSum := 0.0 + count := 0 + for i := start; i <= end; i++ { + db := spectrum[i] + if db > peakDb { + peakDb = db + peakIdx = i + } + p := math.Pow(10, db/10.0) + sumLin += p + if p > 0 { + geoSum += math.Log(p) + } + count++ + } + avgLin := 0.0 + if count > 0 { + avgLin = sumLin / float64(count) + } + // Peak-to-avg in dB + peakToAvg := 0.0 + if avgLin > 0 { + peakToAvg = 10 * math.Log10(math.Pow(10, peakDb/10.0)/avgLin) + } + // Spectral flatness + flat := 0.0 + if count > 0 && avgLin > 0 { + geoMean := math.Exp(geoSum / float64(count)) + flat = geoMean / avgLin + } + // BW3dB + bw3 := bwAtThreshold(spectrum, start, end, peakDb-3.0) * binHz + // BW90 (90% energy) + bw90 := bwEnergy(spectrum, start, end, 0.90) * binHz + // Symmetry (power left/right of peak) + leftSum, rightSum := 0.0, 0.0 + for i := start; i <= end; i++ { + p := math.Pow(10, spectrum[i]/10.0) + if i <= peakIdx { + leftSum += p + } else { + rightSum += p + } + } + sym := 0.0 + if leftSum+rightSum > 0 { + sym = (rightSum - leftSum) / (rightSum + leftSum) + } + // Rolloff (dB/kHz) at edges + leftDb := spectrum[start] + rightDb := spectrum[end] + leftHz := math.Max(binHz, float64(peakIdx-start)*binHz) + rightHz := math.Max(binHz, float64(end-peakIdx)*binHz) + rollL := (peakDb - leftDb) / (leftHz / 1e3) + rollR := (peakDb - rightDb) / (rightHz / 1e3) + + return Features{ + BW3dB: bw3, + BW90: bw90, + SpectralFlat: clamp01(flat), + PeakToAvg: peakToAvg, + Symmetry: sym, + RolloffLeft: rollL, + RolloffRight: rollR, + } +} + +func bwAtThreshold(spectrum []float64, start, end int, threshDb float64) float64 { + left := start + right := end + for i := start; i <= end; i++ { + if spectrum[i] >= threshDb { + left = i + break + } + } + for i := end; i >= start; i-- { + if spectrum[i] >= threshDb { + right = i + break + } + } + if right < left { + return float64(end - start + 1) + } + return float64(right - left + 1) +} + +func bwEnergy(spectrum []float64, start, end int, frac float64) float64 { + if frac <= 0 { + return 0 + } + if frac > 1 { + frac = 1 + } + powers := make([]float64, 0, end-start+1) + sum := 0.0 + for i := start; i <= end; i++ { + p := math.Pow(10, spectrum[i]/10.0) + sum += p + powers = append(powers, p) + } + if sum == 0 { + return float64(end - start + 1) + } + // accumulate from center outward + center := (start + end) / 2 + l := center + r := center + acc := powers[center-start] + for acc/sum < frac && (l > start || r < end) { + if l > start { + l-- + acc += powers[l-start] + } + if acc/sum >= frac { + break + } + if r < end { + r++ + acc += powers[r-start] + } + } + return float64(r - l + 1) +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/classifier/rules.go b/internal/classifier/rules.go new file mode 100644 index 0000000..85b9377 --- /dev/null +++ b/internal/classifier/rules.go @@ -0,0 +1,74 @@ +package classifier + +import "math" + +func RuleClassify(feat Features) Classification { + bw := feat.BW3dB + flat := feat.SpectralFlat + sym := feat.Symmetry + p2a := feat.PeakToAvg + + best := ClassUnknown + second := ClassUnknown + conf := 0.3 + + switch { + case bw > 100e3: + best = ClassWFM + conf = 0.9 + case bw >= 6e3 && bw <= 16e3: + best = ClassNFM + conf = 0.8 + if flat > 0.7 { + second = ClassNoise + } + case bw >= 500 && bw < 3e3: + if sym > 0.2 { + best = ClassSSBUSB + conf = 0.7 + } else if sym < -0.2 { + best = ClassSSBLSB + conf = 0.7 + } else if p2a > 3 && flat < 0.4 { + best = ClassAM + conf = 0.6 + } + case bw < 500: + best = ClassCW + conf = 0.7 + } + + // noise hint + if best == ClassUnknown && flat > 0.85 && bw > 2e3 { + best = ClassNoise + conf = 0.6 + } + + // edge-case: if symmetry is strong, second best opposite side + if (best == ClassSSBUSB || best == ClassSSBLSB) && second == ClassUnknown { + if best == ClassSSBUSB { + second = ClassSSBLSB + } else { + second = ClassSSBUSB + } + } + + // slightly scale confidence by feature strength + if best == ClassNFM || best == ClassWFM { + conf = conf * (0.8 + 0.2*clamp01(1-flat)) + } + if best == ClassAM { + conf = conf * (0.7 + 0.3*clamp01(p2a/6.0)) + } + if math.IsNaN(conf) || conf <= 0 { + conf = 0.3 + } + + return Classification{ + ModType: best, + Confidence: conf, + BW3dB: bw, + Features: feat, + SecondBest: second, + } +} diff --git a/internal/classifier/types.go b/internal/classifier/types.go new file mode 100644 index 0000000..eccc905 --- /dev/null +++ b/internal/classifier/types.go @@ -0,0 +1,43 @@ +package classifier + +// SignalClass is a coarse modulation label. +type SignalClass string + +const ( + ClassAM SignalClass = "AM" + ClassNFM SignalClass = "NFM" + ClassWFM SignalClass = "WFM" + ClassSSBUSB SignalClass = "USB" + ClassSSBLSB SignalClass = "LSB" + ClassCW SignalClass = "CW" + ClassNoise SignalClass = "NOISE" + ClassUnknown SignalClass = "UNKNOWN" +) + +// Features are lightweight spectral features derived from a signal slice. +type Features struct { + // Spectral + BW3dB float64 `json:"bw_3db_hz"` + BW90 float64 `json:"bw_90_hz"` + SpectralFlat float64 `json:"spectral_flat"` + PeakToAvg float64 `json:"peak_to_avg_db"` + Symmetry float64 `json:"symmetry"` + RolloffLeft float64 `json:"rolloff_left_db_khz"` + RolloffRight float64 `json:"rolloff_right_db_khz"` +} + +// Classification is the classifier output attached to signals/events. +type Classification struct { + ModType SignalClass `json:"mod_type"` + Confidence float64 `json:"confidence"` + BW3dB float64 `json:"bw_3db_hz"` + Features Features `json:"features,omitempty"` + SecondBest SignalClass `json:"second_best,omitempty"` +} + +// SignalInput is the minimal input needed for classification. +type SignalInput struct { + FirstBin int + LastBin int + SNRDb float64 +} diff --git a/internal/detector/detector.go b/internal/detector/detector.go index b0bd2e2..e305bb7 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -4,18 +4,21 @@ import ( "math" "sort" "time" + + "sdr-visual-suite/internal/classifier" ) type Event struct { - ID int64 `json:"id"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - CenterHz float64 `json:"center_hz"` - Bandwidth float64 `json:"bandwidth_hz"` - PeakDb float64 `json:"peak_db"` - SNRDb float64 `json:"snr_db"` - FirstBin int `json:"first_bin"` - LastBin int `json:"last_bin"` + ID int64 `json:"id"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + CenterHz float64 `json:"center_hz"` + Bandwidth float64 `json:"bandwidth_hz"` + PeakDb float64 `json:"peak_db"` + SNRDb float64 `json:"snr_db"` + FirstBin int `json:"first_bin"` + LastBin int `json:"last_bin"` + Class *classifier.Classification `json:"class,omitempty"` } type Detector struct { @@ -23,32 +26,35 @@ type Detector struct { MinDuration time.Duration Hold time.Duration - binWidth float64 - nbins int + binWidth float64 + nbins int + sampleRate int - active map[int64]*activeEvent - nextID int64 + active map[int64]*activeEvent + nextID int64 } type activeEvent struct { - id int64 - start time.Time - lastSeen time.Time - centerHz float64 - bwHz float64 - peakDb float64 - snrDb float64 - firstBin int - lastBin int + id int64 + start time.Time + lastSeen time.Time + centerHz float64 + bwHz float64 + peakDb float64 + snrDb float64 + firstBin int + lastBin int + class *classifier.Classification } type Signal struct { - FirstBin int `json:"first_bin"` - LastBin int `json:"last_bin"` - CenterHz float64 `json:"center_hz"` - BWHz float64 `json:"bw_hz"` - PeakDb float64 `json:"peak_db"` - SNRDb float64 `json:"snr_db"` + FirstBin int `json:"first_bin"` + LastBin int `json:"last_bin"` + CenterHz float64 `json:"center_hz"` + BWHz float64 `json:"bw_hz"` + PeakDb float64 `json:"peak_db"` + SNRDb float64 `json:"snr_db"` + Class *classifier.Classification `json:"class,omitempty"` } func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration) *Detector { @@ -64,6 +70,7 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur Hold: hold, binWidth: float64(sampleRate) / float64(fftSize), nbins: fftSize, + sampleRate: sampleRate, active: map[int64]*activeEvent{}, nextID: 1, } @@ -100,21 +107,22 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal peakBin = i } } else if in { - signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz)) + signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, spectrum)) in = false } } if in { - signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz)) + signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, spectrum)) } return signals } -func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64) Signal { +func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64, spectrum []float64) Signal { centerBin := float64(first+last) / 2.0 centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth bw := float64(last-first+1) * d.binWidth snr := peak - noise + cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins) return Signal{ FirstBin: first, LastBin: last, @@ -122,6 +130,7 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise BWHz: bw, PeakDb: peak, SNRDb: snr, + Class: cls, } } @@ -158,6 +167,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { snrDb: s.SNRDb, firstBin: s.FirstBin, lastBin: s.LastBin, + class: s.Class, } continue } @@ -179,6 +189,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { if s.LastBin > best.lastBin { best.lastBin = s.LastBin } + if s.Class != nil { + if best.class == nil || s.Class.Confidence >= best.class.Confidence { + best.class = s.Class + } + } } var finished []Event @@ -204,6 +219,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { SNRDb: ev.snrDb, FirstBin: ev.firstBin, LastBin: ev.lastBin, + Class: ev.class, }) delete(d.active, id) }