Bläddra i källkod

Add optional cuFFT GPU FFT path

master
Jan Svabenik 4 dagar sedan
förälder
incheckning
6dcedf6744
10 ändrade filer med 263 tillägg och 5 borttagningar
  1. +14
    -1
      README.md
  2. +90
    -3
      cmd/sdrd/main.go
  3. +1
    -0
      config.yaml
  4. +2
    -0
      internal/config/config.go
  5. +18
    -0
      internal/fft/fft.go
  6. +88
    -0
      internal/fft/gpufft/gpu.go
  7. +19
    -0
      internal/fft/gpufft/stub.go
  8. +4
    -0
      internal/runtime/runtime.go
  9. +23
    -1
      web/app.js
  10. +4
    -0
      web/index.html

+ 14
- 1
README.md Visa fil

@@ -10,6 +10,7 @@ Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event
- Events API (`/api/events?limit=...&since=...`)
- Runtime UI controls for center frequency, span, sample rate, tuner bandwidth, FFT size, gain, AGC, DC block, IQ balance, detector threshold
- Display controls: averaging + max-hold
- Optional GPU FFT (cuFFT) with toggle + `/api/gpu`
- Recorded clips list placeholder (metadata only for now)
- Windows + Linux support
- Mock mode for testing without hardware
@@ -34,6 +35,16 @@ go build -tags sdrplay ./cmd/sdrd
.\sdrd.exe -config config.yaml
```

#### Windows (GPU FFT / cuFFT)
Requires the NVIDIA CUDA Toolkit installed (cuFFT + cudart). Ensure CUDA `bin` and `lib/x64` are on PATH/LIB.
```powershell
$env:CGO_CFLAGS='-IC:\Program Files\SDRplay\API\inc -IC:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.4\include'
$env:CGO_LDFLAGS='-LC:\Program Files\SDRplay\API\x64 -lsdrplay_api -LC:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.4\lib\x64 -lcufft -lcudart'

go build -tags "sdrplay,cufft" ./cmd/sdrd
.\sdrd.exe -config config.yaml
```

### Linux
```bash
export CGO_CFLAGS='-I/opt/sdrplay_api/include'
@@ -51,6 +62,7 @@ Edit `config.yaml`:
- `fft_size`: FFT size
- `gain_db`: device gain (gain reduction)
- `tuner_bw_khz`: tuner bandwidth (200/300/600/1536/5000/6000/7000/8000)
- `use_gpu_fft`: enable GPU FFT (requires CUDA + cufft build tag)
- `agc`: enable automatic gain control
- `dc_block`: enable DC blocking filter
- `iq_balance`: enable basic IQ imbalance correction
@@ -69,8 +81,9 @@ Use the right-side controls to adjust center frequency, span (zoom), sample rate

### Config API
- `GET /api/config`: returns the current runtime configuration.
- `POST /api/config`: updates `center_hz`, `sample_rate`, `fft_size`, `gain_db`, and `detector.threshold_db` at runtime.
- `POST /api/config`: updates `center_hz`, `sample_rate`, `fft_size`, `gain_db`, `tuner_bw_khz`, `use_gpu_fft`, and `detector.threshold_db` at runtime.
- `POST /api/sdr/settings`: updates `agc`, `dc_block`, and `iq_balance` at runtime.
- `GET /api/gpu`: reports GPU FFT availability/active status.

### Events API
`/api/events` reads from the JSONL event log and returns the most recent events:


+ 90
- 3
cmd/sdrd/main.go Visa fil

@@ -21,6 +21,7 @@ import (
"sdr-visual-suite/internal/dsp"
"sdr-visual-suite/internal/events"
fftutil "sdr-visual-suite/internal/fft"
"sdr-visual-suite/internal/fft/gpufft"
"sdr-visual-suite/internal/mock"
"sdr-visual-suite/internal/runtime"
"sdr-visual-suite/internal/sdr"
@@ -41,6 +42,30 @@ type hub struct {
clients map[*websocket.Conn]struct{}
}

type gpuStatus struct {
mu sync.RWMutex
Available bool `json:"available"`
Active bool `json:"active"`
Error string `json:"error"`
}

func (g *gpuStatus) set(active bool, err error) {
g.mu.Lock()
defer g.mu.Unlock()
g.Active = active
if err != nil {
g.Error = err.Error()
} else {
g.Error = ""
}
}

func (g *gpuStatus) snapshot() gpuStatus {
g.mu.RLock()
defer g.mu.RUnlock()
return gpuStatus{Available: g.Available, Active: g.Active, Error: g.Error}
}

func newHub() *hub {
return &hub{clients: map[*websocket.Conn]struct{}{}}
}
@@ -126,6 +151,7 @@ type dspUpdate struct {
window []float64
dcBlock bool
iqBalance bool
useGPUFFT bool
}

func pushDSPUpdate(ch chan dspUpdate, update dspUpdate) {
@@ -153,6 +179,7 @@ func main() {
}

cfgManager := runtime.New(cfg)
gpuState := &gpuStatus{Available: gpufft.Available()}

newSource := func(cfg config.Config) (sdr.Source, error) {
if mockFlag {
@@ -203,7 +230,7 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, dspUpdates)
go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, dspUpdates, gpuState)

upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
@@ -271,6 +298,7 @@ func main() {
window: newWindow,
dcBlock: next.DCBlock,
iqBalance: next.IQBalance,
useGPUFFT: next.UseGPUFFT,
})
_ = json.NewEncoder(w).Encode(next)
default:
@@ -321,6 +349,11 @@ func main() {
_ = json.NewEncoder(w).Encode(sdr.SourceStats{})
})

http.HandleFunc("/api/gpu", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(gpuState.snapshot())
})

http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
limit := 200
@@ -364,13 +397,26 @@ func main() {
_ = server.Shutdown(ctxTimeout)
}

func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File, updates <-chan dspUpdate) {
func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File, updates <-chan dspUpdate, gpuState *gpuStatus) {
ticker := time.NewTicker(cfg.FrameInterval())
defer ticker.Stop()
enc := json.NewEncoder(eventFile)
dcBlocker := dsp.NewDCBlocker(0.995)
dcEnabled := cfg.DCBlock
iqEnabled := cfg.IQBalance
useGPU := cfg.UseGPUFFT
var gpuEngine *gpufft.Engine
if useGPU && gpuState != nil && gpuState.Available {
if eng, err := gpufft.New(cfg.FFTSize); err == nil {
gpuEngine = eng
gpuState.set(true, nil)
} else {
gpuState.set(false, err)
useGPU = false
}
} else if gpuState != nil {
gpuState.set(false, nil)
}

gotSamples := false
for {
@@ -378,6 +424,8 @@ func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detecto
case <-ctx.Done():
return
case upd := <-updates:
prevFFT := cfg.FFTSize
prevUseGPU := useGPU
cfg = upd.cfg
if upd.det != nil {
det = upd.det
@@ -387,6 +435,24 @@ func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detecto
}
dcEnabled = upd.dcBlock
iqEnabled = upd.iqBalance
if cfg.FFTSize != prevFFT || cfg.UseGPUFFT != prevUseGPU {
if gpuEngine != nil {
gpuEngine.Close()
gpuEngine = nil
}
useGPU = cfg.UseGPUFFT
if useGPU && gpuState != nil && gpuState.Available {
if eng, err := gpufft.New(cfg.FFTSize); err == nil {
gpuEngine = eng
gpuState.set(true, nil)
} else {
gpuState.set(false, err)
useGPU = false
}
} else if gpuState != nil {
gpuState.set(false, nil)
}
}
dcBlocker.Reset()
ticker.Reset(cfg.FrameInterval())
case <-ticker.C:
@@ -405,7 +471,28 @@ func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detecto
if iqEnabled {
dsp.IQBalance(iq)
}
spectrum := fftutil.Spectrum(iq, window)
var spectrum []float64
if useGPU && gpuEngine != nil {
if len(window) == len(iq) {
for i := 0; i < len(iq); i++ {
v := iq[i]
w := float32(window[i])
iq[i] = complex(real(v)*w, imag(v)*w)
}
}
out, err := gpuEngine.Exec(iq)
if err != nil {
if gpuState != nil {
gpuState.set(false, err)
}
useGPU = false
spectrum = fftutil.Spectrum(iq, window)
} else {
spectrum = fftutil.SpectrumFromFFT(out)
}
} else {
spectrum = fftutil.Spectrum(iq, window)
}
now := time.Now()
finished, signals := det.Process(now, spectrum, cfg.CenterHz)
for _, ev := range finished {


+ 1
- 0
config.yaml Visa fil

@@ -7,6 +7,7 @@ sample_rate: 2048000
fft_size: 2048
gain_db: 30
tuner_bw_khz: 1536
use_gpu_fft: false
agc: false
dc_block: false
iq_balance: false


+ 2
- 0
internal/config/config.go Visa fil

@@ -26,6 +26,7 @@ type Config struct {
FFTSize int `yaml:"fft_size" json:"fft_size"`
GainDb float64 `yaml:"gain_db" json:"gain_db"`
TunerBwKHz int `yaml:"tuner_bw_khz" json:"tuner_bw_khz"`
UseGPUFFT bool `yaml:"use_gpu_fft" json:"use_gpu_fft"`
AGC bool `yaml:"agc" json:"agc"`
DCBlock bool `yaml:"dc_block" json:"dc_block"`
IQBalance bool `yaml:"iq_balance" json:"iq_balance"`
@@ -47,6 +48,7 @@ func Default() Config {
FFTSize: 2048,
GainDb: 30,
TunerBwKHz: 1536,
UseGPUFFT: false,
AGC: false,
DCBlock: false,
IQBalance: false,


+ 18
- 0
internal/fft/fft.go Visa fil

@@ -50,6 +50,24 @@ func Spectrum(iq []complex64, window []float64) []float64 {
return power
}

func SpectrumFromFFT(out []complex64) []float64 {
n := len(out)
if n == 0 {
return nil
}
power := make([]float64, n)
eps := 1e-12
invN := 1.0 / float64(n)
for i := 0; i < n; i++ {
idx := (i + n/2) % n
v := out[idx]
mag := math.Hypot(float64(real(v)), float64(imag(v))) * invN
p := 20 * math.Log10(mag+eps)
power[i] = p
}
return power
}

func cmplxAbs(v complex128) float64 {
return math.Hypot(real(v), imag(v))
}

+ 88
- 0
internal/fft/gpufft/gpu.go Visa fil

@@ -0,0 +1,88 @@
//go:build cufft

package gpufft

/*
#cgo windows LDFLAGS: -lcufft -lcudart
#include <cuda_runtime.h>
#include <cufft.h>
*/
import "C"

import (
"errors"
"fmt"
"unsafe"
)

type Engine struct {
plan C.cufftHandle
n int
data *C.cufftComplex
bytes C.size_t
}

func Available() bool {
var count C.int
if C.cudaGetDeviceCount(&count) != C.cudaSuccess {
return false
}
return count > 0
}

func New(n int) (*Engine, error) {
if n <= 0 {
return nil, errors.New("invalid fft size")
}
if !Available() {
return nil, errors.New("cuda device not available")
}
var plan C.cufftHandle
if C.cufftPlan1d(&plan, C.int(n), C.CUFFT_C2C, 1) != C.CUFFT_SUCCESS {
return nil, errors.New("cufftPlan1d failed")
}
var ptr unsafe.Pointer
bytes := C.size_t(n) * C.size_t(unsafe.Sizeof(C.cufftComplex{}))
if C.cudaMalloc(&ptr, bytes) != C.cudaSuccess {
C.cufftDestroy(plan)
return nil, errors.New("cudaMalloc failed")
}
return &Engine{plan: plan, n: n, data: (*C.cufftComplex)(ptr), bytes: bytes}, nil
}

func (e *Engine) Close() {
if e == nil {
return
}
if e.plan != 0 {
_ = C.cufftDestroy(e.plan)
e.plan = 0
}
if e.data != nil {
_ = C.cudaFree(unsafe.Pointer(e.data))
e.data = nil
}
}

func (e *Engine) Exec(in []complex64) ([]complex64, error) {
if e == nil {
return nil, errors.New("gpu fft not initialized")
}
if len(in) != e.n {
return nil, fmt.Errorf("expected %d samples, got %d", e.n, len(in))
}
if len(in) == 0 {
return nil, nil
}
if C.cudaMemcpy(unsafe.Pointer(e.data), unsafe.Pointer(&in[0]), e.bytes, C.cudaMemcpyHostToDevice) != C.cudaSuccess {
return nil, errors.New("cudaMemcpy H2D failed")
}
if C.cufftExecC2C(e.plan, e.data, e.data, C.CUFFT_FORWARD) != C.CUFFT_SUCCESS {
return nil, errors.New("cufftExecC2C failed")
}
if C.cudaMemcpy(unsafe.Pointer(&in[0]), unsafe.Pointer(e.data), e.bytes, C.cudaMemcpyDeviceToHost) != C.cudaSuccess {
return nil, errors.New("cudaMemcpy D2H failed")
}
_ = C.cudaDeviceSynchronize()
return in, nil
}

+ 19
- 0
internal/fft/gpufft/stub.go Visa fil

@@ -0,0 +1,19 @@
//go:build !cufft

package gpufft

import "errors"

type Engine struct{}

func Available() bool { return false }

func New(n int) (*Engine, error) {
return nil, errors.New("cufft build tag not enabled")
}

func (e *Engine) Close() {}

func (e *Engine) Exec(in []complex64) ([]complex64, error) {
return nil, errors.New("cufft build tag not enabled")
}

+ 4
- 0
internal/runtime/runtime.go Visa fil

@@ -13,6 +13,7 @@ type ConfigUpdate struct {
FFTSize *int `json:"fft_size"`
GainDb *float64 `json:"gain_db"`
TunerBwKHz *int `json:"tuner_bw_khz"`
UseGPUFFT *bool `json:"use_gpu_fft"`
Detector *DetectorUpdate `json:"detector"`
}

@@ -78,6 +79,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {
}
next.TunerBwKHz = *update.TunerBwKHz
}
if update.UseGPUFFT != nil {
next.UseGPUFFT = *update.UseGPUFFT
}
if update.Detector != nil {
if update.Detector.ThresholdDb != nil {
next.Detector.ThresholdDb = *update.Detector.ThresholdDb


+ 23
- 1
web/app.js Visa fil

@@ -29,6 +29,7 @@ const iqToggle = document.getElementById('iqToggle');
const avgSelect = document.getElementById('avgSelect');
const maxHoldToggle = document.getElementById('maxHoldToggle');
const maxHoldReset = document.getElementById('maxHoldReset');
const gpuToggle = document.getElementById('gpuToggle');
const presetButtons = Array.from(document.querySelectorAll('.preset-btn'));

let latest = null;
@@ -52,6 +53,7 @@ let maxHold = false;
let maxSpectrum = null;
let lastFFTSize = null;
let stats = { buffer_samples: 0, dropped: 0, resets: 0 };
let gpuInfo = { available: false, active: false, error: '' };

const events = [];
const eventsById = new Map();
@@ -121,6 +123,7 @@ function applyConfigToUI(cfg) {
agcToggle.checked = !!cfg.agc;
dcToggle.checked = !!cfg.dc_block;
iqToggle.checked = !!cfg.iq_balance;
if (gpuToggle) gpuToggle.checked = !!cfg.use_gpu_fft;
isSyncingConfig = false;
}

@@ -151,6 +154,17 @@ async function loadStats() {
}
}

async function loadGPU() {
try {
const res = await fetch('/api/gpu');
if (!res.ok) return;
const data = await res.json();
gpuInfo = data || gpuInfo;
} catch (err) {
// ignore
}
}

function queueConfigUpdate(partial) {
if (isSyncingConfig) return;
pendingConfigUpdate = { ...(pendingConfigUpdate || {}), ...partial };
@@ -338,7 +352,8 @@ function renderSpectrum() {
}

const binHz = sample_rate / n;
metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin | Buf ${stats.buffer_samples} Drop ${stats.dropped} Reset ${stats.resets}`;
const gpuState = gpuInfo.active ? 'GPU:ON' : (gpuInfo.available ? 'GPU:OFF' : 'GPU:N/A');
metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin | Buf ${stats.buffer_samples} Drop ${stats.dropped} Reset ${stats.resets} | ${gpuState}`;
}

function renderWaterfall() {
@@ -607,6 +622,12 @@ if (maxHoldReset) {
});
}

if (gpuToggle) {
gpuToggle.addEventListener('change', () => {
queueConfigUpdate({ use_gpu_fft: gpuToggle.checked });
});
}

fftSelect.addEventListener('change', () => {
const size = parseInt(fftSelect.value, 10);
if (Number.isFinite(size)) {
@@ -771,3 +792,4 @@ requestAnimationFrame(tick);
fetchEvents(true);
setInterval(() => fetchEvents(false), 2000);
setInterval(loadStats, 1000);
setInterval(loadGPU, 1000);

+ 4
- 0
web/index.html Visa fil

@@ -117,6 +117,10 @@
<span>Max Hold</span>
</label>
<button id="maxHoldReset" type="button" class="preset-btn">Reset Max</button>
<label class="toggle">
<input id="gpuToggle" type="checkbox" />
<span>GPU FFT</span>
</label>
</div>
</div>
</section>


Laddar…
Avbryt
Spara