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