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