| @@ -19,3 +19,22 @@ func TestRuleClassifyWFM(t *testing.T) { | |||||
| t.Fatalf("expected WFM, got %+v", cls) | 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) | |||||
| } | |||||
| } | |||||
| @@ -69,7 +69,7 @@ func RuleClassify(feat Features) Classification { | |||||
| add(ClassDMR, 0.7) | add(ClassDMR, 0.7) | ||||
| } | } | ||||
| best, bestScore, second, secondScore := top2(scores) | |||||
| best, _, second, _ := top2(scores) | |||||
| if best == "" { | if best == "" { | ||||
| best = ClassUnknown | best = ClassUnknown | ||||
| } | } | ||||
| @@ -77,11 +77,7 @@ func RuleClassify(feat Features) Classification { | |||||
| second = ClassUnknown | 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 { | if best == ClassNFM || best == ClassWFM { | ||||
| conf = conf * (0.8 + 0.2*clamp01(1-flat)) | 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)) | conf = conf * (0.7 + 0.3*clamp01(p2a/6.0)) | ||||
| } | } | ||||
| if math.IsNaN(conf) || conf <= 0 { | if math.IsNaN(conf) || conf <= 0 { | ||||
| conf = 0.3 | |||||
| conf = 0.1 | |||||
| } | } | ||||
| if (best == ClassSSBUSB || best == ClassSSBLSB) && second == ClassUnknown { | 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) { | func top2(scores map[SignalClass]float64) (SignalClass, float64, SignalClass, float64) { | ||||
| var best, second SignalClass | var best, second SignalClass | ||||
| bestScore := 0.0 | bestScore := 0.0 | ||||
| @@ -39,8 +39,8 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f | |||||
| var stereoHybrid *wfmHybridResult | var stereoHybrid *wfmHybridResult | ||||
| if audio == nil { | if audio == nil { | ||||
| if name == "WFM_STEREO" { | 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 | stereoHybrid = &res | ||||
| audio = res.Audio | audio = res.Audio | ||||
| inputRate = res.AudioRate | inputRate = res.AudioRate | ||||
| @@ -1,6 +1,11 @@ | |||||
| package recorder | package recorder | ||||
| import "sdr-visual-suite/internal/demod" | |||||
| import ( | |||||
| "log" | |||||
| "sdr-visual-suite/internal/demod" | |||||
| "sdr-visual-suite/internal/demod/gpudemod" | |||||
| ) | |||||
| type wfmHybridResult struct { | type wfmHybridResult struct { | ||||
| Audio []float32 | Audio []float32 | ||||
| @@ -10,14 +15,35 @@ type wfmHybridResult struct { | |||||
| RDSRate int | 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) | 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{ | return wfmHybridResult{ | ||||
| Audio: audio, | Audio: audio, | ||||
| AudioRate: rate, | AudioRate: rate, | ||||
| Channels: 2, | Channels: 2, | ||||
| RDS: rds.Samples, | |||||
| RDSRate: rds.SampleRate, | |||||
| RDS: rdsSamples, | |||||
| RDSRate: rdsRate, | |||||
| } | } | ||||
| } | } | ||||