| @@ -185,6 +185,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| } | |||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb, CenterHz: signals[i].CenterHz}, spectrum, cfg.SampleRate, cfg.FFTSize, snip, classifier.ClassifierMode(cfg.ClassifierMode)) | |||
| signals[i].Class = cls | |||
| if cls != nil && snip != nil && len(snip) > 256 { | |||
| pll := classifier.EstimateExactFrequency(snip, cfg.SampleRate, signals[i].CenterHz, cls.ModType) | |||
| cls.PLL = &pll | |||
| signals[i].PLL = &pll | |||
| if pll.Locked { | |||
| signals[i].CenterHz = pll.ExactHz | |||
| } | |||
| } | |||
| } | |||
| det.UpdateClasses(signals) | |||
| } | |||
| @@ -13,6 +13,10 @@ func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int | |||
| return nil | |||
| } | |||
| feat := ExtractFeatures(input, spectrum, sampleRate, fftSize) | |||
| if hard := TryHardRule(input.CenterHz, feat.BW3dB); hard != nil { | |||
| hard.Features = feat | |||
| return hard | |||
| } | |||
| if len(iq) > 0 { | |||
| envVar, zc, instStd, crest := ExtractTemporalFeatures(iq) | |||
| feat.EnvVariance = envVar | |||
| @@ -0,0 +1,70 @@ | |||
| package classifier | |||
| import ( | |||
| _ "embed" | |||
| "encoding/json" | |||
| "log" | |||
| ) | |||
| //go:embed hard_rules.json | |||
| var hardRulesJSON []byte | |||
| type hardRule struct { | |||
| Name string `json:"name"` | |||
| Match hardMatch `json:"match"` | |||
| Result hardResult `json:"result"` | |||
| Note string `json:"note"` | |||
| } | |||
| type hardMatch struct { | |||
| MinMHz float64 `json:"min_mhz,omitempty"` | |||
| MaxMHz float64 `json:"max_mhz,omitempty"` | |||
| MinBWHz float64 `json:"min_bw_hz,omitempty"` | |||
| MaxBWHz float64 `json:"max_bw_hz,omitempty"` | |||
| } | |||
| type hardResult struct { | |||
| ModType string `json:"mod_type"` | |||
| Confidence float64 `json:"confidence"` | |||
| } | |||
| type hardRulesFile struct { | |||
| Rules []hardRule `json:"rules"` | |||
| } | |||
| var loadedHardRules []hardRule | |||
| func init() { | |||
| var f hardRulesFile | |||
| if err := json.Unmarshal(hardRulesJSON, &f); err != nil { | |||
| log.Printf("classifier: failed to load hard rules: %v", err) | |||
| return | |||
| } | |||
| loadedHardRules = f.Rules | |||
| } | |||
| func TryHardRule(centerHz float64, bwHz float64) *Classification { | |||
| mhz := centerHz / 1e6 | |||
| for _, r := range loadedHardRules { | |||
| if r.Match.MinMHz > 0 && mhz < r.Match.MinMHz { | |||
| continue | |||
| } | |||
| if r.Match.MaxMHz > 0 && mhz > r.Match.MaxMHz { | |||
| continue | |||
| } | |||
| if r.Match.MinBWHz > 0 && bwHz < r.Match.MinBWHz { | |||
| continue | |||
| } | |||
| if r.Match.MaxBWHz > 0 && bwHz > r.Match.MaxBWHz { | |||
| continue | |||
| } | |||
| mod := SignalClass(r.Result.ModType) | |||
| return &Classification{ | |||
| ModType: mod, | |||
| Confidence: r.Result.Confidence, | |||
| BW3dB: bwHz, | |||
| Scores: map[SignalClass]float64{mod: 10.0}, | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| @@ -0,0 +1,52 @@ | |||
| { | |||
| "rules": [ | |||
| { | |||
| "name": "fm_broadcast", | |||
| "match": {"min_mhz": 87.5, "max_mhz": 108.0, "min_bw_hz": 50000}, | |||
| "result": {"mod_type": "WFM", "confidence": 0.99}, | |||
| "note": "FM Broadcast: >50 kHz BW im UKW-Band ist immer WFM" | |||
| }, | |||
| { | |||
| "name": "airband_am", | |||
| "match": {"min_mhz": 118.0, "max_mhz": 137.0, "min_bw_hz": 4000, "max_bw_hz": 12000}, | |||
| "result": {"mod_type": "AM", "confidence": 0.95}, | |||
| "note": "Airband ist ausschliesslich AM" | |||
| }, | |||
| { | |||
| "name": "cw_any_band", | |||
| "match": {"min_bw_hz": 0, "max_bw_hz": 500}, | |||
| "result": {"mod_type": "CW", "confidence": 0.90}, | |||
| "note": "Unter 500 Hz BW ist CW, egal welches Band" | |||
| }, | |||
| { | |||
| "name": "ft8_40m", | |||
| "match": {"min_mhz": 7.072, "max_mhz": 7.076, "min_bw_hz": 1500, "max_bw_hz": 3500}, | |||
| "result": {"mod_type": "FT8", "confidence": 0.95}, | |||
| "note": "FT8 Dial-Frequenz 40m" | |||
| }, | |||
| { | |||
| "name": "ft8_20m", | |||
| "match": {"min_mhz": 14.072, "max_mhz": 14.076, "min_bw_hz": 1500, "max_bw_hz": 3500}, | |||
| "result": {"mod_type": "FT8", "confidence": 0.95}, | |||
| "note": "FT8 Dial-Frequenz 20m" | |||
| }, | |||
| { | |||
| "name": "wspr_40m", | |||
| "match": {"min_mhz": 7.0384, "max_mhz": 7.0388, "min_bw_hz": 100, "max_bw_hz": 500}, | |||
| "result": {"mod_type": "WSPR", "confidence": 0.95}, | |||
| "note": "WSPR Dial-Frequenz 40m" | |||
| }, | |||
| { | |||
| "name": "pmr446_nfm", | |||
| "match": {"min_mhz": 446.0, "max_mhz": 446.2, "min_bw_hz": 5000, "max_bw_hz": 15000}, | |||
| "result": {"mod_type": "NFM", "confidence": 0.92}, | |||
| "note": "PMR446 ist immer NFM" | |||
| }, | |||
| { | |||
| "name": "wfm_wide_any", | |||
| "match": {"min_bw_hz": 100000}, | |||
| "result": {"mod_type": "WFM", "confidence": 0.95}, | |||
| "note": "Über 100 kHz BW ist fast immer WFM, unabhängig vom Band" | |||
| } | |||
| ] | |||
| } | |||
| @@ -0,0 +1,58 @@ | |||
| package classifier | |||
| import ( | |||
| "math" | |||
| "testing" | |||
| ) | |||
| func TestHardRulesFMBroadcast(t *testing.T) { | |||
| cls := TryHardRule(100.0e6, 120000) | |||
| if cls == nil { | |||
| t.Fatal("expected hard rule match for FM broadcast") | |||
| } | |||
| if cls.ModType != ClassWFM { | |||
| t.Errorf("expected WFM, got %s", cls.ModType) | |||
| } | |||
| if cls.Confidence < 0.95 { | |||
| t.Errorf("confidence too low: %.2f", cls.Confidence) | |||
| } | |||
| cls2 := TryHardRule(434.0e6, 120000) | |||
| if cls2 == nil || cls2.ModType != ClassWFM { | |||
| t.Errorf("expected WFM for >100kHz signal") | |||
| } | |||
| } | |||
| func TestHardRulesAirband(t *testing.T) { | |||
| cls := TryHardRule(121.5e6, 8000) | |||
| if cls == nil || cls.ModType != ClassAM { | |||
| t.Fatalf("expected AM for airband, got %v", cls) | |||
| } | |||
| } | |||
| func TestHardRulesCW(t *testing.T) { | |||
| cls := TryHardRule(7.020e6, 100) | |||
| if cls == nil || cls.ModType != ClassCW { | |||
| t.Fatalf("expected CW for <500Hz, got %v", cls) | |||
| } | |||
| } | |||
| func TestWFMPilotDetection(t *testing.T) { | |||
| sampleRate := 192000 | |||
| n := sampleRate * 2 | |||
| iq := make([]complex64, n) | |||
| phase := 0.0 | |||
| for i := range iq { | |||
| pilot := math.Sin(2 * math.Pi * 19000 * float64(i) / float64(sampleRate)) | |||
| modulation := pilot * 0.1 | |||
| freqDev := modulation * 75000 / float64(sampleRate) | |||
| phase += 2 * math.Pi * freqDev | |||
| iq[i] = complex(float32(math.Cos(phase)), float32(math.Sin(phase))) | |||
| } | |||
| result := EstimateExactFrequency(iq, sampleRate, 102.1e6, ClassWFM) | |||
| if !result.Locked { | |||
| t.Fatal("PLL should lock on pilot") | |||
| } | |||
| if math.Abs(result.OffsetHz) > 5 { | |||
| t.Errorf("offset too large: %.1f Hz", result.OffsetHz) | |||
| } | |||
| } | |||
| @@ -0,0 +1,130 @@ | |||
| package classifier | |||
| import "math" | |||
| type PLLResult struct { | |||
| ExactHz float64 `json:"exact_hz"` | |||
| OffsetHz float64 `json:"offset_hz"` | |||
| Locked bool `json:"locked"` | |||
| Method string `json:"method"` | |||
| PrecisionHz float64 `json:"precision_hz"` | |||
| } | |||
| func EstimateExactFrequency(iq []complex64, sampleRate int, detectedHz float64, modType SignalClass) PLLResult { | |||
| if len(iq) < 256 { | |||
| return PLLResult{ExactHz: detectedHz} | |||
| } | |||
| switch modType { | |||
| case ClassWFM: | |||
| return estimateWFMPilot(iq, sampleRate, detectedHz) | |||
| case ClassAM: | |||
| return estimateAMCarrier(iq, sampleRate, detectedHz) | |||
| case ClassNFM: | |||
| return estimateNFMCarrier(iq, sampleRate, detectedHz) | |||
| case ClassCW: | |||
| return estimateCWTone(iq, sampleRate, detectedHz) | |||
| default: | |||
| return PLLResult{ExactHz: detectedHz, Method: "none"} | |||
| } | |||
| } | |||
| func estimateWFMPilot(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||
| demod := fmDemod(iq) | |||
| if len(demod) == 0 { | |||
| return PLLResult{ExactHz: detectedHz, Method: "pilot"} | |||
| } | |||
| pilotFreq := 19000.0 | |||
| bestFreq := pilotFreq | |||
| bestMag := goertzelMagnitude(demod, pilotFreq, sampleRate) | |||
| for offset := -50.0; offset <= 50.0; offset += 1.0 { | |||
| mag := goertzelMagnitude(demod, pilotFreq+offset, sampleRate) | |||
| if mag > bestMag { | |||
| bestMag = mag | |||
| bestFreq = pilotFreq + offset | |||
| } | |||
| } | |||
| freqError := bestFreq - 19000.0 | |||
| noiseMag := goertzelMagnitude(demod, 17500, sampleRate) | |||
| locked := bestMag > noiseMag*5 | |||
| if !locked { | |||
| return PLLResult{ExactHz: detectedHz, Method: "pilot", Locked: false} | |||
| } | |||
| return PLLResult{ExactHz: detectedHz - freqError, OffsetHz: -freqError, Locked: true, Method: "pilot", PrecisionHz: 1.0} | |||
| } | |||
| func estimateAMCarrier(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||
| offset := meanInstFreqHz(iq, sampleRate) | |||
| return PLLResult{ExactHz: detectedHz + offset, OffsetHz: offset, Locked: true, Method: "carrier", PrecisionHz: 5.0} | |||
| } | |||
| func estimateNFMCarrier(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||
| offset := meanInstFreqHz(iq, sampleRate) | |||
| return PLLResult{ExactHz: detectedHz + offset, OffsetHz: offset, Locked: math.Abs(offset) < 5000, Method: "fm_dc", PrecisionHz: 20.0} | |||
| } | |||
| func estimateCWTone(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||
| demod := fmDemod(iq) | |||
| if len(demod) == 0 { | |||
| return PLLResult{ExactHz: detectedHz, Method: "tone"} | |||
| } | |||
| bestFreq := 700.0 | |||
| bestMag := 0.0 | |||
| for f := 300.0; f <= 1200.0; f += 1.0 { | |||
| mag := goertzelMagnitude(demod, f, sampleRate) | |||
| if mag > bestMag { | |||
| bestMag = mag | |||
| bestFreq = f | |||
| } | |||
| } | |||
| bfoHz := 700.0 | |||
| toneOffset := bestFreq - bfoHz | |||
| return PLLResult{ExactHz: detectedHz + toneOffset, OffsetHz: toneOffset, Locked: bestMag > 0, Method: "tone", PrecisionHz: 2.0} | |||
| } | |||
| func fmDemod(iq []complex64) []float64 { | |||
| if len(iq) < 2 { | |||
| return nil | |||
| } | |||
| out := make([]float64, len(iq)-1) | |||
| for i := 1; i < len(iq); i++ { | |||
| p := iq[i-1] | |||
| c := iq[i] | |||
| num := float64(real(p))*float64(imag(c)) - float64(imag(p))*float64(real(c)) | |||
| den := float64(real(p))*float64(real(c)) + float64(imag(p))*float64(imag(c)) | |||
| out[i-1] = math.Atan2(num, den) | |||
| } | |||
| return out | |||
| } | |||
| func goertzelMagnitude(samples []float64, targetHz float64, sampleRate int) float64 { | |||
| n := len(samples) | |||
| if n == 0 { | |||
| return 0 | |||
| } | |||
| k := targetHz / (float64(sampleRate) / float64(n)) | |||
| w := 2.0 * math.Pi * k / float64(n) | |||
| coeff := 2.0 * math.Cos(w) | |||
| s1, s2 := 0.0, 0.0 | |||
| for _, v := range samples { | |||
| s0 := v + coeff*s1 - s2 | |||
| s2 = s1 | |||
| s1 = s0 | |||
| } | |||
| return math.Sqrt(s1*s1 + s2*s2 - coeff*s1*s2) | |||
| } | |||
| func meanInstFreqHz(iq []complex64, sampleRate int) float64 { | |||
| if len(iq) < 2 { | |||
| return 0 | |||
| } | |||
| var sum float64 | |||
| for i := 1; i < len(iq); i++ { | |||
| p := iq[i-1] | |||
| c := iq[i] | |||
| num := float64(real(p))*float64(imag(c)) - float64(imag(p))*float64(real(c)) | |||
| den := float64(real(p))*float64(real(c)) + float64(imag(p))*float64(imag(c)) | |||
| sum += math.Atan2(num, den) | |||
| } | |||
| meanRad := sum / float64(len(iq)-1) | |||
| return meanRad * float64(sampleRate) / (2.0 * math.Pi) | |||
| } | |||
| @@ -44,6 +44,7 @@ type Classification struct { | |||
| BW3dB float64 `json:"bw_3db_hz"` | |||
| Features Features `json:"features,omitempty"` | |||
| MathFeatures *MathFeatures `json:"math_features,omitempty"` | |||
| PLL *PLLResult `json:"pll,omitempty"` | |||
| SecondBest SignalClass `json:"second_best,omitempty"` | |||
| Scores map[SignalClass]float64 `json:"scores,omitempty"` | |||
| } | |||
| @@ -74,6 +74,7 @@ type Signal struct { | |||
| SNRDb float64 `json:"snr_db"` | |||
| NoiseDb float64 `json:"noise_db,omitempty"` | |||
| Class *classifier.Classification `json:"class,omitempty"` | |||
| PLL *classifier.PLLResult `json:"pll,omitempty"` | |||
| } | |||
| func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||