diff --git a/internal/demod/gpudemod/gpudemod.go b/internal/demod/gpudemod/gpudemod.go new file mode 100644 index 0000000..632b411 --- /dev/null +++ b/internal/demod/gpudemod/gpudemod.go @@ -0,0 +1,104 @@ +//go:build cufft + +package gpudemod + +/* +#cgo windows LDFLAGS: -lcufft64_12 -lcudart64_13 +#include +#include +*/ +import "C" + +import ( + "errors" + "math" + + "sdr-visual-suite/internal/demod" + "sdr-visual-suite/internal/dsp" +) + +type DemodType int + +const ( + DemodNFM DemodType = iota + DemodWFM + DemodAM + DemodUSB + DemodLSB + DemodCW +) + +type Engine struct { + maxSamples int + sampleRate int + phase float64 + bfoPhase float64 + firTaps []float32 +} + +func Available() bool { return true } + +func New(maxSamples int, sampleRate int) (*Engine, error) { + if maxSamples <= 0 { + return nil, errors.New("invalid maxSamples") + } + if sampleRate <= 0 { + return nil, errors.New("invalid sampleRate") + } + return &Engine{maxSamples: maxSamples, sampleRate: sampleRate}, nil +} + +func (e *Engine) SetFIR(taps []float32) { + if len(taps) == 0 { + e.firTaps = nil + return + } + e.firTaps = append(e.firTaps[:0], taps...) +} + +func (e *Engine) Demod(iq []complex64, offsetHz float64, bw float64, mode DemodType) ([]float32, int, error) { + if e == nil { + return nil, 0, errors.New("nil CUDA demod engine") + } + if len(iq) == 0 { + return nil, 0, nil + } + if len(iq) > e.maxSamples { + return nil, 0, errors.New("sample count exceeds engine capacity") + } + if mode != DemodNFM { + return nil, 0, errors.New("CUDA demod phase 1 currently supports NFM only") + } + + // Phase 1 conservative scaffold: + // Keep build/tag/CUDA-specific package boundaries now, but use the existing + // CPU DSP implementation as the processing backend until the actual CUDA + // kernels are introduced in later phases. + shifted := dsp.FreqShift(iq, e.sampleRate, offsetHz) + cutoff := bw / 2 + if cutoff < 200 { + cutoff = 200 + } + taps := e.firTaps + if len(taps) == 0 { + base := dsp.LowpassFIR(cutoff, e.sampleRate, 101) + taps = append(make([]float32, 0, len(base)), base...) + } + filtered := dsp.ApplyFIR(shifted, taps) + outRate := demod.NFM{}.OutputSampleRate() + decim := int(math.Round(float64(e.sampleRate) / float64(outRate))) + if decim < 1 { + decim = 1 + } + dec := dsp.Decimate(filtered, decim) + inputRate := e.sampleRate / decim + audio := demod.NFM{}.Demod(dec, inputRate) + return audio, inputRate, nil +} + +func (e *Engine) Close() { + if e == nil { + return + } + e.firTaps = nil +} diff --git a/internal/demod/gpudemod/gpudemod_stub.go b/internal/demod/gpudemod/gpudemod_stub.go new file mode 100644 index 0000000..30ebb18 --- /dev/null +++ b/internal/demod/gpudemod/gpudemod_stub.go @@ -0,0 +1,35 @@ +//go:build !cufft + +package gpudemod + +import "errors" + +type DemodType int + +const ( + DemodNFM DemodType = iota + DemodWFM + DemodAM + DemodUSB + DemodLSB + DemodCW +) + +type Engine struct { + maxSamples int + sampleRate int +} + +func Available() bool { return false } + +func New(maxSamples int, sampleRate int) (*Engine, error) { + return nil, errors.New("CUDA demod not available: cufft build tag not enabled") +} + +func (e *Engine) SetFIR(taps []float32) {} + +func (e *Engine) Demod(iq []complex64, offsetHz float64, bw float64, mode DemodType) ([]float32, int, error) { + return nil, 0, errors.New("CUDA demod not available: cufft build tag not enabled") +} + +func (e *Engine) Close() {} diff --git a/internal/demod/gpudemod/gpudemod_test.go b/internal/demod/gpudemod/gpudemod_test.go new file mode 100644 index 0000000..1725923 --- /dev/null +++ b/internal/demod/gpudemod/gpudemod_test.go @@ -0,0 +1,9 @@ +package gpudemod + +import "testing" + +func TestStubAvailableFalseWithoutCufft(t *testing.T) { + if Available() { + t.Fatal("expected CUDA demod to be unavailable without cufft build tag") + } +} diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go index cf8970f..238d567 100644 --- a/internal/recorder/demod.go +++ b/internal/recorder/demod.go @@ -7,6 +7,7 @@ import ( "sdr-visual-suite/internal/classifier" "sdr-visual-suite/internal/demod" + "sdr-visual-suite/internal/demod/gpudemod" "sdr-visual-suite/internal/detector" "sdr-visual-suite/internal/dsp" ) @@ -26,20 +27,30 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f // band-extract around signal bw := ev.Bandwidth offset := ev.CenterHz - m.centerHz - shifted := dsp.FreqShift(iq, m.sampleRate, offset) - cutoff := bw / 2 - if cutoff < 200 { - cutoff = 200 + var audio []float32 + var inputRate int + if m.gpuDemod != nil && name == "NFM" { + if gpuAudio, gpuRate, err := m.gpuDemod.Demod(iq, offset, bw, gpudemod.DemodNFM); err == nil { + audio = gpuAudio + inputRate = gpuRate + } } - taps := dsp.LowpassFIR(cutoff, m.sampleRate, 101) - filtered := dsp.ApplyFIR(shifted, taps) - decim := int(math.Round(float64(m.sampleRate) / float64(d.OutputSampleRate()))) - if decim < 1 { - decim = 1 + if audio == nil { + shifted := dsp.FreqShift(iq, m.sampleRate, offset) + cutoff := bw / 2 + if cutoff < 200 { + cutoff = 200 + } + taps := dsp.LowpassFIR(cutoff, m.sampleRate, 101) + filtered := dsp.ApplyFIR(shifted, taps) + decim := int(math.Round(float64(m.sampleRate) / float64(d.OutputSampleRate()))) + if decim < 1 { + decim = 1 + } + dec := dsp.Decimate(filtered, decim) + inputRate = m.sampleRate / decim + audio = d.Demod(dec, inputRate) } - dec := dsp.Decimate(filtered, decim) - inputRate := m.sampleRate / decim - audio := d.Demod(dec, inputRate) wav := filepath.Join(dir, "audio.wav") if err := writeWAV(wav, audio, inputRate, d.Channels()); err != nil { return err diff --git a/internal/recorder/recorder.go b/internal/recorder/recorder.go index 56f3889..6b7b8d4 100644 --- a/internal/recorder/recorder.go +++ b/internal/recorder/recorder.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "sdr-visual-suite/internal/demod/gpudemod" "sdr-visual-suite/internal/detector" ) @@ -35,6 +36,7 @@ type Manager struct { centerHz float64 decodeCommands map[string]string queue chan detector.Event + gpuDemod *gpudemod.Engine } func New(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeCommands map[string]string) *Manager { @@ -45,6 +47,7 @@ func New(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeC policy.RingSeconds = 8 } m := &Manager{policy: policy, ring: NewRing(sampleRate, blockSize, policy.RingSeconds), sampleRate: sampleRate, blockSize: blockSize, centerHz: centerHz, decodeCommands: decodeCommands, queue: make(chan detector.Event, 64)} + m.initGPUDemod(sampleRate, blockSize) go m.worker() return m } @@ -55,6 +58,7 @@ func (m *Manager) Update(sampleRate int, blockSize int, policy Policy, centerHz m.blockSize = blockSize m.centerHz = centerHz m.decodeCommands = decodeCommands + m.initGPUDemod(sampleRate, blockSize) if m.ring == nil { m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) return @@ -88,6 +92,21 @@ func (m *Manager) worker() { } } +func (m *Manager) initGPUDemod(sampleRate int, blockSize int) { + if m.gpuDemod != nil { + m.gpuDemod.Close() + m.gpuDemod = nil + } + if !gpudemod.Available() { + return + } + eng, err := gpudemod.New(blockSize, sampleRate) + if err != nil { + return + } + m.gpuDemod = eng +} + func (m *Manager) recordEvent(ev detector.Event) error { if !m.policy.Enabled { return nil