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