Browse Source

Checkpoint current working SDR pipeline state

master
Jan Svabenik 1 day ago
parent
commit
c6b128b43f
26 changed files with 2067 additions and 217 deletions
  1. +10
    -0
      build-sdrplay.ps1
  2. +136
    -12
      cmd/sdrd/dsp_loop.go
  3. +39
    -10
      cmd/sdrd/helpers.go
  4. +9
    -1
      internal/classifier/classifier.go
  5. +1
    -1
      internal/classifier/classifier_test.go
  6. +5
    -5
      internal/classifier/hard_rules.json
  7. +8
    -2
      internal/classifier/pll.go
  8. +2
    -0
      internal/classifier/types.go
  9. +28
    -24
      internal/demod/fm.go
  10. +4
    -2
      internal/demod/gpudemod/batch_runner.go
  11. +7
    -0
      internal/demod/gpudemod/batch_runner_other.go
  12. +5
    -15
      internal/demod/gpudemod/batch_runner_windows.go
  13. BIN
      internal/demod/gpudemod/build/gpudemod_kernels.exp
  14. BIN
      internal/demod/gpudemod/build/gpudemod_kernels.lib
  15. +29
    -0
      internal/demod/gpudemod/native/exports.cu
  16. +173
    -66
      internal/detector/detector.go
  17. +181
    -53
      internal/rds/rds.go
  18. +58
    -6
      internal/recorder/cpu_audio.go
  19. +1
    -1
      internal/recorder/demod.go
  20. +48
    -1
      internal/recorder/demod_live.go
  21. +2
    -2
      internal/recorder/gpu_audio.go
  22. +9
    -0
      internal/recorder/rds.go
  23. +32
    -6
      internal/recorder/recorder.go
  24. BIN
      sdr-visual-suite.rar
  25. BIN
      sdr-visual-suite_works.rar
  26. +1280
    -10
      web/app.js

+ 10
- 0
build-sdrplay.ps1 View File

@@ -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' }


+ 136
- 12
cmd/sdrd/dsp_loop.go View File

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

+ 39
- 10
cmd/sdrd/helpers.go View File

@@ -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) {


+ 9
- 1
internal/classifier/classifier.go View File

@@ -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 {


+ 1
- 1
internal/classifier/classifier_test.go View File

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


+ 5
- 5
internal/classifier/hard_rules.json View File

@@ -1,10 +1,10 @@
{
"rules": [
{
"name": "fm_broadcast",
"match": {"min_mhz": 87.5, "max_mhz": 108.0, "min_bw_hz": 50000},
"name": "fm_broadcast_any",
"match": {"min_mhz": 87.5, "max_mhz": 108.0},
"result": {"mod_type": "WFM", "confidence": 0.99},
"note": "FM Broadcast: >50 kHz BW im UKW-Band ist immer WFM"
"note": "FM Broadcast band: ANY signal here is WFM, no BW check needed"
},
{
"name": "airband_am",
@@ -44,9 +44,9 @@
},
{
"name": "wfm_wide_any",
"match": {"min_bw_hz": 100000},
"match": {"min_bw_hz": 80000},
"result": {"mod_type": "WFM", "confidence": 0.95},
"note": "Über 100 kHz BW ist fast immer WFM, unabhängig vom Band"
"note": "Ueber 80 kHz BW ist fast immer WFM"
}
]
}

+ 8
- 2
internal/classifier/pll.go View File

@@ -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 {


+ 2
- 0
internal/classifier/types.go View File

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

+ 28
- 24
internal/demod/fm.go View File

@@ -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 {


+ 4
- 2
internal/demod/gpudemod/batch_runner.go View File

@@ -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


+ 7
- 0
internal/demod/gpudemod/batch_runner_other.go View File

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


+ 5
- 15
internal/demod/gpudemod/batch_runner_windows.go View File

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


BIN
internal/demod/gpudemod/build/gpudemod_kernels.exp View File


BIN
internal/demod/gpudemod/build/gpudemod_kernels.lib View File


+ 29
- 0
internal/demod/gpudemod/native/exports.cu View File

@@ -170,6 +170,21 @@ GPUD_API int GPUD_CALL gpud_launch_fir_cuda(
return (int)cudaGetLastError();
}

GPUD_API int GPUD_CALL gpud_launch_fir_stream_cuda(
const float2* in,
float2* out,
int n,
int num_taps,
gpud_stream_handle stream
) {
if (n <= 0 || num_taps <= 0 || num_taps > 256) return 0;
const int block = 256;
const int grid = (n + block - 1) / block;
size_t sharedBytes = (size_t)(block + num_taps - 1) * sizeof(float2);
gpud_fir_kernel<<<grid, block, sharedBytes, (cudaStream_t)stream>>>(in, out, n, num_taps);
return (int)cudaGetLastError();
}

__global__ void gpud_fir_kernel_v2(
const float2* __restrict__ in,
float2* __restrict__ out,
@@ -231,6 +246,20 @@ GPUD_API int GPUD_CALL gpud_launch_decimate_cuda(
return (int)cudaGetLastError();
}

GPUD_API int GPUD_CALL gpud_launch_decimate_stream_cuda(
const float2* in,
float2* out,
int n_out,
int factor,
gpud_stream_handle stream
) {
if (n_out <= 0 || factor <= 0) return 0;
const int block = 256;
const int grid = (n_out + block - 1) / block;
gpud_decimate_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(in, out, n_out, factor);
return (int)cudaGetLastError();
}

__global__ void gpud_am_envelope_kernel(
const float2* __restrict__ in,
float* __restrict__ out,


+ 173
- 66
internal/detector/detector.go View File

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


+ 181
- 53
internal/rds/rds.go View File

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

+ 58
- 6
internal/recorder/cpu_audio.go View File

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

+ 1
- 1
internal/recorder/demod.go View File

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


+ 48
- 1
internal/recorder/demod_live.go View File

@@ -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


+ 2
- 2
internal/recorder/gpu_audio.go View File

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


+ 9
- 0
internal/recorder/rds.go View File

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

+ 32
- 6
internal/recorder/recorder.go View File

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

BIN
sdr-visual-suite.rar View File


BIN
sdr-visual-suite_works.rar View File


+ 1280
- 10
web/app.js
File diff suppressed because it is too large
View File


Loading…
Cancel
Save