| @@ -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" | "math" | ||||
| "sort" | "sort" | ||||
| "time" | "time" | ||||
| "sdr-visual-suite/internal/classifier" | |||||
| ) | ) | ||||
| type Event struct { | 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 { | type Detector struct { | ||||
| @@ -23,32 +26,35 @@ type Detector struct { | |||||
| MinDuration time.Duration | MinDuration time.Duration | ||||
| Hold 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 { | 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 { | 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 { | 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, | Hold: hold, | ||||
| binWidth: float64(sampleRate) / float64(fftSize), | binWidth: float64(sampleRate) / float64(fftSize), | ||||
| nbins: fftSize, | nbins: fftSize, | ||||
| sampleRate: sampleRate, | |||||
| active: map[int64]*activeEvent{}, | active: map[int64]*activeEvent{}, | ||||
| nextID: 1, | nextID: 1, | ||||
| } | } | ||||
| @@ -100,21 +107,22 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||||
| peakBin = i | peakBin = i | ||||
| } | } | ||||
| } else if in { | } 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 | in = false | ||||
| } | } | ||||
| } | } | ||||
| if in { | 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 | 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 | centerBin := float64(first+last) / 2.0 | ||||
| centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth | centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth | ||||
| bw := float64(last-first+1) * d.binWidth | bw := float64(last-first+1) * d.binWidth | ||||
| snr := peak - noise | snr := peak - noise | ||||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins) | |||||
| return Signal{ | return Signal{ | ||||
| FirstBin: first, | FirstBin: first, | ||||
| LastBin: last, | LastBin: last, | ||||
| @@ -122,6 +130,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, | ||||
| Class: cls, | |||||
| } | } | ||||
| } | } | ||||
| @@ -158,6 +167,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||||
| snrDb: s.SNRDb, | snrDb: s.SNRDb, | ||||
| firstBin: s.FirstBin, | firstBin: s.FirstBin, | ||||
| lastBin: s.LastBin, | lastBin: s.LastBin, | ||||
| class: s.Class, | |||||
| } | } | ||||
| continue | continue | ||||
| } | } | ||||
| @@ -179,6 +189,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||||
| if s.LastBin > best.lastBin { | if s.LastBin > best.lastBin { | ||||
| best.lastBin = s.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 | var finished []Event | ||||
| @@ -204,6 +219,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||||
| SNRDb: ev.snrDb, | SNRDb: ev.snrDb, | ||||
| FirstBin: ev.firstBin, | FirstBin: ev.firstBin, | ||||
| LastBin: ev.lastBin, | LastBin: ev.lastBin, | ||||
| Class: ev.class, | |||||
| }) | }) | ||||
| delete(d.active, id) | delete(d.active, id) | ||||
| } | } | ||||