From 5b2eafc11aceb206b0d779759cdef64f2df5f414 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 19 Mar 2026 15:49:27 +0100 Subject: [PATCH] feat: improve classifier confidence and GPU RDS path --- internal/classifier/classifier_test.go | 19 +++++++++++++ internal/classifier/rules.go | 38 +++++++++++++++++++++----- internal/recorder/demod.go | 4 +-- internal/recorder/wfm_hybrid.go | 36 ++++++++++++++++++++---- 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/internal/classifier/classifier_test.go b/internal/classifier/classifier_test.go index f4439b1..afe036b 100644 --- a/internal/classifier/classifier_test.go +++ b/internal/classifier/classifier_test.go @@ -19,3 +19,22 @@ func TestRuleClassifyWFM(t *testing.T) { t.Fatalf("expected WFM, got %+v", cls) } } + +func TestSoftmaxConfidence(t *testing.T) { + scores1 := map[SignalClass]float64{ClassNFM: 2.0, ClassAM: 0.3, ClassNoise: 0.1} + c1 := softmaxConfidence(scores1, ClassNFM) + if c1 < 0.7 { + t.Fatalf("clear winner should have high confidence: %f", c1) + } + + scores2 := map[SignalClass]float64{ClassSSBUSB: 1.0, ClassSSBLSB: 0.9, ClassAM: 0.8} + c2 := softmaxConfidence(scores2, ClassSSBUSB) + if c2 > 0.5 { + t.Fatalf("ambiguous should have low confidence: %f", c2) + } + + c3 := softmaxConfidence(map[SignalClass]float64{}, ClassNFM) + if c3 != 0.1 { + t.Fatalf("empty should return 0.1: %f", c3) + } +} diff --git a/internal/classifier/rules.go b/internal/classifier/rules.go index 6af06e2..7e9b660 100644 --- a/internal/classifier/rules.go +++ b/internal/classifier/rules.go @@ -69,7 +69,7 @@ func RuleClassify(feat Features) Classification { add(ClassDMR, 0.7) } - best, bestScore, second, secondScore := top2(scores) + best, _, second, _ := top2(scores) if best == "" { best = ClassUnknown } @@ -77,11 +77,7 @@ func RuleClassify(feat Features) Classification { second = ClassUnknown } - conf := 0.3 - if best != ClassUnknown { - sum := bestScore + secondScore + 1e-6 - conf = 0.3 + 0.7*(bestScore/sum) - } + conf := softmaxConfidence(scores, best) if best == ClassNFM || best == ClassWFM { conf = conf * (0.8 + 0.2*clamp01(1-flat)) } @@ -89,7 +85,7 @@ func RuleClassify(feat Features) Classification { conf = conf * (0.7 + 0.3*clamp01(p2a/6.0)) } if math.IsNaN(conf) || conf <= 0 { - conf = 0.3 + conf = 0.1 } if (best == ClassSSBUSB || best == ClassSSBLSB) && second == ClassUnknown { @@ -110,6 +106,34 @@ func RuleClassify(feat Features) Classification { } } +func softmaxConfidence(scores map[SignalClass]float64, best SignalClass) float64 { + if len(scores) == 0 || best == "" || best == ClassUnknown { + return 0.1 + } + maxScore := math.Inf(-1) + for _, v := range scores { + if v > maxScore { + maxScore = v + } + } + if math.IsInf(maxScore, -1) { + return 0.1 + } + var expSum float64 + var expBest float64 + for k, v := range scores { + e := math.Exp(v - maxScore) + expSum += e + if k == best { + expBest = e + } + } + if expSum <= 0 { + return 0.1 + } + return expBest / expSum +} + func top2(scores map[SignalClass]float64) (SignalClass, float64, SignalClass, float64) { var best, second SignalClass bestScore := 0.0 diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go index 09eb06a..595464f 100644 --- a/internal/recorder/demod.go +++ b/internal/recorder/demod.go @@ -39,8 +39,8 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f var stereoHybrid *wfmHybridResult if audio == nil { if name == "WFM_STEREO" { - log.Printf("gpudemod: WFM_STEREO using CPU stereo/RDS post-process for event %d", ev.ID) - res := demodWFMStereoHybrid(iq, m.sampleRate, offset, bw) + log.Printf("gpudemod: WFM_STEREO using hybrid stereo/RDS post-process for event %d", ev.ID) + res := demodWFMStereoHybrid(m.gpuEngine(), iq, m.sampleRate, offset, bw) stereoHybrid = &res audio = res.Audio inputRate = res.AudioRate diff --git a/internal/recorder/wfm_hybrid.go b/internal/recorder/wfm_hybrid.go index c7f942e..36ddbab 100644 --- a/internal/recorder/wfm_hybrid.go +++ b/internal/recorder/wfm_hybrid.go @@ -1,6 +1,11 @@ package recorder -import "sdr-visual-suite/internal/demod" +import ( + "log" + + "sdr-visual-suite/internal/demod" + "sdr-visual-suite/internal/demod/gpudemod" +) type wfmHybridResult struct { Audio []float32 @@ -10,14 +15,35 @@ type wfmHybridResult struct { RDSRate int } -func demodWFMStereoHybrid(iq []complex64, sampleRate int, offset float64, bw float64) wfmHybridResult { +func demodWFMStereoHybrid(gpu *gpudemod.Engine, iq []complex64, sampleRate int, offset float64, bw float64) wfmHybridResult { audio, rate := demodAudioCPU(demod.Get("WFM_STEREO"), iq, sampleRate, offset, bw) - rds := demod.RDSBasebandDecimated(iq, sampleRate) + + var rdsSamples []float32 + var rdsRate int + if gpu != nil { + rdsIQ, gpuRate, err := gpu.ShiftFilterDecimate(iq, 57000, 4800, 4800) + if err == nil && len(rdsIQ) > 0 { + rdsSamples = make([]float32, len(rdsIQ)) + for i, v := range rdsIQ { + rdsSamples[i] = real(v) + } + rdsRate = gpuRate + log.Printf("gpudemod: GPU RDS extraction used (%d samples at %d Hz)", len(rdsSamples), rdsRate) + } else if err != nil { + log.Printf("gpudemod: GPU RDS extraction failed: %v - CPU fallback", err) + } + } + if rdsSamples == nil { + rds := demod.RDSBasebandDecimated(iq, sampleRate) + rdsSamples = rds.Samples + rdsRate = rds.SampleRate + } + return wfmHybridResult{ Audio: audio, AudioRate: rate, Channels: 2, - RDS: rds.Samples, - RDSRate: rds.SampleRate, + RDS: rdsSamples, + RDSRate: rdsRate, } }