diff --git a/build-sdrplay.ps1 b/build-sdrplay.ps1 index f580735..5f5e2bb 100644 --- a/build-sdrplay.ps1 +++ b/build-sdrplay.ps1 @@ -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' } diff --git a/cmd/sdrd/dsp_loop.go b/cmd/sdrd/dsp_loop.go index 892dfb9..450ed44 100644 --- a/cmd/sdrd/dsp_loop.go +++ b/cmd/sdrd/dsp_loop.go @@ -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}) } } } diff --git a/cmd/sdrd/helpers.go b/cmd/sdrd/helpers.go index f5c15c5..2693272 100644 --- a/cmd/sdrd/helpers.go +++ b/cmd/sdrd/helpers.go @@ -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) { diff --git a/internal/classifier/classifier.go b/internal/classifier/classifier.go index 8307d02..311db70 100644 --- a/internal/classifier/classifier.go +++ b/internal/classifier/classifier.go @@ -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 { diff --git a/internal/classifier/classifier_test.go b/internal/classifier/classifier_test.go index 679c3f4..f06aa77 100644 --- a/internal/classifier/classifier_test.go +++ b/internal/classifier/classifier_test.go @@ -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) } diff --git a/internal/classifier/hard_rules.json b/internal/classifier/hard_rules.json index 1a69e14..4b56a69 100644 --- a/internal/classifier/hard_rules.json +++ b/internal/classifier/hard_rules.json @@ -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" } ] } diff --git a/internal/classifier/pll.go b/internal/classifier/pll.go index 11d8934..acc3234 100644 --- a/internal/classifier/pll.go +++ b/internal/classifier/pll.go @@ -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 { diff --git a/internal/classifier/types.go b/internal/classifier/types.go index d58e616..ccdc5e6 100644 --- a/internal/classifier/types.go +++ b/internal/classifier/types.go @@ -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) } diff --git a/internal/demod/fm.go b/internal/demod/fm.go index e5d7b18..18d1be1 100644 --- a/internal/demod/fm.go +++ b/internal/demod/fm.go @@ -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 { diff --git a/internal/demod/gpudemod/batch_runner.go b/internal/demod/gpudemod/batch_runner.go index ef84f47..02b2aff 100644 --- a/internal/demod/gpudemod/batch_runner.go +++ b/internal/demod/gpudemod/batch_runner.go @@ -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 diff --git a/internal/demod/gpudemod/batch_runner_other.go b/internal/demod/gpudemod/batch_runner_other.go index 64188e3..3ba7872 100644 --- a/internal/demod/gpudemod/batch_runner_other.go +++ b/internal/demod/gpudemod/batch_runner_other.go @@ -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)) diff --git a/internal/demod/gpudemod/batch_runner_windows.go b/internal/demod/gpudemod/batch_runner_windows.go index 98299db..a7ed004 100644 --- a/internal/demod/gpudemod/batch_runner_windows.go +++ b/internal/demod/gpudemod/batch_runner_windows.go @@ -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") } diff --git a/internal/demod/gpudemod/build/gpudemod_kernels.exp b/internal/demod/gpudemod/build/gpudemod_kernels.exp index 0239b60..d7039cb 100644 Binary files a/internal/demod/gpudemod/build/gpudemod_kernels.exp and b/internal/demod/gpudemod/build/gpudemod_kernels.exp differ diff --git a/internal/demod/gpudemod/build/gpudemod_kernels.lib b/internal/demod/gpudemod/build/gpudemod_kernels.lib index 2bed4d2..dccfca0 100644 Binary files a/internal/demod/gpudemod/build/gpudemod_kernels.lib and b/internal/demod/gpudemod/build/gpudemod_kernels.lib differ diff --git a/internal/demod/gpudemod/native/exports.cu b/internal/demod/gpudemod/native/exports.cu index 3a7e88b..97118c8 100644 --- a/internal/demod/gpudemod/native/exports.cu +++ b/internal/demod/gpudemod/native/exports.cu @@ -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<<>>(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<<>>(in, out, n_out, factor); + return (int)cudaGetLastError(); +} + __global__ void gpud_am_envelope_kernel( const float2* __restrict__ in, float* __restrict__ out, diff --git a/internal/detector/detector.go b/internal/detector/detector.go index a483423..c1a2a3f 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -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) diff --git a/internal/rds/rds.go b/internal/rds/rds.go index 02236dc..8f56d31 100644 --- a/internal/rds/rds.go +++ b/internal/rds/rds.go @@ -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 -} diff --git a/internal/recorder/cpu_audio.go b/internal/recorder/cpu_audio.go index f8802fe..8d07f47 100644 --- a/internal/recorder/cpu_audio.go +++ b/internal/recorder/cpu_audio.go @@ -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 } diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go index 595464f..f354a6a 100644 --- a/internal/recorder/demod.go +++ b/internal/recorder/demod.go @@ -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 } diff --git a/internal/recorder/demod_live.go b/internal/recorder/demod_live.go index a2de3dc..66ca3cd 100644 --- a/internal/recorder/demod_live.go +++ b/internal/recorder/demod_live.go @@ -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 diff --git a/internal/recorder/gpu_audio.go b/internal/recorder/gpu_audio.go index 7980c02..79a3b9c 100644 --- a/internal/recorder/gpu_audio.go +++ b/internal/recorder/gpu_audio.go @@ -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) diff --git a/internal/recorder/rds.go b/internal/recorder/rds.go index 0dd6851..f30209f 100644 --- a/internal/recorder/rds.go +++ b/internal/recorder/rds.go @@ -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) +} diff --git a/internal/recorder/recorder.go b/internal/recorder/recorder.go index 88a7298..4b4ea5d 100644 --- a/internal/recorder/recorder.go +++ b/internal/recorder/recorder.go @@ -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 +} diff --git a/sdr-visual-suite.rar b/sdr-visual-suite.rar index f84b762..80a11ac 100644 Binary files a/sdr-visual-suite.rar and b/sdr-visual-suite.rar differ diff --git a/sdr-visual-suite_works.rar b/sdr-visual-suite_works.rar new file mode 100644 index 0000000..113dad6 Binary files /dev/null and b/sdr-visual-suite_works.rar differ diff --git a/web/app.js b/web/app.js index 52aa30e..22042e9 100644 --- a/web/app.js +++ b/web/app.js @@ -30,12 +30,16 @@ const gainRange = qs('gainRange'); const gainInput = qs('gainInput'); const thresholdRange = qs('thresholdRange'); const thresholdInput = qs('thresholdInput'); -const classifierModeSelect = qs('classifierModeSelect'); const cfarModeSelect = qs('cfarModeSelect'); const cfarWrapToggle = qs('cfarWrapToggle'); const cfarGuardHzInput = qs('cfarGuardHzInput'); const cfarTrainHzInput = qs('cfarTrainHzInput'); const cfarRankInput = qs('cfarRankInput'); +const classifierModeSelect = qs('classifierModeSelect'); +const edgeMarginInput = qs('edgeMarginInput'); +const mergeGapInput = qs('mergeGapInput'); +const classHistoryInput = qs('classHistoryInput'); +const classSwitchInput = qs('classSwitchInput'); const cfarScaleInput = qs('cfarScaleInput'); const minDurationInput = qs('minDurationInput'); const holdInput = qs('holdInput'); @@ -43,10 +47,6 @@ const emaAlphaInput = qs('emaAlphaInput'); const hysteresisInput = qs('hysteresisInput'); const stableFramesInput = qs('stableFramesInput'); const gapToleranceInput = qs('gapToleranceInput'); -const edgeMarginInput = qs('edgeMarginInput'); -const mergeGapInput = qs('mergeGapInput'); -const classHistoryInput = qs('classHistoryInput'); -const classSwitchInput = qs('classSwitchInput'); const agcToggle = qs('agcToggle'); const dcToggle = qs('dcToggle'); const iqToggle = qs('iqToggle'); @@ -229,8 +229,7 @@ function renderSignalPopover(rect, signal) { const width = Math.max(4, (Number(value) / maxVal) * 100); return `
${label}${Number(value).toFixed(2)}
`; }).join(''); - const pllMeta = signal.class?.pll?.locked ? ` • PLL ${signal.class.pll.method} LOCK ±${signal.class.pll.precision_hz} Hz` : ''; - signalPopover.innerHTML = `
${signal.class?.mod_type || 'Signal'}
${fmtMHz(signal.class?.pll?.exact_hz || signal.center_hz, 5)} • ${fmtKHz(signal.bw_hz || 0)} • ${(signal.snr_db || 0).toFixed(1)} dB SNR${pllMeta}
${rows || '
No classifier scores
'}
`; + signalPopover.innerHTML = `
${signal.class?.mod_type || 'Signal'}${signal.class?.pll?.rds_station ? ' · ' + signal.class.pll.rds_station : ''}
${fmtMHz(signal.class?.pll?.exact_hz || signal.center_hz, 5)} · ${fmtKHz(signal.bw_hz || 0)} · ${(signal.snr_db || 0).toFixed(1)} dB SNR${signal.class?.pll?.locked ? ` · PLL ${signal.class.pll.method} LOCK` : ''}${signal.class?.pll?.stereo ? ' · STEREO' : ''}
${rows || '
No classifier scores
'}
`; const popW = 220; const left = rect.x + rect.w + 8; const top = rect.y + 8; @@ -373,6 +372,7 @@ function resizeAll() { window.addEventListener('resize', resizeAll); resizeAll(); + function setSelectValueOrNearest(selectEl, numericValue) { if (!selectEl) return; const options = Array.from(selectEl.options || []); @@ -409,7 +409,6 @@ function applyConfigToUI(cfg) { gainInput.value = uiGain; thresholdRange.value = cfg.detector.threshold_db; thresholdInput.value = cfg.detector.threshold_db; - if (classifierModeSelect) classifierModeSelect.value = cfg.classifier_mode || 'combined'; if (cfarModeSelect) cfarModeSelect.value = cfg.detector.cfar_mode || 'OFF'; if (cfarWrapToggle) cfarWrapToggle.checked = cfg.detector.cfar_wrap_around !== false; if (cfarGuardHzInput) cfarGuardHzInput.value = cfg.detector.cfar_guard_hz ?? 500; @@ -424,6 +423,7 @@ function applyConfigToUI(cfg) { if (hysteresisInput) hysteresisInput.value = cfg.detector.hysteresis_db ?? 3; if (stableFramesInput) stableFramesInput.value = cfg.detector.min_stable_frames ?? 3; if (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; + if (classifierModeSelect) classifierModeSelect.value = cfg.classifier_mode || 'combined'; if (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; if (mergeGapInput) mergeGapInput.value = cfg.detector.merge_gap_hz ?? 5000; if (classHistoryInput) classHistoryInput.value = cfg.detector.class_history_size ?? 10; @@ -459,6 +459,974 @@ async function loadConfig() { } } +async function loadSignals() { + try { + const res = await fetch('/api/signals'); + if (!res.ok) return; + const sigs = await res.json(); + if (Array.isArray(sigs)) { + latest = latest || {}; + latest.signals = sigs; + renderLists(); + } + } catch {} +} + +async function loadDecoders() { + if (!decodeModeSelect) return; + try { + const res = await fetch('/api/decoders'); + if (!res.ok) return; + const list = await res.json(); + if (!Array.isArray(list)) return; + const current = decodeModeSelect.value; + decodeModeSelect.innerHTML = ''; + list.forEach((mode) => { + const opt = document.createElement('option'); + opt.value = mode; + opt.textContent = mode; + decodeModeSelect.appendChild(opt); + }); + if (current) decodeModeSelect.value = current; + } catch {} +} + +async function loadStats() { + try { + const res = await fetch('/api/stats'); + if (!res.ok) return; + stats = await res.json(); + } catch {} +} + +async function loadGPU() { + try { + const res = await fetch('/api/gpu'); + if (!res.ok) return; + gpuInfo = await res.json(); + } catch {} +} + +function queueConfigUpdate(partial) { + if (isSyncingConfig) return; + pendingConfigUpdate = { ...(pendingConfigUpdate || {}), ...partial }; + setConfigStatus('Applying…'); + clearTimeout(configTimer); + configTimer = setTimeout(sendConfigUpdate, 180); +} + +function queueSettingsUpdate(partial) { + if (isSyncingConfig) return; + pendingSettingsUpdate = { ...(pendingSettingsUpdate || {}), ...partial }; + setConfigStatus('Applying…'); + clearTimeout(settingsTimer); + settingsTimer = setTimeout(sendSettingsUpdate, 120); +} + +async function sendConfigUpdate() { + if (!pendingConfigUpdate) return; + const payload = pendingConfigUpdate; + pendingConfigUpdate = null; + try { + const res = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('apply'); + currentConfig = await res.json(); + applyConfigToUI(currentConfig); + setConfigStatus('Config applied'); + } catch { + setConfigStatus('Config apply failed'); + } +} + +async function sendSettingsUpdate() { + if (!pendingSettingsUpdate) return; + const payload = pendingSettingsUpdate; + pendingSettingsUpdate = null; + try { + const res = await fetch('/api/sdr/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('apply'); + currentConfig = await res.json(); + applyConfigToUI(currentConfig); + setConfigStatus('Settings applied'); + } catch { + setConfigStatus('Settings apply failed'); + } +} + +function updateHeroMetrics() { + if (!latest) return; + const span = latest.sample_rate / zoom; + const binHz = latest.sample_rate / Math.max(1, latest.spectrum_db?.length || latest.fft_size || 1); + metricCenter.textContent = fmtMHz(latest.center_hz, 6); + metricSpan.textContent = fmtHz(span); + metricRes.textContent = `${binHz.toFixed(1)} Hz/bin`; + metricSignals.textContent = String(latest.signals?.length || 0); + metricGpu.textContent = gpuInfo.active ? 'ON' : (gpuInfo.available ? 'OFF' : 'N/A'); + metricSource.textContent = stats.last_sample_ago_ms >= 0 ? `${stats.last_sample_ago_ms} ms` : 'n/a'; + + const gpuText = gpuInfo.active ? 'GPU active' : (gpuInfo.available ? 'GPU ready' : 'GPU n/a'); + const debug = latest.debug || {}; + const thresholdInfo = Array.isArray(debug.thresholds) && debug.thresholds.length + ? `CFAR ${showDebugOverlay ? 'on' : 'hidden'} · noise ${(Number.isFinite(debug.noise_floor) ? debug.noise_floor.toFixed(1) : 'n/a')} dB` + : `CFAR off · noise ${(Number.isFinite(debug.noise_floor) ? debug.noise_floor.toFixed(1) : 'n/a')} dB`; + metaLine.textContent = `${fmtMHz(latest.center_hz, 3)} · ${fmtHz(span)} span · ${thresholdInfo} · ${gpuText}`; + heroSubtitle.textContent = `${latest.signals?.length || 0} live signals · ${events.length} recent events tracked`; + + healthBuffer.textContent = String(stats.buffer_samples ?? '-'); + healthDropped.textContent = String(stats.dropped ?? '-'); + healthResets.textContent = String(stats.resets ?? '-'); + healthAge.textContent = stats.last_sample_ago_ms >= 0 ? `${stats.last_sample_ago_ms} ms` : 'n/a'; + healthGpu.textContent = gpuInfo.error ? `${gpuInfo.active ? 'ON' : 'OFF'} · ${gpuInfo.error}` : (gpuInfo.active ? 'ON' : (gpuInfo.available ? 'Ready' : 'N/A')); + healthFps.textContent = `${renderFps.toFixed(0)} fps`; +} + +function renderBandNavigator() { + if (!latest) return; + const ctx = navCanvas.getContext('2d'); + const w = navCanvas.width; + const h = navCanvas.height; + ctx.clearRect(0, 0, w, h); + + const display = getProcessedSpectrum(); + if (!display) return; + const minDb = -120; + const maxDb = 0; + + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); + + ctx.strokeStyle = 'rgba(102, 169, 255, 0.25)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const idx = Math.min(display.length - 1, Math.floor((x / w) * display.length)); + const v = display[idx]; + const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 10) - 5; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + const span = latest.sample_rate / zoom; + const fullStart = latest.center_hz - latest.sample_rate / 2; + const viewStart = latest.center_hz - span / 2 + pan * span; + const viewEnd = latest.center_hz + span / 2 + pan * span; + const x1 = ((viewStart - fullStart) / latest.sample_rate) * w; + const x2 = ((viewEnd - fullStart) / latest.sample_rate) * w; + + ctx.fillStyle = 'rgba(102, 240, 209, 0.10)'; + ctx.strokeStyle = 'rgba(102, 240, 209, 0.85)'; + ctx.lineWidth = 2; + ctx.fillRect(x1, 4, Math.max(2, x2 - x1), h - 8); + ctx.strokeRect(x1, 4, Math.max(2, x2 - x1), h - 8); +} + +function drawSpectrumGrid(ctx, w, h, startHz, endHz) { + ctx.strokeStyle = 'rgba(86, 109, 148, 0.18)'; + ctx.lineWidth = 1; + for (let i = 1; i < 6; i++) { + const y = (h / 6) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + for (let i = 1; i < 8; i++) { + const x = (w / 8) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + const hz = startHz + (i / 8) * (endHz - startHz); + ctx.fillStyle = 'rgba(173, 192, 220, 0.72)'; + ctx.font = `${Math.max(11, Math.floor(h / 26))}px Inter, sans-serif`; + ctx.fillText((hz / 1e6).toFixed(3), x + 4, h - 8); + } +} + +function drawCfarEdgeOverlay(ctx, w, h, startHz, endHz) { + if (!latest) return; + const mode = currentConfig?.detector?.cfar_mode || 'OFF'; + if (mode === 'OFF') return; + if (currentConfig?.detector?.cfar_wrap_around) return; + const guardHz = currentConfig.detector.cfar_guard_hz ?? 500; + const trainHz = currentConfig.detector.cfar_train_hz ?? 5000; + const fftSize = latest.fft_size || latest.spectrum_db?.length; + if (!fftSize || fftSize <= 0) return; + const binW = (latest.sample_rate || 2048000) / fftSize; + const bins = Math.ceil(guardHz / binW) + Math.ceil(trainHz / binW); + if (bins <= 0) return; + const binHz = latest.sample_rate / fftSize; + const edgeHz = bins * binHz; + const bandStart = latest.center_hz - latest.sample_rate / 2; + const bandEnd = latest.center_hz + latest.sample_rate / 2; + const leftEdgeEnd = bandStart + edgeHz; + const rightEdgeStart = bandEnd - edgeHz; + + ctx.fillStyle = 'rgba(255, 204, 102, 0.08)'; + ctx.strokeStyle = 'rgba(255, 204, 102, 0.18)'; + ctx.lineWidth = 1; + + const leftStart = Math.max(startHz, bandStart); + const leftEnd = Math.min(endHz, leftEdgeEnd); + if (leftEnd > leftStart) { + const x1 = ((leftStart - startHz) / (endHz - startHz)) * w; + const x2 = ((leftEnd - startHz) / (endHz - startHz)) * w; + ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); + ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); + } + + const rightStart = Math.max(startHz, rightEdgeStart); + const rightEnd = Math.min(endHz, bandEnd); + if (rightEnd > rightStart) { + const x1 = ((rightStart - startHz) / (endHz - startHz)) * w; + const x2 = ((rightEnd - startHz) / (endHz - startHz)) * w; + ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); + ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); + } +} + +function renderSpectrum() { + if (!latest) return; + const ctx = spectrumCanvas.getContext('2d'); + const w = spectrumCanvas.width; + const h = spectrumCanvas.height; + ctx.clearRect(0, 0, w, h); + + const display = getProcessedSpectrum(); + if (!display) return; + const n = display.length; + const span = latest.sample_rate / zoom; + const startHz = latest.center_hz - span / 2 + pan * span; + const endHz = latest.center_hz + span / 2 + pan * span; + spanInput.value = (span / 1e6).toFixed(3); + + drawSpectrumGrid(ctx, w, h, startHz, endHz); + drawCfarEdgeOverlay(ctx, w, h, startHz, endHz); + + const minDb = -120; + const maxDb = 0; + + const fill = ctx.createLinearGradient(0, 0, 0, h); + fill.addColorStop(0, 'rgba(102, 240, 209, 0.20)'); + fill.addColorStop(1, 'rgba(102, 240, 209, 0.02)'); + + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const f1 = startHz + (x / w) * (endHz - startHz); + const f2 = startHz + ((x + 1) / w) * (endHz - startHz); + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); + const v = maxInBinRange(display, b0, b1); + const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 18) - 6; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.lineTo(w, h); + ctx.lineTo(0, h); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + + ctx.strokeStyle = '#66f0d1'; + ctx.lineWidth = 2; + ctx.beginPath(); + liveSignalRects = []; + for (let x = 0; x < w; x++) { + const f1 = startHz + (x / w) * (endHz - startHz); + const f2 = startHz + ((x + 1) / w) * (endHz - startHz); + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); + const v = maxInBinRange(display, b0, b1); + const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 18) - 6; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + drawThresholdOverlay(ctx, w, h, minDb, maxDb); + + if (Array.isArray(latest.signals)) { + latest.signals.forEach((s, index) => { + const left = s.center_hz - s.bw_hz / 2; + const right = s.center_hz + s.bw_hz / 2; + if (right < startHz || left > endHz) return; + const x1 = ((left - startHz) / (endHz - startHz)) * w; + const x2 = ((right - startHz) / (endHz - startHz)) * w; + const boxW = Math.max(2, x2 - x1); + const color = snrColor(s.snr_db || 0); + + ctx.fillStyle = color.replace('rgb', 'rgba').replace(')', ', 0.14)'); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.fillRect(x1, 10, boxW, h - 28); + ctx.strokeRect(x1, 10, boxW, h - 28); + ctx.fillStyle = color; + ctx.font = '12px Inter, sans-serif'; + const modLabel = s.class?.mod_type || ''; + const rdsName = s.class?.pll?.rds_station || ''; + const freqStr = `${(s.center_hz / 1e6).toFixed(4)} MHz`; + const label = rdsName ? `${freqStr} · ${modLabel} · ${rdsName}` : (modLabel ? `${freqStr} · ${modLabel}` : freqStr); + ctx.fillText(label, Math.max(4, x1 + 4), 24 + (index % 3) * 16); + + const debugMatch = (latest?.debug?.scores || []).find((d) => Math.abs((d.center_hz || 0) - (s.center_hz || 0)) < Math.max(500, s.bw_hz || 0)); + if (debugMatch?.scores && (!s.class || !s.class.scores)) { + s.debug_scores = debugMatch.scores; + } + liveSignalRects.push({ + x: x1, + y: 10, + w: boxW, + h: h - 28, + signal: s, + }); + }); + } +} + +function renderWaterfall() { + if (!latest) return; + const ctx = waterfallCanvas.getContext('2d'); + const w = waterfallCanvas.width; + const h = waterfallCanvas.height; + + const prev = ctx.getImageData(0, 0, w, h - 1); + ctx.putImageData(prev, 0, 1); + + const display = getProcessedSpectrum(); + if (!display) return; + const n = display.length; + const span = latest.sample_rate / zoom; + const startHz = latest.center_hz - span / 2 + pan * span; + const endHz = latest.center_hz + span / 2 + pan * span; + const minDb = -120; + const maxDb = 0; + + const row = ctx.createImageData(w, 1); + for (let x = 0; x < w; x++) { + const f1 = startHz + (x / w) * (endHz - startHz); + const f2 = startHz + ((x + 1) / w) * (endHz - startHz); + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); + const v = maxInBinRange(display, b0, b1); + const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); + const [r, g, b] = colorMap(norm); + row.data[x * 4] = r; + row.data[x * 4 + 1] = g; + row.data[x * 4 + 2] = b; + row.data[x * 4 + 3] = 255; + } + ctx.putImageData(row, 0, 0); + drawCfarEdgeOverlay(ctx, w, h, startHz, endHz); +} + +function renderOccupancy() { + const ctx = occupancyCanvas.getContext('2d'); + const w = occupancyCanvas.width; + const h = occupancyCanvas.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); + + if (!latest || events.length === 0) return; + + const bins = new Array(Math.max(32, Math.min(160, Math.floor(w / 8)))).fill(0); + const bandStart = latest.center_hz - latest.sample_rate / 2; + const bandEnd = latest.center_hz + latest.sample_rate / 2; + const now = Date.now(); + const windowStart = now - timelineWindowMs; + + for (const ev of events) { + if (ev.end_ms < windowStart || ev.start_ms > now) continue; + const left = ev.center_hz - ev.bandwidth_hz / 2; + const right = ev.center_hz + ev.bandwidth_hz / 2; + const normL = Math.max(0, Math.min(1, (left - bandStart) / (bandEnd - bandStart))); + const normR = Math.max(0, Math.min(1, (right - bandStart) / (bandEnd - bandStart))); + let b0 = Math.floor(normL * bins.length); + let b1 = Math.floor(normR * bins.length); + if (b1 < b0) [b0, b1] = [b1, b0]; + for (let i = Math.max(0, b0); i <= Math.min(bins.length - 1, b1); i++) { + bins[i] += Math.max(0.3, (ev.snr_db || 0) / 12 + 1); + } + } + + const maxBin = Math.max(1, ...bins); + bins.forEach((v, i) => { + const norm = v / maxBin; + const [r, g, b] = colorMap(norm); + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + const x = (i / bins.length) * w; + const bw = Math.ceil(w / bins.length) + 1; + ctx.fillRect(x, 0, bw, h); + }); +} + +function renderTimeline() { + const ctx = timelineCanvas.getContext('2d'); + const w = timelineCanvas.width; + const h = timelineCanvas.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); + + if (events.length === 0) { + timelineRangeEl.textContent = 'No events yet'; + return; + } + + const endMs = Date.now(); + const startMs = endMs - timelineWindowMs; + timelineRangeEl.textContent = `${new Date(startMs).toLocaleTimeString()} - ${new Date(endMs).toLocaleTimeString()}`; + + let minHz = Infinity; + let maxHz = -Infinity; + if (latest) { + minHz = latest.center_hz - latest.sample_rate / 2; + maxHz = latest.center_hz + latest.sample_rate / 2; + } else { + for (const ev of events) { + minHz = Math.min(minHz, ev.center_hz - ev.bandwidth_hz / 2); + maxHz = Math.max(maxHz, ev.center_hz + ev.bandwidth_hz / 2); + } + } + if (!isFinite(minHz) || !isFinite(maxHz) || minHz === maxHz) { + minHz = 0; + maxHz = 1; + } + + ctx.strokeStyle = 'rgba(86, 109, 148, 0.18)'; + ctx.lineWidth = 1; + for (let i = 1; i < 6; i++) { + const y = (h / 6) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + for (let i = 1; i < 8; i++) { + const x = (w / 8) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + + timelineRects = []; + for (const ev of events) { + if (ev.end_ms < startMs || ev.start_ms > endMs) continue; + + const x1 = ((Math.max(ev.start_ms, startMs) - startMs) / (endMs - startMs)) * w; + const x2 = ((Math.min(ev.end_ms, endMs) - startMs) / (endMs - startMs)) * w; + const topHz = ev.center_hz + ev.bandwidth_hz / 2; + const bottomHz = ev.center_hz - ev.bandwidth_hz / 2; + const y1 = ((maxHz - topHz) / (maxHz - minHz)) * h; + const y2 = ((maxHz - bottomHz) / (maxHz - minHz)) * h; + + const rect = { x: x1, y: y1, w: Math.max(2, x2 - x1), h: Math.max(3, y2 - y1), id: ev.id }; + timelineRects.push(rect); + + ctx.fillStyle = snrColor(ev.snr_db || 0).replace('rgb', 'rgba').replace(')', ', 0.85)'); + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + } + + if (selectedEventId) { + const hit = timelineRects.find(r => r.id === selectedEventId); + if (hit) { + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.strokeRect(hit.x - 1, hit.y - 1, hit.w + 2, hit.h + 2); + } + } +} + +function renderDetailSpectrogram() { + const ev = eventsById.get(selectedEventId); + const ctx = detailSpectrogram.getContext('2d'); + const w = detailSpectrogram.width; + const h = detailSpectrogram.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); + if (!latest || !ev) return; + + const display = getProcessedSpectrum(); + if (!display) return; + const n = display.length; + const localSpan = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 4, latest.sample_rate / 10)); + const startHz = ev.center_hz - localSpan / 2; + const endHz = ev.center_hz + localSpan / 2; + const minDb = -120; + const maxDb = 0; + + const row = ctx.createImageData(w, 1); + for (let x = 0; x < w; x++) { + const f1 = startHz + (x / w) * (endHz - startHz); + const f2 = startHz + ((x + 1) / w) * (endHz - startHz); + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); + const v = maxInBinRange(display, b0, b1); + const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); + const [r, g, b] = colorMap(norm); + row.data[x * 4] = r; + row.data[x * 4 + 1] = g; + row.data[x * 4 + 2] = b; + row.data[x * 4 + 3] = 255; + } + + for (let y = 0; y < h; y++) ctx.putImageData(row, 0, y); + + const centerX = w / 2; + ctx.strokeStyle = 'rgba(255,255,255,0.65)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(centerX, 0); + ctx.lineTo(centerX, h); + ctx.stroke(); +} + +let _lastEventListKey = ''; + +function _createSignalItem(s) { + const btn = document.createElement('button'); + btn.className = 'list-item signal-item'; + btn.type = 'button'; + btn.dataset.center = s.center_hz; + btn.dataset.bw = s.bw_hz || 0; + btn.dataset.class = s.class?.mod_type || ''; + btn.dataset.id = s.id || 0; + btn.innerHTML = `
${fmtMHz(s.center_hz, 6)}${(s.snr_db || 0).toFixed(1)} dB
BW ${fmtKHz(s.bw_hz || 0)}${s.class?.mod_type || 'live carrier'}${s.class?.pll?.rds_station ? ' · ' + s.class.pll.rds_station : ''}
`; + return btn; +} + +function _patchSignalItem(el, s) { + const freqEl = el.querySelector('[data-field="freq"]'); + const snrEl = el.querySelector('[data-field="snr"]'); + const bwEl = el.querySelector('[data-field="bw"]'); + const modEl = el.querySelector('[data-field="mod"]'); + if (freqEl) freqEl.textContent = fmtMHz(s.center_hz, 6); + if (snrEl) { snrEl.textContent = `${(s.snr_db || 0).toFixed(1)} dB`; snrEl.style.color = snrColor(s.snr_db || 0); } + if (bwEl) bwEl.textContent = `BW ${fmtKHz(s.bw_hz || 0)}`; + if (modEl) modEl.textContent = (s.class?.mod_type || 'live carrier') + (s.class?.pll?.rds_station ? ' · ' + s.class.pll.rds_station : ''); + el.dataset.center = s.center_hz; + el.dataset.bw = s.bw_hz || 0; + el.dataset.class = s.class?.mod_type || ''; +} + +function renderLists() { + const signals = Array.isArray(latest?.signals) ? [...latest.signals] : []; + signals.sort((a, b) => (b.snr_db || 0) - (a.snr_db || 0)); + signalCountBadge.textContent = `${signals.length} live`; + metricSignals.textContent = String(signals.length); + + const displaySigs = signals.slice(0, 24); + const wantIds = new Set(displaySigs.map(s => String(s.id || 0))); + + // Remove empty-state placeholder if signals exist + const emptyEl = signalList.querySelector('.empty-state'); + if (emptyEl && displaySigs.length > 0) emptyEl.remove(); + + // Remove DOM items whose signal ID is no longer present + signalList.querySelectorAll('.signal-item').forEach(el => { + if (!wantIds.has(el.dataset.id)) el.remove(); + }); + + if (displaySigs.length === 0) { + if (!signalList.querySelector('.empty-state')) { + signalList.innerHTML = '
No live signals yet.
'; + } + } else { + // Build map of existing DOM items + const domById = new Map(); + signalList.querySelectorAll('.signal-item').forEach(el => domById.set(el.dataset.id, el)); + + displaySigs.forEach(s => { + const id = String(s.id || 0); + const existing = domById.get(id); + if (existing) { + _patchSignalItem(existing, s); + } else { + const el = _createSignalItem(s); + // Auto-select if it matches the user's last selection + if (window._selectedSignal && Math.abs(s.center_hz - window._selectedSignal.freq) < 50000) { + el.classList.add('active'); + } + signalList.appendChild(el); + } + }); + } + + const recent = [...events].sort((a, b) => b.end_ms - a.end_ms); + eventCountBadge.textContent = `${recent.length} stored`; + + const evtKey = `${recent.length}:${selectedEventId}:${recent.slice(0, 5).map(e => e.id).join(',')}`; + if (evtKey !== _lastEventListKey) { + _lastEventListKey = evtKey; + if (recent.length === 0) { + eventList.innerHTML = '
No events yet.
'; + } else { + eventList.innerHTML = recent.slice(0, 40).map((ev) => ` + + `).join(''); + } + } + + if (recordingList && recordingCountBadge) { + recordingCountBadge.textContent = `${recordings.length}`; + if (recordings.length === 0) { + recordingList.innerHTML = '
No recordings yet.
'; + } else { + recordingList.innerHTML = recordings.slice(0, 50).map((rec) => ` + + `).join(''); + } + } +} + +function normalizeEvent(ev) { + const startMs = new Date(ev.start).getTime(); + const endMs = new Date(ev.end).getTime(); + return { + ...ev, + start_ms: startMs, + end_ms: endMs, + duration_ms: Math.max(0, endMs - startMs), + }; +} + +function upsertEvents(list, replace = false) { + if (replace) { + events.length = 0; + eventsById.clear(); + } + for (const raw of list) { + if (!raw || !raw.id || eventsById.has(raw.id)) continue; + const ev = normalizeEvent(raw); + eventsById.set(ev.id, ev); + events.push(ev); + } + events.sort((a, b) => a.end_ms - b.end_ms); + const maxEvents = 1500; + if (events.length > maxEvents) { + const drop = events.length - maxEvents; + for (let i = 0; i < drop; i++) eventsById.delete(events[i].id); + events.splice(0, drop); + } + if (events.length > 0) lastEventEndMs = events[events.length - 1].end_ms; + renderLists(); +} + +async function fetchEvents(initial) { + if (eventsFetchInFlight || timelineFrozen) return; + eventsFetchInFlight = true; + try { + let url = '/api/events?limit=1000'; + if (!initial && lastEventEndMs > 0) url = `/api/events?since=${lastEventEndMs - 1}`; + const res = await fetch(url); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data)) upsertEvents(data, initial); + } finally { + eventsFetchInFlight = false; + } +} + +async function fetchRecordings() { + if (recordingsFetchInFlight || !recordingList) return; + recordingsFetchInFlight = true; + try { + const res = await fetch('/api/recordings'); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data)) { + recordings = data; + renderLists(); + } + } finally { + recordingsFetchInFlight = false; + } +} + +function openDrawer(ev) { + if (!ev) return; + selectedEventId = ev.id; + detailSubtitle.textContent = `Event ${ev.id}`; + detailCenterEl.textContent = fmtMHz(ev.center_hz, 6); + detailBwEl.textContent = fmtKHz(ev.bandwidth_hz || 0); + detailStartEl.textContent = new Date(ev.start_ms).toLocaleString(); + detailEndEl.textContent = new Date(ev.end_ms).toLocaleString(); + detailSnrEl.textContent = `${(ev.snr_db || 0).toFixed(1)} dB`; + detailDurEl.textContent = fmtMs(ev.duration_ms || 0); + detailClassEl.textContent = ev.class?.mod_type || '-'; + if (classifierScoresEl) { + const scores = ev.class?.scores; + if (scores && typeof scores === 'object') { + const rows = Object.entries(scores) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([k, v]) => `${k}:${v.toFixed(2)}`) + .join(' · '); + classifierScoresEl.textContent = rows ? `Classifier scores: ${rows}` : 'Classifier scores: -'; + renderScoreBars(scores); + } else { + const liveScores = (latest?.debug?.scores || []).find((s) => Math.abs((s.center_hz || 0) - (ev.center_hz || 0)) < Math.max(500, (ev.bandwidth_hz || 0))); + if (liveScores?.scores) { + const rows = Object.entries(liveScores.scores) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([k, v]) => `${k}:${Number(v).toFixed(2)}`) + .join(' · '); + classifierScoresEl.textContent = rows ? `Classifier scores: ${rows}` : 'Classifier scores: -'; + renderScoreBars(liveScores.scores); + } else { + classifierScoresEl.textContent = 'Classifier scores: -'; + renderScoreBars(null); + } + } + } + if (recordingMetaEl) { + recordingMetaEl.textContent = 'Recording: -'; + } + if (recordingMetaLink) { + recordingMetaLink.href = '#'; + recordingIQLink.href = '#'; + recordingAudioLink.href = '#'; + } + drawerEl.classList.add('open'); + drawerEl.setAttribute('aria-hidden', 'false'); + renderDetailSpectrogram(); + renderLists(); +} + +function closeDrawer() { + drawerEl.classList.remove('open'); + drawerEl.setAttribute('aria-hidden', 'true'); + selectedEventId = null; + renderLists(); +} + +function fitView() { + zoom = 1; + pan = 0; + followLive = true; +} + +function tuneToFrequency(centerHz) { + if (!Number.isFinite(centerHz)) return; + followLive = true; + centerInput.value = (centerHz / 1e6).toFixed(6); + queueConfigUpdate({ center_hz: centerHz }); +} + +function connect() { + clearTimeout(wsReconnectTimer); + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${location.host}/ws`); + setWsBadge('Connecting', 'neutral'); + + ws.onopen = () => setWsBadge('Live', 'ok'); + ws.onmessage = (ev) => { + latest = JSON.parse(ev.data); + markSpectrumDirty(); + if (followLive) pan = 0; + updateHeroMetrics(); + renderLists(); + }; + ws.onclose = () => { + setWsBadge('Retrying', 'bad'); + wsReconnectTimer = setTimeout(connect, 1000); + }; + ws.onerror = () => ws.close(); +} + +function renderLoop() { + renderFrames += 1; + const now = performance.now(); + if (now - lastFpsTs >= 1000) { + renderFps = (renderFrames * 1000) / (now - lastFpsTs); + renderFrames = 0; + lastFpsTs = now; + } + + if (latest) { + renderBandNavigator(); + renderSpectrum(); + renderWaterfall(); + renderOccupancy(); + renderTimeline(); + if (drawerEl.classList.contains('open')) renderDetailSpectrogram(); + } + updateHeroMetrics(); + requestAnimationFrame(renderLoop); +} + +function handleSpectrumClick(ev) { + const rect = spectrumCanvas.getBoundingClientRect(); + const x = (ev.clientX - rect.left) * (spectrumCanvas.width / rect.width); + const y = (ev.clientY - rect.top) * (spectrumCanvas.height / rect.height); + + for (let i = liveSignalRects.length - 1; i >= 0; i--) { + const r = liveSignalRects[i]; + if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) { + tuneToFrequency(r.signal.center_hz); + return; + } + } + + if (!latest) return; + const span = latest.sample_rate / zoom; + const startHz = latest.center_hz - span / 2 + pan * span; + const clickedHz = startHz + (x / spectrumCanvas.width) * span; + tuneToFrequency(clickedHz); +} + +function handleNavPosition(ev) { + if (!latest) return; + const rect = navCanvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width, ev.clientX - rect.left)); + const norm = x / rect.width; + const fullStart = latest.center_hz - latest.sample_rate / 2; + const newViewCenter = fullStart + norm * latest.sample_rate; + const span = latest.sample_rate / zoom; + const desiredPan = (newViewCenter - latest.center_hz) / span; + pan = Math.max(-0.5, Math.min(0.5, desiredPan)); + followLive = false; +} + +function exportSelectedEvent() { + const ev = eventsById.get(selectedEventId); + if (!ev) return; + const blob = new Blob([JSON.stringify(ev, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `event-${ev.id}.json`; + a.click(); + URL.revokeObjectURL(a.href); +} + +spectrumCanvas.addEventListener('wheel', (ev) => { + ev.preventDefault(); + const direction = Math.sign(ev.deltaY); + zoom = Math.max(0.25, Math.min(24, zoom * (direction > 0 ? 1.12 : 0.89))); + followLive = false; +}); + +spectrumCanvas.addEventListener('mousedown', (ev) => { + isDraggingSpectrum = true; + dragStartX = ev.clientX; + dragStartPan = pan; +}); +window.addEventListener('mouseup', () => { + isDraggingSpectrum = false; + navDrag = false; +}); +spectrumCanvas.addEventListener('mouseleave', hideSignalPopover); +window.addEventListener('mousemove', (ev) => { + const rect = spectrumCanvas.getBoundingClientRect(); + const x = (ev.clientX - rect.left) * (spectrumCanvas.width / rect.width); + const y = (ev.clientY - rect.top) * (spectrumCanvas.height / rect.height); + let hoverHit = null; + for (let i = liveSignalRects.length - 1; i >= 0; i--) { + const r = liveSignalRects[i]; + if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) { + hoverHit = r; + break; + } + } + if (hoverHit) { + hoveredSignal = hoverHit.signal; + renderSignalPopover(hoverHit, hoverHit.signal); + } else { + scheduleHideSignalPopover(); + } + if (isDraggingSpectrum) { + const dx = ev.clientX - dragStartX; + pan = Math.max(-0.5, Math.min(0.5, dragStartPan - dx / spectrumCanvas.clientWidth)); + followLive = false; + } + if (navDrag) handleNavPosition(ev); +}); + +spectrumCanvas.addEventListener('dblclick', fitView); +spectrumCanvas.addEventListener('click', handleSpectrumClick); + +navCanvas.addEventListener('mousedown', (ev) => { + navDrag = true; + handleNavPosition(ev); +}); +navCanvas.addEventListener('click', handleNavPosition); + +centerInput.addEventListener('change', () => { + const mhz = parseFloat(centerInput.value); + if (Number.isFinite(mhz)) tuneToFrequency(fromMHz(mhz)); +}); + +spanInput.addEventListener('change', () => { + const mhz = parseFloat(spanInput.value); + if (!Number.isFinite(mhz) || mhz <= 0) return; + const baseRate = currentConfig?.sample_rate || latest?.sample_rate; + if (!baseRate) return; + zoom = Math.max(0.25, Math.min(24, baseRate / fromMHz(mhz))); + followLive = false; +}); + +sampleRateSelect.addEventListener('change', () => { + const mhz = parseFloat(sampleRateSelect.value); + if (Number.isFinite(mhz)) queueConfigUpdate({ sample_rate: Math.round(fromMHz(mhz)) }); +}); + +bwSelect.addEventListener('change', () => { + const bw = parseInt(bwSelect.value, 10); + if (Number.isFinite(bw)) queueConfigUpdate({ tuner_bw_khz: bw }); +}); + +fftSelect.addEventListener('change', () => { + const size = parseInt(fftSelect.value, 10); + if (Number.isFinite(size)) queueConfigUpdate({ fft_size: size }); +}); + +gainRange.addEventListener('input', () => { + gainInput.value = gainRange.value; + const uiVal = parseFloat(gainRange.value); + if (Number.isFinite(uiVal)) queueConfigUpdate({ gain_db: Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - uiVal)) }); +}); + +gainInput.addEventListener('change', () => { + const uiVal = parseFloat(gainInput.value); + if (Number.isFinite(uiVal)) { + gainRange.value = uiVal; + queueConfigUpdate({ gain_db: Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - uiVal)) }); + } +}); + +thresholdRange.addEventListener('input', () => { + thresholdInput.value = thresholdRange.value; + queueConfigUpdate({ detector: { threshold_db: parseFloat(thresholdRange.value) } }); +}); thresholdInput.addEventListener('change', () => { const v = parseFloat(thresholdInput.value); if (Number.isFinite(v)) { @@ -467,8 +1435,13 @@ thresholdInput.addEventListener('change', () => { } }); -if (classifierModeSelect) classifierModeSelect.addEventListener('change', () => { - queueConfigUpdate({ classifier_mode: classifierModeSelect.value }); +if (cfarModeSelect) cfarModeSelect.addEventListener('change', () => { + queueConfigUpdate({ detector: { cfar_mode: cfarModeSelect.value } }); + const rankRow = cfarRankInput?.closest('.field'); + if (rankRow) rankRow.style.display = (cfarModeSelect.value === 'OS') ? '' : 'none'; +}); +if (cfarWrapToggle) cfarWrapToggle.addEventListener('change', () => { + queueConfigUpdate({ detector: { cfar_wrap_around: cfarWrapToggle.checked } }); }); if (cfarGuardHzInput) cfarGuardHzInput.addEventListener('change', () => { const v = parseFloat(cfarGuardHzInput.value); @@ -478,6 +1451,45 @@ if (cfarTrainHzInput) cfarTrainHzInput.addEventListener('change', () => { const v = parseFloat(cfarTrainHzInput.value); if (Number.isFinite(v) && v > 0) queueConfigUpdate({ detector: { cfar_train_hz: v } }); }); +if (cfarRankInput) cfarRankInput.addEventListener('change', () => { + const v = parseInt(cfarRankInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_rank: v } }); +}); +if (cfarScaleInput) cfarScaleInput.addEventListener('change', () => { + const v = parseFloat(cfarScaleInput.value); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_scale_db: v } }); +}); +if (minDurationInput) minDurationInput.addEventListener('change', () => { + const v = parseInt(minDurationInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { min_duration_ms: v } }); +}); +if (holdInput) holdInput.addEventListener('change', () => { + const v = parseInt(holdInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { hold_ms: v } }); +}); +if (emaAlphaInput) emaAlphaInput.addEventListener('change', () => { + const v = parseFloat(emaAlphaInput.value); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { ema_alpha: v } }); +}); +if (hysteresisInput) hysteresisInput.addEventListener('change', () => { + const v = parseFloat(hysteresisInput.value); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { hysteresis_db: v } }); +}); +if (stableFramesInput) stableFramesInput.addEventListener('change', () => { + const v = parseInt(stableFramesInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { min_stable_frames: v } }); +}); +if (gapToleranceInput) gapToleranceInput.addEventListener('change', () => { + const v = parseInt(gapToleranceInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { gap_tolerance_ms: v } }); +}); +if (classifierModeSelect) classifierModeSelect.addEventListener('change', () => { + queueConfigUpdate({ classifier_mode: classifierModeSelect.value }); +}); +if (edgeMarginInput) edgeMarginInput.addEventListener('change', () => { + const v = parseFloat(edgeMarginInput.value); + if (Number.isFinite(v) && v >= 0) queueConfigUpdate({ detector: { edge_margin_db: v } }); +}); if (mergeGapInput) mergeGapInput.addEventListener('change', () => { const v = parseFloat(mergeGapInput.value); if (Number.isFinite(v)) queueConfigUpdate({ detector: { merge_gap_hz: v } }); @@ -490,3 +1502,261 @@ if (classSwitchInput) classSwitchInput.addEventListener('change', () => { const v = parseFloat(classSwitchInput.value); if (Number.isFinite(v) && v >= 0.1 && v <= 1.0) queueConfigUpdate({ detector: { class_switch_ratio: v } }); }); + +agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); +dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); +iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.checked })); +gpuToggle.addEventListener('change', () => queueConfigUpdate({ use_gpu_fft: gpuToggle.checked })); +if (recEnableToggle) recEnableToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { enabled: recEnableToggle.checked } })); +if (recIQToggle) recIQToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { record_iq: recIQToggle.checked } })); +if (recAudioToggle) recAudioToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { record_audio: recAudioToggle.checked } })); +if (recDemodToggle) recDemodToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { auto_demod: recDemodToggle.checked } })); +if (recDecodeToggle) recDecodeToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { auto_decode: recDecodeToggle.checked } })); +if (recMinSNR) recMinSNR.addEventListener('change', () => queueConfigUpdate({ recorder: { min_snr_db: parseFloat(recMinSNR.value) } })); +if (recMaxDisk) recMaxDisk.addEventListener('change', () => queueConfigUpdate({ recorder: { max_disk_mb: parseInt(recMaxDisk.value || '0', 10) } })); +if (recClassFilter) recClassFilter.addEventListener('change', () => { + const list = (recClassFilter.value || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + queueConfigUpdate({ recorder: { class_filter: list } }); +}); + +avgSelect.addEventListener('change', () => { + avgAlpha = parseFloat(avgSelect.value) || 0; + resetProcessingCaches(); +}); +maxHoldToggle.addEventListener('change', () => { + maxHold = maxHoldToggle.checked; + maxSpectrum = null; + markSpectrumDirty(); +}); +if (debugOverlayToggle) debugOverlayToggle.addEventListener('change', () => { + showDebugOverlay = debugOverlayToggle.checked; + localStorage.setItem('spectre.debugOverlay', showDebugOverlay ? '1' : '0'); + markSpectrumDirty(); + updateHeroMetrics(); +}); +resetMaxBtn.addEventListener('click', () => { + maxSpectrum = null; + markSpectrumDirty(); +}); +followBtn.addEventListener('click', () => { followLive = true; pan = 0; }); +fitBtn.addEventListener('click', fitView); +timelineFollowBtn.addEventListener('click', () => { timelineFrozen = false; }); +timelineFreezeBtn.addEventListener('click', () => { + timelineFrozen = !timelineFrozen; + timelineFreezeBtn.textContent = timelineFrozen ? 'Frozen' : 'Freeze'; +}); + +presetButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const mhz = parseFloat(btn.dataset.center); + if (Number.isFinite(mhz)) tuneToFrequency(fromMHz(mhz)); + }); +}); + +railTabs.forEach((tab) => { + tab.addEventListener('click', () => { + railTabs.forEach(t => t.classList.toggle('active', t === tab)); + tabPanels.forEach(panel => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab)); + }); +}); + +modeButtons.forEach((btn) => { + btn.addEventListener('click', () => { + modeButtons.forEach(b => b.classList.toggle('active', b === btn)); + document.body.classList.remove('mode-live', 'mode-hunt', 'mode-review', 'mode-lab'); + document.body.classList.add(`mode-${btn.dataset.mode}`); + }); +}); +document.body.classList.add('mode-live'); + +drawerCloseBtn.addEventListener('click', closeDrawer); +exportEventBtn.addEventListener('click', exportSelectedEvent); +if (liveListenEventBtn) { + liveListenEventBtn.addEventListener('click', () => { + const ev = eventsById.get(selectedEventId); + if (!ev) return; + const freq = ev.center_hz; + const bw = ev.bandwidth_hz || 12000; + const mode = (listenModeSelect?.value || ev.class?.mod_type || 'NFM'); + const sec = parseInt(listenSecondsInput?.value || '2', 10); + const url = `/api/demod?freq=${freq}&bw=${bw}&mode=${mode}&sec=${sec}`; + const audio = new Audio(url); + audio.play(); + }); +} +if (decodeEventBtn) { + decodeEventBtn.addEventListener('click', async () => { + const ev = eventsById.get(selectedEventId); + if (!ev) return; + if (!recordingMetaEl) return; + const rec = recordings.find(r => r.event_id === ev.id) || recordings.find(r => r.center_hz === ev.center_hz); + if (!rec) { + decodeResultEl.textContent = 'Decode: no recording'; + return; + } + const mode = decodeModeSelect?.value || ev.class?.mod_type || 'FT8'; + const res = await fetch(`/api/recordings/${rec.id}/decode?mode=${mode}`); + if (!res.ok) { + decodeResultEl.textContent = 'Decode: failed'; + return; + } + const data = await res.json(); + decodeResultEl.textContent = `Decode: ${String(data.stdout || '').slice(0, 80)}`; + }); +} +jumpToEventBtn.addEventListener('click', () => { + const ev = eventsById.get(selectedEventId); + if (!ev) return; + tuneToFrequency(ev.center_hz); +}); + +timelineCanvas.addEventListener('click', (ev) => { + const rect = timelineCanvas.getBoundingClientRect(); + const x = (ev.clientX - rect.left) * (timelineCanvas.width / rect.width); + const y = (ev.clientY - rect.top) * (timelineCanvas.height / rect.height); + for (let i = timelineRects.length - 1; i >= 0; i--) { + const r = timelineRects[i]; + if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) { + openDrawer(eventsById.get(r.id)); + return; + } + } +}); + +signalList.addEventListener('click', (ev) => { + const target = ev.target.closest('.signal-item'); + if (!target) return; + // Select this signal for live listening — don't retune the SDR + const allItems = signalList.querySelectorAll('.signal-item'); + allItems.forEach(el => el.classList.remove('active')); + target.classList.add('active'); + // Store selected signal data for Live Listen button + window._selectedSignal = { + freq: parseFloat(target.dataset.center), + bw: parseFloat(target.dataset.bw || '12000'), + mode: target.dataset.class || '' + }; +}); + +if (liveListenBtn) { + liveListenBtn.addEventListener('click', async () => { + // Use selected signal if available, otherwise first in list + let freq, bw, mode; + if (window._selectedSignal) { + freq = window._selectedSignal.freq; + bw = window._selectedSignal.bw; + mode = window._selectedSignal.mode; + } else { + const first = signalList.querySelector('.signal-item'); + if (!first) return; + freq = parseFloat(first.dataset.center); + bw = parseFloat(first.dataset.bw || '12000'); + mode = first.dataset.class || ''; + } + if (!Number.isFinite(freq)) return; + mode = (listenModeSelect?.value === 'Auto') ? (mode || 'NFM') : listenModeSelect.value; + const sec = parseInt(listenSecondsInput?.value || '2', 10); + const url = `/api/demod?freq=${freq}&bw=${bw}&mode=${mode}&sec=${sec}`; + if (liveAudio) { + liveAudio.pause(); + } + liveAudio = new Audio(url); + liveAudio.play().catch(() => {}); + }); +} + +eventList.addEventListener('click', (ev) => { + const target = ev.target.closest('.event-item'); + if (!target) return; + const id = target.dataset.eventId; + openDrawer(eventsById.get(id)); +}); + +if (recordingList) { + recordingList.addEventListener('click', async (ev) => { + const target = ev.target.closest('.recording-item'); + if (!target) return; + const id = target.dataset.id; + const audio = new Audio(`/api/recordings/${id}/audio`); + audio.play(); + if (recordingMetaEl) recordingMetaEl.textContent = `Recording: ${id}`; + if (recordingMetaLink) { + recordingMetaLink.href = `/api/recordings/${id}`; + recordingIQLink.href = `/api/recordings/${id}/iq`; + recordingAudioLink.href = `/api/recordings/${id}/audio`; + } + try { + const res = await fetch(`/api/recordings/${id}`); + if (!res.ok) return; + const meta = await res.json(); + if (decodeResultEl) { + const rds = meta.rds_ps ? `RDS: ${meta.rds_ps}` : ''; + decodeResultEl.textContent = `Decode: ${rds}`; + } + if (classifierScoresEl) { + const scores = meta.classification?.scores; + if (scores && typeof scores === 'object') { + const rows = Object.entries(scores) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([k, v]) => `${k}:${v.toFixed(2)}`) + .join(' · '); + classifierScoresEl.textContent = rows ? `Classifier scores: ${rows}` : 'Classifier scores: -'; + } else { + classifierScoresEl.textContent = 'Classifier scores: -'; + } + } + } catch {} + }); +} + +if (debugOverlayToggle) debugOverlayToggle.checked = showDebugOverlay; + +window.addEventListener('keydown', (ev) => { + if (ev.target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(ev.target.tagName)) return; + if (ev.key === ' ') { + ev.preventDefault(); + followLive = true; + pan = 0; + } else if (ev.key.toLowerCase() === 'f') { + fitView(); + } else if (ev.key.toLowerCase() === 'm') { + maxHold = !maxHold; + maxHoldToggle.checked = maxHold; + if (!maxHold) maxSpectrum = null; + markSpectrumDirty(); + } else if (ev.key.toLowerCase() === 'g') { + gpuToggle.checked = !gpuToggle.checked; + queueConfigUpdate({ use_gpu_fft: gpuToggle.checked }); + } else if (ev.key === '[') { + zoom = Math.max(0.25, zoom * 0.88); + } else if (ev.key === ']') { + zoom = Math.min(24, zoom * 1.12); + } else if (ev.key === 'ArrowLeft') { + pan = Math.max(-0.5, pan - 0.04); + followLive = false; + } else if (ev.key === 'ArrowRight') { + pan = Math.min(0.5, pan + 0.04); + followLive = false; + } +}); + +loadConfig(); +loadStats(); +loadGPU(); +fetchEvents(true); +fetchRecordings(); +loadDecoders(); +connect(); +requestAnimationFrame(renderLoop); +setInterval(loadStats, 1000); +setInterval(loadGPU, 1000); +setInterval(() => fetchEvents(false), 2000); +setInterval(fetchRecordings, 5000); +setInterval(loadSignals, 1500); +setInterval(loadDecoders, 10000); + +