| @@ -0,0 +1,104 @@ | |||||
| //go:build cufft | |||||
| package gpudemod | |||||
| /* | |||||
| #cgo windows LDFLAGS: -lcufft64_12 -lcudart64_13 | |||||
| #include <cuda_runtime.h> | |||||
| #include <cufft.h> | |||||
| */ | |||||
| 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 | |||||
| } | |||||
| @@ -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() {} | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -7,6 +7,7 @@ import ( | |||||
| "sdr-visual-suite/internal/classifier" | "sdr-visual-suite/internal/classifier" | ||||
| "sdr-visual-suite/internal/demod" | "sdr-visual-suite/internal/demod" | ||||
| "sdr-visual-suite/internal/demod/gpudemod" | |||||
| "sdr-visual-suite/internal/detector" | "sdr-visual-suite/internal/detector" | ||||
| "sdr-visual-suite/internal/dsp" | "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 | // band-extract around signal | ||||
| bw := ev.Bandwidth | bw := ev.Bandwidth | ||||
| offset := ev.CenterHz - m.centerHz | 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") | wav := filepath.Join(dir, "audio.wav") | ||||
| if err := writeWAV(wav, audio, inputRate, d.Channels()); err != nil { | if err := writeWAV(wav, audio, inputRate, d.Channels()); err != nil { | ||||
| return err | return err | ||||
| @@ -8,6 +8,7 @@ import ( | |||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| "sdr-visual-suite/internal/demod/gpudemod" | |||||
| "sdr-visual-suite/internal/detector" | "sdr-visual-suite/internal/detector" | ||||
| ) | ) | ||||
| @@ -35,6 +36,7 @@ type Manager struct { | |||||
| centerHz float64 | centerHz float64 | ||||
| decodeCommands map[string]string | decodeCommands map[string]string | ||||
| queue chan detector.Event | queue chan detector.Event | ||||
| gpuDemod *gpudemod.Engine | |||||
| } | } | ||||
| func New(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeCommands map[string]string) *Manager { | 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 | 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 := &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() | go m.worker() | ||||
| return m | return m | ||||
| } | } | ||||
| @@ -55,6 +58,7 @@ func (m *Manager) Update(sampleRate int, blockSize int, policy Policy, centerHz | |||||
| m.blockSize = blockSize | m.blockSize = blockSize | ||||
| m.centerHz = centerHz | m.centerHz = centerHz | ||||
| m.decodeCommands = decodeCommands | m.decodeCommands = decodeCommands | ||||
| m.initGPUDemod(sampleRate, blockSize) | |||||
| if m.ring == nil { | if m.ring == nil { | ||||
| m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | ||||
| return | 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 { | func (m *Manager) recordEvent(ev detector.Event) error { | ||||
| if !m.policy.Enabled { | if !m.policy.Enabled { | ||||
| return nil | return nil | ||||