| @@ -28,6 +28,16 @@ if (Test-Path $cudaInc) { $env:CGO_CFLAGS = "$env:CGO_CFLAGS -I$cudaInc" } | |||
| if (Test-Path $cudaBin) { $env:PATH = "$cudaBin;" + $env:PATH } | |||
| if (Test-Path $cudaMingw) { $env:CGO_LDFLAGS = "$env:CGO_LDFLAGS -L$cudaMingw -lcudart64_13 -lcufft64_12 -lkernel32" } | |||
| # Fix for GCC 15 / MSYS2: ensure system headers are found by CGO | |||
| $mingwSysInclude = 'C:\msys64\mingw64\include' | |||
| if (Test-Path $mingwSysInclude) { | |||
| $env:CGO_CFLAGS = "$env:CGO_CFLAGS -I$mingwSysInclude" | |||
| } | |||
| $mingwCrtInclude = 'C:\msys64\mingw64\x86_64-w64-mingw32\include' | |||
| if (Test-Path $mingwCrtInclude) { | |||
| $env:CGO_CFLAGS = "$env:CGO_CFLAGS -I$mingwCrtInclude" | |||
| } | |||
| Write-Host 'Building SDRplay + cuFFT app (Windows DLL path)...' -ForegroundColor Cyan | |||
| go build -tags "sdrplay,cufft" ./cmd/sdrd | |||
| if ($LASTEXITCODE -ne 0) { throw 'build failed' } | |||
| @@ -9,14 +9,17 @@ import ( | |||
| "runtime/debug" | |||
| "strings" | |||
| "sync" | |||
| "sync/atomic" | |||
| "time" | |||
| "sdr-visual-suite/internal/classifier" | |||
| "sdr-visual-suite/internal/config" | |||
| "sdr-visual-suite/internal/demod" | |||
| "sdr-visual-suite/internal/detector" | |||
| "sdr-visual-suite/internal/dsp" | |||
| fftutil "sdr-visual-suite/internal/fft" | |||
| "sdr-visual-suite/internal/fft/gpufft" | |||
| "sdr-visual-suite/internal/rds" | |||
| "sdr-visual-suite/internal/recorder" | |||
| ) | |||
| @@ -36,6 +39,16 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| iqEnabled := cfg.IQBalance | |||
| plan := fftutil.NewCmplxPlan(cfg.FFTSize) | |||
| useGPU := cfg.UseGPUFFT | |||
| // Persistent RDS decoders per signal — async ring-buffer based | |||
| type rdsState struct { | |||
| dec rds.Decoder | |||
| result rds.Result | |||
| lastDecode time.Time | |||
| busy int32 // atomic: 1 = goroutine running | |||
| mu sync.Mutex | |||
| } | |||
| rdsMap := map[int64]*rdsState{} | |||
| var gpuEngine *gpufft.Engine | |||
| if useGPU && gpuState != nil { | |||
| snap := gpuState.snapshot() | |||
| @@ -122,7 +135,18 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| dcBlocker.Reset() | |||
| ticker.Reset(cfg.FrameInterval()) | |||
| case <-ticker.C: | |||
| iq, err := srcMgr.ReadIQ(cfg.FFTSize) | |||
| // Read all available IQ data — not just one FFT block. | |||
| // This ensures the ring buffer captures 100% of IQ for recording/demod. | |||
| available := cfg.FFTSize | |||
| st := srcMgr.Stats() | |||
| if st.BufferSamples > cfg.FFTSize { | |||
| // Round down to multiple of FFTSize for clean processing | |||
| available = (st.BufferSamples / cfg.FFTSize) * cfg.FFTSize | |||
| if available < cfg.FFTSize { | |||
| available = cfg.FFTSize | |||
| } | |||
| } | |||
| allIQ, err := srcMgr.ReadIQ(available) | |||
| if err != nil { | |||
| log.Printf("read IQ: %v", err) | |||
| if strings.Contains(err.Error(), "timeout") { | |||
| @@ -132,8 +156,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| } | |||
| continue | |||
| } | |||
| // Ingest ALL IQ data into the ring buffer for recording | |||
| if rec != nil { | |||
| rec.Ingest(time.Now(), iq) | |||
| rec.Ingest(time.Now(), allIQ) | |||
| } | |||
| // Use only the last FFT block for spectrum display | |||
| iq := allIQ | |||
| if len(allIQ) > cfg.FFTSize { | |||
| iq = allIQ[len(allIQ)-cfg.FFTSize:] | |||
| } | |||
| if !gotSamples { | |||
| log.Printf("received IQ samples") | |||
| @@ -177,27 +207,121 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| thresholds := det.LastThresholds() | |||
| noiseFloor := det.LastNoiseFloor() | |||
| if len(iq) > 0 { | |||
| snips := extractSignalIQBatch(extractMgr, iq, cfg.SampleRate, cfg.CenterHz, signals) | |||
| snips, snipRates := extractSignalIQBatch(extractMgr, iq, cfg.SampleRate, cfg.CenterHz, signals) | |||
| for i := range signals { | |||
| var snip []complex64 | |||
| if i < len(snips) { | |||
| snip = snips[i] | |||
| } | |||
| 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)) | |||
| // Determine actual sample rate of the extracted snippet | |||
| snipRate := cfg.SampleRate | |||
| if i < len(snipRates) && snipRates[i] > 0 { | |||
| snipRate = snipRates[i] | |||
| } | |||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb, CenterHz: signals[i].CenterHz, BWHz: signals[i].BWHz}, 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) | |||
| pll := classifier.EstimateExactFrequency(snip, snipRate, signals[i].CenterHz, cls.ModType) | |||
| cls.PLL = &pll | |||
| signals[i].PLL = &pll | |||
| if pll.Locked { | |||
| signals[i].CenterHz = pll.ExactHz | |||
| // Upgrade WFM → WFM_STEREO if stereo pilot detected | |||
| if cls.ModType == classifier.ClassWFM && pll.Stereo { | |||
| cls.ModType = classifier.ClassWFMStereo | |||
| } | |||
| // RDS decode for WFM — async, uses ring buffer for continuous IQ | |||
| if (cls.ModType == classifier.ClassWFM || cls.ModType == classifier.ClassWFMStereo) && rec != nil { | |||
| key := int64(math.Round(signals[i].CenterHz / 500000)) | |||
| st := rdsMap[key] | |||
| if st == nil { | |||
| st = &rdsState{} | |||
| rdsMap[key] = st | |||
| } | |||
| // Launch async decode every 4 seconds, skip if previous still running | |||
| if now.Sub(st.lastDecode) >= 4*time.Second && atomic.LoadInt32(&st.busy) == 0 { | |||
| st.lastDecode = now | |||
| atomic.StoreInt32(&st.busy, 1) | |||
| go func(st *rdsState, sigHz float64) { | |||
| defer atomic.StoreInt32(&st.busy, 0) | |||
| ringIQ, ringSR, ringCenter := rec.SliceRecent(4.0) | |||
| if len(ringIQ) < ringSR || ringSR <= 0 { | |||
| return | |||
| } | |||
| // Shift FM station to center | |||
| offset := sigHz - ringCenter | |||
| shifted := dsp.FreqShift(ringIQ, ringSR, offset) | |||
| // Two-stage decimation to ~250kHz with proper anti-alias | |||
| // Stage 1: 4MHz → 1MHz (decim 4), LP at 400kHz | |||
| decim1 := ringSR / 1000000 | |||
| if decim1 < 1 { | |||
| decim1 = 1 | |||
| } | |||
| lp1 := dsp.LowpassFIR(float64(ringSR/decim1)/2.0*0.8, ringSR, 51) | |||
| f1 := dsp.ApplyFIR(shifted, lp1) | |||
| d1 := dsp.Decimate(f1, decim1) | |||
| rate1 := ringSR / decim1 | |||
| // Stage 2: 1MHz → 250kHz (decim 4), LP at 100kHz | |||
| decim2 := rate1 / 250000 | |||
| if decim2 < 1 { | |||
| decim2 = 1 | |||
| } | |||
| lp2 := dsp.LowpassFIR(float64(rate1/decim2)/2.0*0.8, rate1, 101) | |||
| f2 := dsp.ApplyFIR(d1, lp2) | |||
| decimated := dsp.Decimate(f2, decim2) | |||
| actualRate := rate1 / decim2 | |||
| // RDS baseband extraction on the clean decimated block | |||
| rdsBase := demod.RDSBasebandComplex(decimated, actualRate) | |||
| if len(rdsBase.Samples) == 0 { | |||
| return | |||
| } | |||
| st.mu.Lock() | |||
| result := st.dec.Decode(rdsBase.Samples, rdsBase.SampleRate) | |||
| diag := st.dec.LastDiag | |||
| if result.PS != "" { | |||
| st.result = result | |||
| } | |||
| st.mu.Unlock() | |||
| log.Printf("RDS TRACE: ring decode freq=%.1fMHz decIQ=%d decSR=%d bbLen=%d bbRate=%d PI=%04X PS=%q %s", | |||
| sigHz/1e6, len(decimated), actualRate, len(rdsBase.Samples), rdsBase.SampleRate, | |||
| result.PI, result.PS, diag) | |||
| if result.PS != "" { | |||
| log.Printf("RDS decoded: PI=%04X PS=%q RT=%q freq=%.1fMHz", result.PI, result.PS, result.RT, sigHz/1e6) | |||
| } | |||
| }(st, signals[i].CenterHz) | |||
| } | |||
| // Read last known result (lock-free for display) | |||
| st.mu.Lock() | |||
| ps := st.result.PS | |||
| st.mu.Unlock() | |||
| if ps != "" { | |||
| pll.RDSStation = strings.TrimSpace(ps) | |||
| cls.PLL = &pll | |||
| signals[i].PLL = &pll | |||
| } | |||
| } | |||
| } | |||
| } | |||
| det.UpdateClasses(signals) | |||
| // Cleanup RDS accumulators for signals that no longer exist | |||
| if len(rdsMap) > 0 { | |||
| activeIDs := make(map[int64]bool, len(signals)) | |||
| for _, s := range signals { | |||
| activeIDs[int64(math.Round(s.CenterHz / 500000))] = true | |||
| } | |||
| for id := range rdsMap { | |||
| if !activeIDs[id] { | |||
| delete(rdsMap, id) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // Use smoothed active events for frontend display (stable markers) | |||
| displaySignals := det.StableSignals() | |||
| if sigSnap != nil { | |||
| sigSnap.set(signals) | |||
| sigSnap.set(displaySignals) | |||
| } | |||
| eventMu.Lock() | |||
| for _, ev := range finished { | |||
| @@ -210,9 +334,9 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| rec.OnEvents(evCopy) | |||
| } | |||
| var debugInfo *SpectrumDebug | |||
| if len(thresholds) > 0 || len(signals) > 0 || noiseFloor != 0 { | |||
| scoreDebug := make([]map[string]any, 0, len(signals)) | |||
| for _, s := range signals { | |||
| if len(thresholds) > 0 || len(displaySignals) > 0 || noiseFloor != 0 { | |||
| scoreDebug := make([]map[string]any, 0, len(displaySignals)) | |||
| for _, s := range displaySignals { | |||
| if s.Class == nil || len(s.Class.Scores) == 0 { | |||
| scoreDebug = append(scoreDebug, map[string]any{"center_hz": s.CenterHz, "class": nil}) | |||
| continue | |||
| @@ -231,7 +355,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| } | |||
| debugInfo = &SpectrumDebug{Thresholds: thresholds, NoiseFloor: noiseFloor, Scores: scoreDebug} | |||
| } | |||
| h.broadcast(SpectrumFrame{Timestamp: now.UnixMilli(), CenterHz: cfg.CenterHz, SampleHz: cfg.SampleRate, FFTSize: cfg.FFTSize, Spectrum: spectrum, Signals: signals, Debug: debugInfo}) | |||
| h.broadcast(SpectrumFrame{Timestamp: now.UnixMilli(), CenterHz: cfg.CenterHz, SampleHz: cfg.SampleRate, FFTSize: cfg.FFTSize, Spectrum: spectrum, Signals: displaySignals, Debug: debugInfo}) | |||
| } | |||
| } | |||
| } | |||
| @@ -88,17 +88,18 @@ func extractSignalIQ(iq []complex64, sampleRate int, centerHz float64, sigHz flo | |||
| if len(iq) == 0 || sampleRate <= 0 { | |||
| return nil | |||
| } | |||
| results := extractSignalIQBatch(nil, iq, sampleRate, centerHz, []detector.Signal{{CenterHz: sigHz, BWHz: bwHz}}) | |||
| results, _ := extractSignalIQBatch(nil, iq, sampleRate, centerHz, []detector.Signal{{CenterHz: sigHz, BWHz: bwHz}}) | |||
| if len(results) == 0 { | |||
| return nil | |||
| } | |||
| return results[0] | |||
| } | |||
| func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleRate int, centerHz float64, signals []detector.Signal) [][]complex64 { | |||
| func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleRate int, centerHz float64, signals []detector.Signal) ([][]complex64, []int) { | |||
| out := make([][]complex64, len(signals)) | |||
| rates := make([]int, len(signals)) | |||
| if len(iq) == 0 || sampleRate <= 0 || len(signals) == 0 { | |||
| return out | |||
| return out, rates | |||
| } | |||
| decimTarget := 200000 | |||
| if decimTarget <= 0 { | |||
| @@ -109,24 +110,51 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR | |||
| if runner != nil { | |||
| jobs := make([]gpudemod.ExtractJob, len(signals)) | |||
| for i, sig := range signals { | |||
| jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: sig.BWHz, OutRate: decimTarget} | |||
| bw := sig.BWHz | |||
| // Minimum extraction BW: ensure enough bandwidth for demod features | |||
| // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo pilot + RDS at 57kHz | |||
| // Also widen for any signal classified as WFM (in case of re-extraction) | |||
| sigMHz := sig.CenterHz / 1e6 | |||
| isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | |||
| if isWFM { | |||
| if bw < 150000 { | |||
| bw = 150000 | |||
| } | |||
| } else if bw < 20000 { | |||
| bw = 20000 | |||
| } | |||
| jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: decimTarget} | |||
| } | |||
| if gpuOuts, _, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) { | |||
| log.Printf("gpudemod: batch extraction used for %d signals", len(signals)) | |||
| if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) { | |||
| // batch extraction OK (silent) | |||
| for i := range gpuOuts { | |||
| out[i] = gpuOuts[i] | |||
| if i < len(gpuRates) { | |||
| rates[i] = gpuRates[i] | |||
| } | |||
| } | |||
| return out | |||
| return out, rates | |||
| } else if err != nil { | |||
| log.Printf("gpudemod: batch extraction failed for %d signals: %v", len(signals), err) | |||
| } | |||
| } | |||
| log.Printf("gpudemod: CPU extraction fallback used for %d signals", len(signals)) | |||
| // CPU extraction fallback (silent — see batch extraction failed above if applicable) | |||
| for i, sig := range signals { | |||
| offset := sig.CenterHz - centerHz | |||
| shifted := dsp.FreqShift(iq, sampleRate, offset) | |||
| cutoff := sig.BWHz / 2 | |||
| bw := sig.BWHz | |||
| // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo + RDS | |||
| sigMHz := sig.CenterHz / 1e6 | |||
| isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | |||
| if isWFM { | |||
| if bw < 150000 { | |||
| bw = 150000 | |||
| } | |||
| } else if bw < 20000 { | |||
| bw = 20000 | |||
| } | |||
| cutoff := bw / 2 | |||
| if cutoff < 200 { | |||
| cutoff = 200 | |||
| } | |||
| @@ -140,8 +168,9 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR | |||
| decim = 1 | |||
| } | |||
| out[i] = dsp.Decimate(filtered, decim) | |||
| rates[i] = sampleRate / decim | |||
| } | |||
| return out | |||
| return out, rates | |||
| } | |||
| func parseSince(raw string) (time.Time, error) { | |||
| @@ -13,8 +13,16 @@ 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 { | |||
| // Use the wider of spectral BW3dB and detector's occupied BWHz for hard rules. | |||
| // BW3dB measures only the 3dB peak width which can be much narrower than the | |||
| // actual occupied bandwidth (e.g. FM broadcast has a peaked spectrum). | |||
| hardBW := feat.BW3dB | |||
| if input.BWHz > hardBW { | |||
| hardBW = input.BWHz | |||
| } | |||
| if hard := TryHardRule(input.CenterHz, hardBW); hard != nil { | |||
| hard.Features = feat | |||
| hard.BW3dB = hardBW | |||
| return hard | |||
| } | |||
| if len(iq) > 0 { | |||
| @@ -14,7 +14,7 @@ func TestRuleClassifyWFM(t *testing.T) { | |||
| for i := start; i <= end; i++ { | |||
| spectrum[i] = -10 | |||
| } | |||
| cls := Classify(SignalInput{FirstBin: start, LastBin: end, CenterHz: 100e6, SNRDb: 30}, spectrum, sampleRate, fftSize, nil) | |||
| cls := Classify(SignalInput{FirstBin: start, LastBin: end, CenterHz: 100e6, SNRDb: 30}, spectrum, sampleRate, fftSize, nil, ModeCombined) | |||
| if cls == nil || cls.ModType != ClassWFM { | |||
| t.Fatalf("expected WFM, got %+v", cls) | |||
| } | |||
| @@ -1,10 +1,10 @@ | |||
| { | |||
| "rules": [ | |||
| { | |||
| "name": "fm_broadcast", | |||
| "match": {"min_mhz": 87.5, "max_mhz": 108.0, "min_bw_hz": 50000}, | |||
| "name": "fm_broadcast_any", | |||
| "match": {"min_mhz": 87.5, "max_mhz": 108.0}, | |||
| "result": {"mod_type": "WFM", "confidence": 0.99}, | |||
| "note": "FM Broadcast: >50 kHz BW im UKW-Band ist immer WFM" | |||
| "note": "FM Broadcast band: ANY signal here is WFM, no BW check needed" | |||
| }, | |||
| { | |||
| "name": "airband_am", | |||
| @@ -44,9 +44,9 @@ | |||
| }, | |||
| { | |||
| "name": "wfm_wide_any", | |||
| "match": {"min_bw_hz": 100000}, | |||
| "match": {"min_bw_hz": 80000}, | |||
| "result": {"mod_type": "WFM", "confidence": 0.95}, | |||
| "note": "Über 100 kHz BW ist fast immer WFM, unabhängig vom Band" | |||
| "note": "Ueber 80 kHz BW ist fast immer WFM" | |||
| } | |||
| ] | |||
| } | |||
| @@ -8,6 +8,8 @@ type PLLResult struct { | |||
| Locked bool `json:"locked"` | |||
| Method string `json:"method"` | |||
| PrecisionHz float64 `json:"precision_hz"` | |||
| Stereo bool `json:"stereo,omitempty"` | |||
| RDSStation string `json:"rds_station,omitempty"` | |||
| } | |||
| func EstimateExactFrequency(iq []complex64, sampleRate int, detectedHz float64, modType SignalClass) PLLResult { | |||
| @@ -29,6 +31,9 @@ func EstimateExactFrequency(iq []complex64, sampleRate int, detectedHz float64, | |||
| } | |||
| func estimateWFMPilot(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||
| if sampleRate < 40000 { | |||
| return PLLResult{ExactHz: detectedHz, Method: "pilot", Locked: false} | |||
| } | |||
| demod := fmDemod(iq) | |||
| if len(demod) == 0 { | |||
| return PLLResult{ExactHz: detectedHz, Method: "pilot"} | |||
| @@ -49,12 +54,13 @@ func estimateWFMPilot(iq []complex64, sampleRate int, detectedHz float64) PLLRes | |||
| if !locked { | |||
| return PLLResult{ExactHz: detectedHz, Method: "pilot", Locked: false} | |||
| } | |||
| return PLLResult{ExactHz: detectedHz - freqError, OffsetHz: -freqError, Locked: true, Method: "pilot", PrecisionHz: 1.0} | |||
| return PLLResult{ExactHz: detectedHz - freqError, OffsetHz: -freqError, Locked: true, Method: "pilot", PrecisionHz: 1.0, Stereo: true} | |||
| } | |||
| 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} | |||
| locked := math.Abs(offset) < 5000 // Only lock if offset is plausible (<5 kHz) | |||
| return PLLResult{ExactHz: detectedHz + offset, OffsetHz: offset, Locked: locked, Method: "carrier", PrecisionHz: 5.0} | |||
| } | |||
| func estimateNFMCarrier(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||
| @@ -7,6 +7,7 @@ const ( | |||
| ClassAM SignalClass = "AM" | |||
| ClassNFM SignalClass = "NFM" | |||
| ClassWFM SignalClass = "WFM" | |||
| ClassWFMStereo SignalClass = "WFM_STEREO" | |||
| ClassSSBUSB SignalClass = "USB" | |||
| ClassSSBLSB SignalClass = "LSB" | |||
| ClassCW SignalClass = "CW" | |||
| @@ -55,4 +56,5 @@ type SignalInput struct { | |||
| LastBin int | |||
| SNRDb float64 | |||
| CenterHz float64 | |||
| BWHz float64 // Occupied bandwidth from detector (edge-expanded) | |||
| } | |||
| @@ -98,40 +98,44 @@ func RDSBaseband(iq []complex64, sampleRate int) []float32 { | |||
| return RDSBasebandDecimated(iq, sampleRate).Samples | |||
| } | |||
| // RDSBasebandDecimated returns the 57 kHz RDS baseband mixed to near-DC and decimated. | |||
| func RDSBasebandDecimated(iq []complex64, sampleRate int) RDSBasebandResult { | |||
| // RDSComplexResult holds complex baseband samples for the Costas loop RDS decoder. | |||
| type RDSComplexResult struct { | |||
| Samples []complex64 | |||
| SampleRate int | |||
| } | |||
| // RDSBasebandComplex extracts the RDS subcarrier as complex samples. | |||
| // The Costas loop in the RDS decoder needs both I and Q to lock. | |||
| func RDSBasebandComplex(iq []complex64, sampleRate int) RDSComplexResult { | |||
| base := wfmMonoBase(iq) | |||
| if len(base) == 0 || sampleRate <= 0 { | |||
| return RDSBasebandResult{} | |||
| } | |||
| bpHi := dsp.LowpassFIR(60000, sampleRate, 101) | |||
| bpLo := dsp.LowpassFIR(54000, sampleRate, 101) | |||
| hi := dsp.ApplyFIRReal(base, bpHi) | |||
| lo := dsp.ApplyFIRReal(base, bpLo) | |||
| bpf := make([]float32, len(base)) | |||
| for i := range base { | |||
| bpf[i] = hi[i] - lo[i] | |||
| return RDSComplexResult{} | |||
| } | |||
| phase := 0.0 | |||
| inc := 2 * math.Pi * 57000 / float64(sampleRate) | |||
| mixed := make([]float32, len(base)) | |||
| for i := range bpf { | |||
| phase += inc | |||
| mixed[i] = bpf[i] * float32(math.Cos(phase)) | |||
| cplx := make([]complex64, len(base)) | |||
| for i, v := range base { | |||
| cplx[i] = complex(v, 0) | |||
| } | |||
| lp := dsp.LowpassFIR(2400, sampleRate, 101) | |||
| filtered := dsp.ApplyFIRReal(mixed, lp) | |||
| targetRate := 4800 | |||
| cplx = dsp.FreqShift(cplx, sampleRate, -57000) | |||
| lpTaps := dsp.LowpassFIR(7500, sampleRate, 101) | |||
| cplx = dsp.ApplyFIR(cplx, lpTaps) | |||
| targetRate := 19000 | |||
| decim := sampleRate / targetRate | |||
| if decim < 1 { | |||
| decim = 1 | |||
| } | |||
| cplx = dsp.Decimate(cplx, decim) | |||
| actualRate := sampleRate / decim | |||
| out := make([]float32, 0, len(filtered)/decim+1) | |||
| for i := 0; i < len(filtered); i += decim { | |||
| out = append(out, filtered[i]) | |||
| return RDSComplexResult{Samples: cplx, SampleRate: actualRate} | |||
| } | |||
| // RDSBasebandDecimated returns float32 baseband for WAV writing / recorder. | |||
| func RDSBasebandDecimated(iq []complex64, sampleRate int) RDSBasebandResult { | |||
| res := RDSBasebandComplex(iq, sampleRate) | |||
| out := make([]float32, len(res.Samples)) | |||
| for i, c := range res.Samples { | |||
| out[i] = real(c) | |||
| } | |||
| return RDSBasebandResult{Samples: out, SampleRate: actualRate} | |||
| return RDSBasebandResult{Samples: out, SampleRate: res.SampleRate} | |||
| } | |||
| func deemphasis(x []float32, sampleRate int, tau float64) []float32 { | |||
| @@ -8,8 +8,9 @@ type batchSlot struct { | |||
| } | |||
| type BatchRunner struct { | |||
| eng *Engine | |||
| slots []batchSlot | |||
| eng *Engine | |||
| slots []batchSlot | |||
| slotBufs []slotBuffers | |||
| } | |||
| func NewBatchRunner(maxSamples int, sampleRate int) (*BatchRunner, error) { | |||
| @@ -24,6 +25,7 @@ func (r *BatchRunner) Close() { | |||
| if r == nil || r.eng == nil { | |||
| return | |||
| } | |||
| r.freeSlotBuffers() | |||
| r.eng.Close() | |||
| r.eng = nil | |||
| r.slots = nil | |||
| @@ -2,6 +2,13 @@ | |||
| package gpudemod | |||
| // slotBuffers stub for non-Windows platforms | |||
| type slotBuffers struct{} | |||
| func (r *BatchRunner) freeSlotBuffers() { | |||
| r.slotBufs = nil | |||
| } | |||
| func (r *BatchRunner) shiftFilterDecimateBatchImpl(iq []complex64) ([][]complex64, []int, error) { | |||
| outs := make([][]complex64, len(r.slots)) | |||
| rates := make([]int, len(r.slots)) | |||
| @@ -23,16 +23,7 @@ type slotBuffers struct { | |||
| stream streamHandle | |||
| } | |||
| type windowsBatchRunner struct { | |||
| *BatchRunner | |||
| slotBufs []slotBuffers | |||
| } | |||
| func asWindowsBatchRunner(r *BatchRunner) *windowsBatchRunner { | |||
| return (*windowsBatchRunner)(unsafe.Pointer(r)) | |||
| } | |||
| func (r *windowsBatchRunner) freeSlotBuffers() { | |||
| func (r *BatchRunner) freeSlotBuffers() { | |||
| for i := range r.slotBufs { | |||
| if r.slotBufs[i].dShifted != nil { | |||
| _ = bridgeCudaFree(r.slotBufs[i].dShifted) | |||
| @@ -58,7 +49,7 @@ func (r *windowsBatchRunner) freeSlotBuffers() { | |||
| r.slotBufs = nil | |||
| } | |||
| func (r *windowsBatchRunner) allocSlotBuffers(n int) error { | |||
| func (r *BatchRunner) allocSlotBuffers(n int) error { | |||
| if len(r.slotBufs) == len(r.slots) && len(r.slotBufs) > 0 { | |||
| return nil | |||
| } | |||
| @@ -91,7 +82,6 @@ func (r *windowsBatchRunner) allocSlotBuffers(n int) error { | |||
| } | |||
| func (r *BatchRunner) shiftFilterDecimateBatchImpl(iq []complex64) ([][]complex64, []int, error) { | |||
| wr := asWindowsBatchRunner(r) | |||
| e := r.eng | |||
| if e == nil || !e.cudaReady { | |||
| return nil, nil, ErrUnavailable | |||
| @@ -102,7 +92,7 @@ func (r *BatchRunner) shiftFilterDecimateBatchImpl(iq []complex64) ([][]complex6 | |||
| if n == 0 { | |||
| return outs, rates, nil | |||
| } | |||
| if err := wr.allocSlotBuffers(n); err != nil { | |||
| if err := r.allocSlotBuffers(n); err != nil { | |||
| return nil, nil, err | |||
| } | |||
| bytesIn := uintptr(n) * unsafe.Sizeof(complex64(0)) | |||
| @@ -113,7 +103,7 @@ func (r *BatchRunner) shiftFilterDecimateBatchImpl(iq []complex64) ([][]complex6 | |||
| if !r.slots[i].active { | |||
| continue | |||
| } | |||
| nOut, rate, err := r.shiftFilterDecimateSlotParallel(iq, r.slots[i].job, wr.slotBufs[i]) | |||
| nOut, rate, err := r.shiftFilterDecimateSlotParallel(iq, r.slots[i].job, r.slotBufs[i]) | |||
| if err != nil { | |||
| return nil, nil, err | |||
| } | |||
| @@ -125,7 +115,7 @@ func (r *BatchRunner) shiftFilterDecimateBatchImpl(iq []complex64) ([][]complex6 | |||
| if !r.slots[i].active { | |||
| continue | |||
| } | |||
| buf := wr.slotBufs[i] | |||
| buf := r.slotBufs[i] | |||
| if bridgeStreamSync(buf.stream) != 0 { | |||
| return nil, nil, errors.New("cuda stream sync failed") | |||
| } | |||
| @@ -170,6 +170,21 @@ GPUD_API int GPUD_CALL gpud_launch_fir_cuda( | |||
| return (int)cudaGetLastError(); | |||
| } | |||
| GPUD_API int GPUD_CALL gpud_launch_fir_stream_cuda( | |||
| const float2* in, | |||
| float2* out, | |||
| int n, | |||
| int num_taps, | |||
| gpud_stream_handle stream | |||
| ) { | |||
| if (n <= 0 || num_taps <= 0 || num_taps > 256) return 0; | |||
| const int block = 256; | |||
| const int grid = (n + block - 1) / block; | |||
| size_t sharedBytes = (size_t)(block + num_taps - 1) * sizeof(float2); | |||
| gpud_fir_kernel<<<grid, block, sharedBytes, (cudaStream_t)stream>>>(in, out, n, num_taps); | |||
| return (int)cudaGetLastError(); | |||
| } | |||
| __global__ void gpud_fir_kernel_v2( | |||
| const float2* __restrict__ in, | |||
| float2* __restrict__ out, | |||
| @@ -231,6 +246,20 @@ GPUD_API int GPUD_CALL gpud_launch_decimate_cuda( | |||
| return (int)cudaGetLastError(); | |||
| } | |||
| GPUD_API int GPUD_CALL gpud_launch_decimate_stream_cuda( | |||
| const float2* in, | |||
| float2* out, | |||
| int n_out, | |||
| int factor, | |||
| gpud_stream_handle stream | |||
| ) { | |||
| if (n_out <= 0 || factor <= 0) return 0; | |||
| const int block = 256; | |||
| const int grid = (n_out + block - 1) / block; | |||
| gpud_decimate_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(in, out, n_out, factor); | |||
| return (int)cudaGetLastError(); | |||
| } | |||
| __global__ void gpud_am_envelope_kernel( | |||
| const float2* __restrict__ in, | |||
| float* __restrict__ out, | |||
| @@ -47,6 +47,7 @@ type Detector struct { | |||
| cfarEngine cfar.CFAR | |||
| lastThresholds []float64 | |||
| lastNoiseFloor float64 | |||
| lastProcessTime time.Time | |||
| } | |||
| type activeEvent struct { | |||
| @@ -61,11 +62,13 @@ type activeEvent struct { | |||
| lastBin int | |||
| class *classifier.Classification | |||
| stableHits int | |||
| missedFrames int // Consecutive frames without a matching raw signal | |||
| classHistory []classifier.SignalClass | |||
| classIdx int | |||
| } | |||
| type Signal struct { | |||
| ID int64 `json:"id"` | |||
| FirstBin int `json:"first_bin"` | |||
| LastBin int `json:"last_bin"` | |||
| CenterHz float64 `json:"center_hz"` | |||
| @@ -182,8 +185,24 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||
| } | |||
| func (d *Detector) Process(now time.Time, spectrum []float64, centerHz float64) ([]Event, []Signal) { | |||
| signals := d.detectSignals(spectrum, centerHz) | |||
| finished := d.matchSignals(now, signals) | |||
| // Compute frame-rate adaptive alpha for consistent smoothing regardless of fps | |||
| dt := now.Sub(d.lastProcessTime).Seconds() | |||
| if d.lastProcessTime.IsZero() || dt <= 0 || dt > 1.0 { | |||
| dt = 1.0 / 15.0 | |||
| } | |||
| d.lastProcessTime = now | |||
| dtRef := 1.0 / 15.0 | |||
| ratio := dt / dtRef | |||
| adaptiveAlpha := 1.0 - math.Pow(1.0-d.EmaAlpha, ratio) | |||
| if adaptiveAlpha < 0.01 { | |||
| adaptiveAlpha = 0.01 | |||
| } | |||
| if adaptiveAlpha > 0.99 { | |||
| adaptiveAlpha = 0.99 | |||
| } | |||
| signals := d.detectSignals(spectrum, centerHz, adaptiveAlpha) | |||
| finished := d.matchSignals(now, signals, adaptiveAlpha) | |||
| return finished, signals | |||
| } | |||
| @@ -252,6 +271,10 @@ func (ev *activeEvent) updateClass(newCls *classifier.Classification, historySiz | |||
| ev.class.SecondBest = newCls.SecondBest | |||
| ev.class.Scores = newCls.Scores | |||
| } | |||
| // Always update PLL — RDS station name accumulates over time | |||
| if newCls.PLL != nil { | |||
| ev.class.PLL = newCls.PLL | |||
| } | |||
| } | |||
| func (d *Detector) UpdateClasses(signals []Signal) { | |||
| @@ -266,12 +289,40 @@ func (d *Detector) UpdateClasses(signals []Signal) { | |||
| } | |||
| } | |||
| func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal { | |||
| // StableSignals returns the smoothed active events as a Signal list for frontend display. | |||
| // Only events that have been seen for at least MinStableFrames are included. | |||
| // Output is sorted by CenterHz for consistent ordering across frames. | |||
| func (d *Detector) StableSignals() []Signal { | |||
| var out []Signal | |||
| for _, ev := range d.active { | |||
| if ev.stableHits < d.MinStableFrames { | |||
| continue | |||
| } | |||
| sig := Signal{ | |||
| ID: ev.id, | |||
| FirstBin: ev.firstBin, | |||
| LastBin: ev.lastBin, | |||
| CenterHz: ev.centerHz, | |||
| BWHz: ev.bwHz, | |||
| PeakDb: ev.peakDb, | |||
| SNRDb: ev.snrDb, | |||
| Class: ev.class, | |||
| } | |||
| if ev.class != nil && ev.class.PLL != nil { | |||
| sig.PLL = ev.class.PLL | |||
| } | |||
| out = append(out, sig) | |||
| } | |||
| sort.Slice(out, func(i, j int) bool { return out[i].CenterHz < out[j].CenterHz }) | |||
| return out | |||
| } | |||
| func (d *Detector) detectSignals(spectrum []float64, centerHz float64, adaptiveAlpha float64) []Signal { | |||
| n := len(spectrum) | |||
| if n == 0 { | |||
| return nil | |||
| } | |||
| smooth := d.smoothSpectrum(spectrum) | |||
| smooth := d.smoothSpectrum(spectrum, adaptiveAlpha) | |||
| var thresholds []float64 | |||
| if d.cfarEngine != nil { | |||
| thresholds = d.cfarEngine.Thresholds(smooth) | |||
| @@ -319,8 +370,8 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||
| } | |||
| signals = d.expandSignalEdges(signals, smooth, noiseGlobal, centerHz) | |||
| for i := range signals { | |||
| centerBin := float64(signals[i].FirstBin+signals[i].LastBin) / 2.0 | |||
| signals[i].CenterHz = d.centerFreqForBin(centerBin, centerHz) | |||
| // Use power-weighted centroid for accurate center frequency | |||
| signals[i].CenterHz = d.powerWeightedCenter(smooth, signals[i].FirstBin, signals[i].LastBin, centerHz) | |||
| signals[i].BWHz = float64(signals[i].LastBin-signals[i].FirstBin+1) * d.binWidth | |||
| } | |||
| return signals | |||
| @@ -387,8 +438,7 @@ func (d *Detector) expandSignalEdges(signals []Signal, smooth []float64, noiseFl | |||
| } | |||
| signals[i].FirstBin = newFirst | |||
| signals[i].LastBin = newLast | |||
| centerBin := float64(newFirst+newLast) / 2.0 | |||
| signals[i].CenterHz = d.centerFreqForBin(centerBin, centerHz) | |||
| // CenterHz will be recalculated with power-weighted centroid after expansion | |||
| signals[i].BWHz = float64(newLast-newFirst+1) * d.binWidth | |||
| } | |||
| signals = d.mergeOverlapping(signals, centerHz) | |||
| @@ -438,6 +488,28 @@ func (d *Detector) centerFreqForBin(bin float64, centerHz float64) float64 { | |||
| return centerHz + (bin-float64(d.nbins)/2.0)*d.binWidth | |||
| } | |||
| // powerWeightedCenter computes the power-weighted centroid frequency within [first, last]. | |||
| // This is more accurate than the midpoint because it accounts for asymmetric signal shapes. | |||
| func (d *Detector) powerWeightedCenter(spectrum []float64, first, last int, centerHz float64) float64 { | |||
| if first > last || first < 0 || last >= len(spectrum) { | |||
| centerBin := float64(first+last) / 2.0 | |||
| return d.centerFreqForBin(centerBin, centerHz) | |||
| } | |||
| // Convert dB to linear, compute weighted average bin | |||
| var sumPower, sumWeighted float64 | |||
| for i := first; i <= last; i++ { | |||
| p := math.Pow(10, spectrum[i]/10.0) | |||
| sumPower += p | |||
| sumWeighted += p * float64(i) | |||
| } | |||
| if sumPower <= 0 { | |||
| centerBin := float64(first+last) / 2.0 | |||
| return d.centerFreqForBin(centerBin, centerHz) | |||
| } | |||
| centroidBin := sumWeighted / sumPower | |||
| return d.centerFreqForBin(centroidBin, centerHz) | |||
| } | |||
| func minInRange(s []float64, from, to int) float64 { | |||
| if len(s) == 0 { | |||
| return 0 | |||
| @@ -496,8 +568,9 @@ func median(vals []float64) float64 { | |||
| } | |||
| func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64, spectrum []float64) Signal { | |||
| centerBin := float64(first+last) / 2.0 | |||
| centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth | |||
| // Use peak bin for center frequency — more accurate than midpoint of first/last | |||
| // because edge expansion can be asymmetric | |||
| centerFreq := centerHz + (float64(peakBin)-float64(d.nbins)/2.0)*d.binWidth | |||
| bw := float64(last-first+1) * d.binWidth | |||
| snr := peak - noise | |||
| return Signal{ | |||
| @@ -511,13 +584,12 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||
| } | |||
| } | |||
| func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | |||
| func (d *Detector) smoothSpectrum(spectrum []float64, alpha float64) []float64 { | |||
| if d.ema == nil || len(d.ema) != len(spectrum) { | |||
| d.ema = make([]float64, len(spectrum)) | |||
| copy(d.ema, spectrum) | |||
| return d.ema | |||
| } | |||
| alpha := d.EmaAlpha | |||
| for i := range spectrum { | |||
| v := spectrum[i] | |||
| d.ema[i] = alpha*v + (1-alpha)*d.ema[i] | |||
| @@ -525,68 +597,90 @@ func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | |||
| return d.ema | |||
| } | |||
| func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||
| func (d *Detector) matchSignals(now time.Time, signals []Signal, adaptiveAlpha float64) []Event { | |||
| used := make(map[int64]bool, len(d.active)) | |||
| for _, s := range signals { | |||
| var best *activeEvent | |||
| var candidates []struct { | |||
| ev *activeEvent | |||
| dist float64 | |||
| } | |||
| for _, ev := range d.active { | |||
| if overlapHz(s.CenterHz, s.BWHz, ev.centerHz, ev.bwHz) && math.Abs(s.CenterHz-ev.centerHz) < (s.BWHz+ev.bwHz)/2.0 { | |||
| candidates = append(candidates, struct { | |||
| ev *activeEvent | |||
| dist float64 | |||
| }{ev: ev, dist: math.Abs(s.CenterHz - ev.centerHz)}) | |||
| signalUsed := make([]bool, len(signals)) | |||
| smoothAlpha := adaptiveAlpha | |||
| // Sort active events by maturity (stableHits descending). | |||
| // Mature events match FIRST, preventing ghost/new events from stealing their signals. | |||
| // Without this, Go map iteration is random and a 1-frame-old ghost can steal | |||
| // a signal from a 1000-frame-old stable event. | |||
| type eventEntry struct { | |||
| id int64 | |||
| ev *activeEvent | |||
| } | |||
| sortedEvents := make([]eventEntry, 0, len(d.active)) | |||
| for id, ev := range d.active { | |||
| sortedEvents = append(sortedEvents, eventEntry{id, ev}) | |||
| } | |||
| sort.Slice(sortedEvents, func(i, j int) bool { | |||
| return sortedEvents[i].ev.stableHits > sortedEvents[j].ev.stableHits | |||
| }) | |||
| // Event-first matching: for each active event (mature first), find the closest unmatched raw signal. | |||
| for _, entry := range sortedEvents { | |||
| id, ev := entry.id, entry.ev | |||
| bestIdx := -1 | |||
| bestDist := math.MaxFloat64 | |||
| for i, s := range signals { | |||
| if signalUsed[i] { | |||
| continue | |||
| } | |||
| } | |||
| if len(candidates) > 0 { | |||
| sort.Slice(candidates, func(i, j int) bool { return candidates[i].dist < candidates[j].dist }) | |||
| best = candidates[0].ev | |||
| } | |||
| if best == nil { | |||
| id := d.nextID | |||
| d.nextID++ | |||
| d.active[id] = &activeEvent{ | |||
| id: id, | |||
| start: now, | |||
| lastSeen: now, | |||
| centerHz: s.CenterHz, | |||
| bwHz: s.BWHz, | |||
| peakDb: s.PeakDb, | |||
| snrDb: s.SNRDb, | |||
| firstBin: s.FirstBin, | |||
| lastBin: s.LastBin, | |||
| class: s.Class, | |||
| stableHits: 1, | |||
| // Use wider of raw and event BW for matching tolerance | |||
| matchBW := math.Max(s.BWHz, ev.bwHz) | |||
| if matchBW < 20000 { | |||
| matchBW = 20000 // Minimum 20 kHz matching window | |||
| } | |||
| dist := math.Abs(s.CenterHz - ev.centerHz) | |||
| if dist < matchBW && dist < bestDist { | |||
| bestIdx = i | |||
| bestDist = dist | |||
| } | |||
| } | |||
| if bestIdx < 0 { | |||
| continue | |||
| } | |||
| used[best.id] = true | |||
| best.lastSeen = now | |||
| best.stableHits++ | |||
| best.centerHz = (best.centerHz + s.CenterHz) / 2.0 | |||
| if best.bwHz <= 0 { | |||
| best.bwHz = s.BWHz | |||
| signalUsed[bestIdx] = true | |||
| used[id] = true | |||
| s := signals[bestIdx] | |||
| ev.lastSeen = now | |||
| ev.stableHits++ | |||
| ev.missedFrames = 0 // Reset miss counter on successful match | |||
| ev.centerHz = smoothAlpha*s.CenterHz + (1-smoothAlpha)*ev.centerHz | |||
| if ev.bwHz <= 0 { | |||
| ev.bwHz = s.BWHz | |||
| } else { | |||
| const alpha = 0.15 | |||
| best.bwHz = alpha*s.BWHz + (1-alpha)*best.bwHz | |||
| } | |||
| if s.PeakDb > best.peakDb { | |||
| best.peakDb = s.PeakDb | |||
| ev.bwHz = smoothAlpha*s.BWHz + (1-smoothAlpha)*ev.bwHz | |||
| } | |||
| if s.SNRDb > best.snrDb { | |||
| best.snrDb = s.SNRDb | |||
| ev.peakDb = smoothAlpha*s.PeakDb + (1-smoothAlpha)*ev.peakDb | |||
| ev.snrDb = smoothAlpha*s.SNRDb + (1-smoothAlpha)*ev.snrDb | |||
| ev.firstBin = int(math.Round(smoothAlpha*float64(s.FirstBin) + (1-smoothAlpha)*float64(ev.firstBin))) | |||
| ev.lastBin = int(math.Round(smoothAlpha*float64(s.LastBin) + (1-smoothAlpha)*float64(ev.lastBin))) | |||
| if s.Class != nil { | |||
| ev.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio) | |||
| } | |||
| if s.FirstBin < best.firstBin { | |||
| best.firstBin = s.FirstBin | |||
| } | |||
| // Create new events for unmatched raw signals | |||
| for i, s := range signals { | |||
| if signalUsed[i] { | |||
| continue | |||
| } | |||
| if s.LastBin > best.lastBin { | |||
| best.lastBin = s.LastBin | |||
| } | |||
| if s.Class != nil { | |||
| best.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio) | |||
| id := d.nextID | |||
| d.nextID++ | |||
| d.active[id] = &activeEvent{ | |||
| id: id, | |||
| start: now, | |||
| lastSeen: now, | |||
| centerHz: s.CenterHz, | |||
| bwHz: s.BWHz, | |||
| peakDb: s.PeakDb, | |||
| snrDb: s.SNRDb, | |||
| firstBin: s.FirstBin, | |||
| lastBin: s.LastBin, | |||
| class: s.Class, | |||
| stableHits: 1, | |||
| } | |||
| } | |||
| @@ -595,7 +689,20 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||
| if used[id] { | |||
| continue | |||
| } | |||
| if now.Sub(ev.lastSeen) < d.GapTolerance { | |||
| // Event was NOT matched this frame — increment miss counter | |||
| ev.missedFrames++ | |||
| // Proportional gap tolerance: mature events are much harder to kill. | |||
| // A new event (stableHits=3) dies after GapTolerance (e.g. 500ms). | |||
| // A mature event (stableHits=300, i.e. ~10 seconds) gets 10x GapTolerance. | |||
| // This prevents FM broadcast events from dying during brief CFAR dips. | |||
| maturityFactor := 1.0 + math.Log1p(float64(ev.stableHits)/10.0) | |||
| if maturityFactor > 20.0 { | |||
| maturityFactor = 20.0 // Cap at 20x base tolerance | |||
| } | |||
| effectiveTolerance := time.Duration(float64(d.GapTolerance) * maturityFactor) | |||
| if now.Sub(ev.lastSeen) < effectiveTolerance { | |||
| continue | |||
| } | |||
| duration := ev.lastSeen.Sub(ev.start) | |||
| @@ -1,12 +1,26 @@ | |||
| package rds | |||
| import "math" | |||
| import ( | |||
| "fmt" | |||
| "math" | |||
| ) | |||
| // Decoder performs a simple RDS baseband decode (BPSK, 1187.5 bps). | |||
| // Decoder performs RDS baseband decode with Costas loop carrier recovery | |||
| // and Mueller & Muller symbol timing synchronization. | |||
| type Decoder struct { | |||
| ps [8]rune | |||
| rt [64]rune | |||
| lastPI uint16 | |||
| // Costas loop state (persistent across calls) | |||
| costasPhase float64 | |||
| costasFreq float64 | |||
| // Symbol sync state | |||
| syncMu float64 | |||
| // Diagnostic counters | |||
| TotalDecodes int | |||
| BlockAHits int | |||
| GroupsFound int | |||
| LastDiag string | |||
| } | |||
| type Result struct { | |||
| @@ -15,56 +29,181 @@ type Result struct { | |||
| RT string `json:"rt"` | |||
| } | |||
| // Decode takes baseband samples at ~2400 Hz and attempts to extract PI/PS/RT. | |||
| // NOTE: lightweight decoder with CRC+block sync; not a full RDS implementation. | |||
| func (d *Decoder) Decode(base []float32, sampleRate int) Result { | |||
| if len(base) == 0 || sampleRate <= 0 { | |||
| return Result{} | |||
| // Decode takes complex baseband samples at ~20kHz and extracts RDS data. | |||
| func (d *Decoder) Decode(samples []complex64, sampleRate int) Result { | |||
| if len(samples) < 104 || sampleRate <= 0 { | |||
| return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} | |||
| } | |||
| // crude clock: 1187.5 bps | |||
| baud := 1187.5 | |||
| spb := float64(sampleRate) / baud | |||
| bits := make([]int, 0, int(float64(len(base))/spb)) | |||
| phase := 0.0 | |||
| for i := 0; i < len(base); i++ { | |||
| phase += 1.0 | |||
| if phase >= spb { | |||
| phase -= spb | |||
| if base[i] >= 0 { | |||
| bits = append(bits, 1) | |||
| } else { | |||
| bits = append(bits, 0) | |||
| } | |||
| d.TotalDecodes++ | |||
| sps := float64(sampleRate) / 1187.5 // samples per symbol | |||
| // === Mueller & Muller symbol timing recovery === | |||
| // Reset state each call — accumulated samples have phase gaps between frames | |||
| mu := sps / 2 | |||
| symbols := make([]complex64, 0, len(samples)/int(sps)+1) | |||
| var prev, prevDecision complex64 | |||
| for mu < float64(len(samples)-1) { | |||
| idx := int(mu) | |||
| frac := mu - float64(idx) | |||
| if idx+1 >= len(samples) { | |||
| break | |||
| } | |||
| samp := complex64(complex( | |||
| float64(real(samples[idx]))*(1-frac)+float64(real(samples[idx+1]))*frac, | |||
| float64(imag(samples[idx]))*(1-frac)+float64(imag(samples[idx+1]))*frac, | |||
| )) | |||
| var decision complex64 | |||
| if real(samp) > 0 { | |||
| decision = 1 | |||
| } else { | |||
| decision = -1 | |||
| } | |||
| if len(symbols) >= 2 { | |||
| errR := float64(real(decision)-real(prevDecision))*float64(real(prev)) - | |||
| float64(real(samp)-real(prev))*float64(real(prevDecision)) | |||
| mu += sps + 0.01*errR | |||
| } else { | |||
| mu += sps | |||
| } | |||
| prevDecision = decision | |||
| prev = samp | |||
| symbols = append(symbols, samp) | |||
| } | |||
| if len(bits) < 26*4 { | |||
| if len(symbols) < 26*4 { | |||
| d.LastDiag = fmt.Sprintf("too few symbols: %d", len(symbols)) | |||
| return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} | |||
| } | |||
| // search for block sync | |||
| // === Costas loop for fine frequency/phase synchronization === | |||
| // Reset each call — phase gaps between accumulated frames break continuity | |||
| alpha := 0.132 | |||
| beta := alpha * alpha / 4.0 | |||
| phase := 0.0 | |||
| freq := 0.0 | |||
| synced := make([]complex64, len(symbols)) | |||
| for i, s := range symbols { | |||
| // Multiply by exp(-j*phase) to de-rotate | |||
| cosP := float32(math.Cos(phase)) | |||
| sinP := float32(math.Sin(phase)) | |||
| synced[i] = complex( | |||
| real(s)*cosP+imag(s)*sinP, | |||
| imag(s)*cosP-real(s)*sinP, | |||
| ) | |||
| // BPSK phase error: sign(I) * Q | |||
| var err float64 | |||
| if real(synced[i]) > 0 { | |||
| err = float64(imag(synced[i])) | |||
| } else { | |||
| err = -float64(imag(synced[i])) | |||
| } | |||
| freq += beta * err | |||
| phase += freq + alpha*err | |||
| for phase > math.Pi { | |||
| phase -= 2 * math.Pi | |||
| } | |||
| for phase < -math.Pi { | |||
| phase += 2 * math.Pi | |||
| } | |||
| } | |||
| // state not persisted — samples have gaps | |||
| // Measure signal quality: average |I| and |Q| after Costas | |||
| var sumI, sumQ float64 | |||
| for _, s := range synced { | |||
| ri := float64(real(s)) | |||
| rq := float64(imag(s)) | |||
| if ri < 0 { ri = -ri } | |||
| if rq < 0 { rq = -rq } | |||
| sumI += ri | |||
| sumQ += rq | |||
| } | |||
| avgI := sumI / float64(len(synced)) | |||
| avgQ := sumQ / float64(len(synced)) | |||
| // === BPSK demodulation === | |||
| hardBits := make([]int, len(synced)) | |||
| for i, s := range synced { | |||
| if real(s) > 0 { | |||
| hardBits[i] = 1 | |||
| } else { | |||
| hardBits[i] = 0 | |||
| } | |||
| } | |||
| // === Differential decoding === | |||
| bits := make([]int, len(hardBits)-1) | |||
| for i := 1; i < len(hardBits); i++ { | |||
| bits[i-1] = hardBits[i] ^ hardBits[i-1] | |||
| } | |||
| // === Block sync + CRC decode (try both polarities) === | |||
| // Count block A CRC hits for diagnostics | |||
| blockAHits := 0 | |||
| for i := 0; i+26 <= len(bits); i++ { | |||
| if _, ok := decodeBlock(bits[i : i+26]); ok { | |||
| blockAHits++ | |||
| } | |||
| } | |||
| d.BlockAHits += blockAHits | |||
| found1 := d.tryDecode(bits) | |||
| invBits := make([]int, len(bits)) | |||
| for i, b := range bits { | |||
| invBits[i] = 1 - b | |||
| } | |||
| found2 := d.tryDecode(invBits) | |||
| if found1 || found2 { | |||
| d.GroupsFound++ | |||
| } | |||
| d.LastDiag = fmt.Sprintf("syms=%d sps=%.1f costasFreq=%.4f avgI=%.4f avgQ=%.4f blockAHits=%d groups=%d", | |||
| len(symbols), sps, freq, avgI, avgQ, blockAHits, d.GroupsFound) | |||
| return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} | |||
| } | |||
| func (d *Decoder) tryDecode(bits []int) bool { | |||
| found := false | |||
| for i := 0; i+26*4 <= len(bits); i++ { | |||
| bA, okA := decodeBlock(bits[i : i+26]) | |||
| if !okA || bA.offset != offA { | |||
| continue | |||
| } | |||
| bB, okB := decodeBlock(bits[i+26 : i+52]) | |||
| if !okB || bB.offset != offB { | |||
| continue | |||
| } | |||
| bC, okC := decodeBlock(bits[i+52 : i+78]) | |||
| bD, okD := decodeBlock(bits[i+78 : i+104]) | |||
| if !(okA && okB && okC && okD) { | |||
| if !okC || (bC.offset != offC && bC.offset != offCp) { | |||
| continue | |||
| } | |||
| if bA.offset != offA || bB.offset != offB || bC.offset != offC || bD.offset != offD { | |||
| bD, okD := decodeBlock(bits[i+78 : i+104]) | |||
| if !okD || bD.offset != offD { | |||
| continue | |||
| } | |||
| found = true | |||
| pi := bA.data | |||
| if pi != 0 { | |||
| d.lastPI = pi | |||
| } | |||
| groupType := (bB.data >> 12) & 0xF | |||
| versionA := ((bB.data >> 11) & 0x1) == 0 | |||
| if groupType == 0 && versionA { | |||
| if groupType == 0 { | |||
| addr := bB.data & 0x3 | |||
| chars := []byte{byte(bD.data >> 8), byte(bD.data & 0xFF)} | |||
| idx := int(addr) * 2 | |||
| if idx+1 < len(d.ps) { | |||
| d.ps[idx] = sanitizeRune(chars[0]) | |||
| d.ps[idx+1] = sanitizeRune(chars[1]) | |||
| if versionA { | |||
| chars := []byte{byte(bD.data >> 8), byte(bD.data & 0xFF)} | |||
| idx := int(addr) * 2 | |||
| if idx+1 < len(d.ps) { | |||
| d.ps[idx] = sanitizeRune(chars[0]) | |||
| d.ps[idx+1] = sanitizeRune(chars[1]) | |||
| } | |||
| } | |||
| } | |||
| if groupType == 2 && versionA { | |||
| @@ -75,9 +214,9 @@ func (d *Decoder) Decode(base []float32, sampleRate int) Result { | |||
| d.rt[idx+j] = sanitizeRune(chars[j]) | |||
| } | |||
| } | |||
| break | |||
| i += 103 | |||
| } | |||
| return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} | |||
| return found | |||
| } | |||
| type block struct { | |||
| @@ -86,10 +225,11 @@ type block struct { | |||
| } | |||
| const ( | |||
| offA uint16 = 0x0FC | |||
| offB uint16 = 0x198 | |||
| offC uint16 = 0x168 | |||
| offD uint16 = 0x1B4 | |||
| offA uint16 = 0x0FC | |||
| offB uint16 = 0x198 | |||
| offC uint16 = 0x168 | |||
| offCp uint16 = 0x350 | |||
| offD uint16 = 0x1B4 | |||
| ) | |||
| func decodeBlock(bits []int) (block, bool) { | |||
| @@ -103,17 +243,16 @@ func decodeBlock(bits []int) (block, bool) { | |||
| data := uint16(raw >> 10) | |||
| synd := crcSyndrome(raw) | |||
| switch synd { | |||
| case offA, offB, offC, offD: | |||
| return block{data: data, offset: uint16(synd)}, true | |||
| case offA, offB, offC, offCp, offD: | |||
| return block{data: data, offset: synd}, true | |||
| default: | |||
| return block{}, false | |||
| } | |||
| } | |||
| func crcSyndrome(raw uint32) uint16 { | |||
| // polynomial 0x1B9 (10-bit) | |||
| var reg uint32 = raw | |||
| poly := uint32(0x1B9) | |||
| reg := raw | |||
| for i := 25; i >= 10; i-- { | |||
| if (reg>>uint(i))&1 == 1 { | |||
| reg ^= poly << uint(i-10) | |||
| @@ -158,14 +297,3 @@ func trimRight(in []rune) string { | |||
| } | |||
| return string(in[:end]) | |||
| } | |||
| // BPSKCostas returns a simple carrier-locked version of baseband (placeholder). | |||
| func BPSKCostas(in []float32) []float32 { | |||
| out := make([]float32, len(in)) | |||
| var phase float64 | |||
| for i, v := range in { | |||
| phase += 0.0001 * float64(v) * math.Sin(phase) | |||
| out[i] = float32(float64(v) * math.Cos(phase)) | |||
| } | |||
| return out | |||
| } | |||
| @@ -13,13 +13,65 @@ func demodAudioCPU(d demod.Demodulator, iq []complex64, sampleRate int, offset f | |||
| if cutoff < 200 { | |||
| cutoff = 200 | |||
| } | |||
| // For WFM, ensure we capture the full FM composite (at least 75 kHz) | |||
| if cutoff < 75000 && d.OutputSampleRate() >= 192000 { | |||
| cutoff = 75000 | |||
| } | |||
| taps := dsp.LowpassFIR(cutoff, sampleRate, 101) | |||
| filtered := dsp.ApplyFIR(shifted, taps) | |||
| decim := int(math.Round(float64(sampleRate) / float64(d.OutputSampleRate()))) | |||
| if decim < 1 { | |||
| decim = 1 | |||
| // First decimation: get to a demod-friendly rate | |||
| demodRate := d.OutputSampleRate() | |||
| decim1 := int(math.Round(float64(sampleRate) / float64(demodRate))) | |||
| if decim1 < 1 { | |||
| decim1 = 1 | |||
| } | |||
| dec := dsp.Decimate(filtered, decim1) | |||
| actualDemodRate := sampleRate / decim1 | |||
| // Demodulate at the intermediate rate | |||
| audio := d.Demod(dec, actualDemodRate) | |||
| // Second decimation: resample to exactly 48 kHz for browser playback | |||
| const outputRate = 48000 | |||
| if actualDemodRate > outputRate { | |||
| // Anti-alias low-pass before decimation | |||
| aaTaps := dsp.LowpassFIR(float64(outputRate)/2.0*0.9, actualDemodRate, 63) | |||
| channels := d.Channels() | |||
| if channels > 1 { | |||
| // For stereo: de-interleave, filter, decimate, re-interleave | |||
| nFrames := len(audio) / channels | |||
| left := make([]float32, nFrames) | |||
| right := make([]float32, nFrames) | |||
| for i := 0; i < nFrames; i++ { | |||
| left[i] = audio[i*2] | |||
| right[i] = audio[i*2+1] | |||
| } | |||
| left = dsp.ApplyFIRReal(left, aaTaps) | |||
| right = dsp.ApplyFIRReal(right, aaTaps) | |||
| decim2 := int(math.Round(float64(actualDemodRate) / float64(outputRate))) | |||
| if decim2 < 1 { | |||
| decim2 = 1 | |||
| } | |||
| outFrames := nFrames / decim2 | |||
| resampled := make([]float32, outFrames*2) | |||
| for i := 0; i < outFrames; i++ { | |||
| resampled[i*2] = left[i*decim2] | |||
| resampled[i*2+1] = right[i*decim2] | |||
| } | |||
| return resampled, actualDemodRate / decim2 | |||
| } | |||
| audio = dsp.ApplyFIRReal(audio, aaTaps) | |||
| decim2 := int(math.Round(float64(actualDemodRate) / float64(outputRate))) | |||
| if decim2 < 1 { | |||
| decim2 = 1 | |||
| } | |||
| resampled := make([]float32, 0, len(audio)/decim2+1) | |||
| for i := 0; i < len(audio); i += decim2 { | |||
| resampled = append(resampled, audio[i]) | |||
| } | |||
| return resampled, actualDemodRate / decim2 | |||
| } | |||
| dec := dsp.Decimate(filtered, decim) | |||
| inputRate := sampleRate / decim | |||
| return d.Demod(dec, inputRate), inputRate | |||
| return audio, actualDemodRate | |||
| } | |||
| @@ -64,7 +64,7 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f | |||
| files["rds_baseband"] = "rds.wav" | |||
| files["rds_sample_rate"] = stereoHybrid.RDSRate | |||
| dec := rdsdecoder{} | |||
| res := dec.Decode(stereoHybrid.RDS, stereoHybrid.RDSRate) | |||
| res := dec.DecodeFloat32(stereoHybrid.RDS, stereoHybrid.RDSRate) | |||
| if res.PI != 0 { | |||
| files["rds_pi"] = res.PI | |||
| } | |||
| @@ -4,6 +4,7 @@ import ( | |||
| "bytes" | |||
| "errors" | |||
| "log" | |||
| "math" | |||
| "time" | |||
| "sdr-visual-suite/internal/demod" | |||
| @@ -23,6 +24,10 @@ func (m *Manager) DemodLive(centerHz float64, bw float64, mode string, seconds i | |||
| if len(segment) == 0 { | |||
| return nil, 0, errors.New("no iq in ring") | |||
| } | |||
| actualDuration := float64(len(segment)) / float64(m.sampleRate) | |||
| if actualDuration < float64(seconds)*0.8 { | |||
| log.Printf("DEMOD WARNING: requested %ds but ring only has %.2fs of IQ data (ring may be underfilled due to sample drops)", seconds, actualDuration) | |||
| } | |||
| name := mode | |||
| if name == "" { | |||
| name = "NFM" | |||
| @@ -61,8 +66,50 @@ func (m *Manager) DemodLive(centerHz float64, bw float64, mode string, seconds i | |||
| } | |||
| audio, inputRate = demodAudioCPU(d, segment, m.sampleRate, offset, bw) | |||
| } | |||
| log.Printf("DEMOD DIAG: mode=%s iqSamples=%d sampleRate=%d audioSamples=%d inputRate=%d bw=%.0f offset=%.0f", | |||
| name, len(segment), m.sampleRate, len(audio), inputRate, bw, offset) | |||
| // Resample to 48 kHz for browser-compatible playback. | |||
| const browserRate = 48000 | |||
| channels := d.Channels() | |||
| if inputRate > browserRate && len(audio) > 0 { | |||
| decim := int(math.Round(float64(inputRate) / float64(browserRate))) | |||
| if decim < 1 { | |||
| decim = 1 | |||
| } | |||
| if channels > 1 { | |||
| nFrames := len(audio) / channels | |||
| outFrames := nFrames / decim | |||
| if outFrames < 1 { | |||
| outFrames = 1 | |||
| } | |||
| resampled := make([]float32, outFrames*channels) | |||
| for i := 0; i < outFrames; i++ { | |||
| srcIdx := i * decim * channels | |||
| for ch := 0; ch < channels; ch++ { | |||
| if srcIdx+ch < len(audio) { | |||
| resampled[i*channels+ch] = audio[srcIdx+ch] | |||
| } | |||
| } | |||
| } | |||
| audio = resampled | |||
| } else { | |||
| resampled := make([]float32, 0, len(audio)/decim+1) | |||
| for i := 0; i < len(audio); i += decim { | |||
| resampled = append(resampled, audio[i]) | |||
| } | |||
| audio = resampled | |||
| } | |||
| inputRate = inputRate / decim | |||
| } | |||
| log.Printf("DEMOD DIAG: after resample audioSamples=%d finalRate=%d duration=%.2fs", | |||
| len(audio), inputRate, float64(len(audio))/float64(inputRate)/float64(channels)) | |||
| // Use actual sample rate for WAV — don't lie about rate | |||
| buf := &bytes.Buffer{} | |||
| if err := writeWAVTo(buf, audio, inputRate, d.Channels()); err != nil { | |||
| if err := writeWAVTo(buf, audio, inputRate, channels); err != nil { | |||
| return nil, 0, err | |||
| } | |||
| return buf.Bytes(), inputRate, nil | |||
| @@ -11,13 +11,13 @@ func tryGPUAudio(gpu *gpudemod.Engine, label string, iq []complex64, offset floa | |||
| return nil, 0, false | |||
| } | |||
| if gpuAudio, gpuRate, err := gpu.DemodFused(iq, offset, bw, gpuMode); err == nil { | |||
| log.Printf("gpudemod: fused GPU demod used (%s)", label) | |||
| // fused GPU demod OK | |||
| return gpuAudio, gpuRate, true | |||
| } else { | |||
| log.Printf("gpudemod: fused GPU demod failed (%s): %v", label, err) | |||
| } | |||
| if gpuAudio, gpuRate, err := gpu.Demod(iq, offset, bw, gpuMode); err == nil { | |||
| log.Printf("gpudemod: legacy GPU demod used (%s)", label) | |||
| // legacy GPU demod OK | |||
| return gpuAudio, gpuRate, true | |||
| } else { | |||
| log.Printf("gpudemod: legacy GPU demod failed (%s): %v", label, err) | |||
| @@ -3,3 +3,12 @@ package recorder | |||
| import "sdr-visual-suite/internal/rds" | |||
| type rdsdecoder struct{ rds.Decoder } | |||
| // DecodeFloat32 wraps Decode for float32 input (converts to complex64) | |||
| func (d *rdsdecoder) DecodeFloat32(samples []float32, sampleRate int) rds.Result { | |||
| cplx := make([]complex64, len(samples)) | |||
| for i, v := range samples { | |||
| cplx[i] = complex(v, 0) | |||
| } | |||
| return d.Decode(cplx, sampleRate) | |||
| } | |||
| @@ -62,16 +62,22 @@ func (m *Manager) Update(sampleRate int, blockSize int, policy Policy, centerHz | |||
| m.mu.Lock() | |||
| defer m.mu.Unlock() | |||
| m.policy = policy | |||
| m.sampleRate = sampleRate | |||
| m.blockSize = blockSize | |||
| m.centerHz = centerHz | |||
| m.decodeCommands = decodeCommands | |||
| m.initGPUDemodLocked(sampleRate, blockSize) | |||
| if m.ring == nil { | |||
| // Only reset ring and GPU engine if sample parameters actually changed | |||
| needRingReset := m.sampleRate != sampleRate || m.blockSize != blockSize | |||
| m.sampleRate = sampleRate | |||
| m.blockSize = blockSize | |||
| if needRingReset { | |||
| m.initGPUDemodLocked(sampleRate, blockSize) | |||
| if m.ring == nil { | |||
| m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | |||
| } else { | |||
| m.ring.Reset(sampleRate, blockSize, policy.RingSeconds) | |||
| } | |||
| } else if m.ring == nil { | |||
| m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | |||
| return | |||
| } | |||
| m.ring.Reset(sampleRate, blockSize, policy.RingSeconds) | |||
| } | |||
| func (m *Manager) Ingest(t0 time.Time, samples []complex64) { | |||
| @@ -240,3 +246,23 @@ func (m *Manager) recordEvent(ev detector.Event) error { | |||
| _ = centerHz | |||
| return writeMeta(dir, ev, sampleRate, files) | |||
| } | |||
| // SliceRecent returns the most recent `seconds` of raw IQ from the ring buffer. | |||
| // Returns the IQ samples, sample rate, and center frequency. | |||
| func (m *Manager) SliceRecent(seconds float64) ([]complex64, int, float64) { | |||
| if m == nil { | |||
| return nil, 0, 0 | |||
| } | |||
| m.mu.RLock() | |||
| ring := m.ring | |||
| sr := m.sampleRate | |||
| center := m.centerHz | |||
| m.mu.RUnlock() | |||
| if ring == nil || sr <= 0 { | |||
| return nil, 0, 0 | |||
| } | |||
| end := time.Now() | |||
| start := end.Add(-time.Duration(seconds * float64(time.Second))) | |||
| iq := ring.Slice(start, end) | |||
| return iq, sr, center | |||
| } | |||