Bladeren bron

Add DSP band-extract and demodulators with WAV output

master
Jan Svabenik 4 dagen geleden
bovenliggende
commit
6e3b602a20
12 gewijzigde bestanden met toevoegingen van 430 en 12 verwijderingen
  1. +6
    -2
      cmd/sdrd/main.go
  2. +4
    -0
      internal/config/config.go
  3. +28
    -0
      internal/demod/am.go
  4. +27
    -0
      internal/demod/cw.go
  5. +25
    -0
      internal/demod/demod.go
  6. +40
    -0
      internal/demod/fm.go
  7. +42
    -0
      internal/demod/ssb.go
  8. +66
    -0
      internal/dsp/fir.go
  9. +23
    -0
      internal/dsp/freqshift.go
  10. +68
    -0
      internal/recorder/demod.go
  11. +23
    -10
      internal/recorder/recorder.go
  12. +78
    -0
      internal/recorder/wavwriter.go

+ 6
- 2
cmd/sdrd/main.go Bestand weergeven

@@ -300,10 +300,12 @@ func main() {
MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second),
PrerollMs: cfg.Recorder.PrerollMs,
RecordIQ: cfg.Recorder.RecordIQ,
RecordAudio: cfg.Recorder.RecordAudio,
AutoDemod: cfg.Recorder.AutoDemod,
OutputDir: cfg.Recorder.OutputDir,
ClassFilter: cfg.Recorder.ClassFilter,
RingSeconds: cfg.Recorder.RingSeconds,
})
}, cfg.CenterHz)

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

@@ -551,10 +553,12 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second),
PrerollMs: cfg.Recorder.PrerollMs,
RecordIQ: cfg.Recorder.RecordIQ,
RecordAudio: cfg.Recorder.RecordAudio,
AutoDemod: cfg.Recorder.AutoDemod,
OutputDir: cfg.Recorder.OutputDir,
ClassFilter: cfg.Recorder.ClassFilter,
RingSeconds: cfg.Recorder.RingSeconds,
})
}, cfg.CenterHz)
}
if upd.det != nil {
det = upd.det


+ 4
- 0
internal/config/config.go Bestand weergeven

@@ -26,6 +26,8 @@ type RecorderConfig struct {
MaxDuration string `yaml:"max_duration" json:"max_duration"`
PrerollMs int `yaml:"preroll_ms" json:"preroll_ms"`
RecordIQ bool `yaml:"record_iq" json:"record_iq"`
RecordAudio bool `yaml:"record_audio" json:"record_audio"`
AutoDemod bool `yaml:"auto_demod" json:"auto_demod"`
OutputDir string `yaml:"output_dir" json:"output_dir"`
ClassFilter []string `yaml:"class_filter" json:"class_filter"`
RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"`
@@ -73,6 +75,8 @@ func Default() Config {
MaxDuration: "300s",
PrerollMs: 500,
RecordIQ: true,
RecordAudio: false,
AutoDemod: true,
OutputDir: "data/recordings",
RingSeconds: 8,
},


+ 28
- 0
internal/demod/am.go Bestand weergeven

@@ -0,0 +1,28 @@
package demod

import "math"

type AM struct{}

func (AM) Name() string { return "AM" }
func (AM) OutputSampleRate() int { return 48000 }

func (AM) Demod(iq []complex64, sampleRate int) []float32 {
if len(iq) == 0 {
return nil
}
out := make([]float32, len(iq))
var mean float64
for i, v := range iq {
mag := math.Hypot(float64(real(v)), float64(imag(v)))
mean += mag
out[i] = float32(mag)
}
mean /= float64(len(iq))
for i := range out {
out[i] -= float32(mean)
}
return out
}

func init() { Register(AM{}) }

+ 27
- 0
internal/demod/cw.go Bestand weergeven

@@ -0,0 +1,27 @@
package demod

import "math"

type CW struct{}

func (CW) Name() string { return "CW" }
func (CW) OutputSampleRate() int { return 48000 }

func (CW) Demod(iq []complex64, sampleRate int) []float32 {
if len(iq) == 0 {
return nil
}
out := make([]float32, len(iq))
bfo := 700.0
phase := 0.0
inc := 2 * math.Pi * bfo / float64(sampleRate)
for i := 0; i < len(iq); i++ {
phase += inc
c := math.Cos(phase)
v := iq[i]
out[i] = float32(float64(real(v)) * c)
}
return out
}

func init() { Register(CW{}) }

+ 25
- 0
internal/demod/demod.go Bestand weergeven

@@ -0,0 +1,25 @@
package demod

type Demodulator interface {
Name() string
Demod(iq []complex64, sampleRate int) []float32
OutputSampleRate() int
}

var registry = map[string]Demodulator{}

func Register(d Demodulator) {
registry[d.Name()] = d
}

func Get(name string) Demodulator {
return registry[name]
}

func Names() []string {
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
return out
}

+ 40
- 0
internal/demod/fm.go Bestand weergeven

@@ -0,0 +1,40 @@
package demod

import "math"

type NFM struct{}

type WFM struct{}

func (NFM) Name() string { return "NFM" }
func (WFM) Name() string { return "WFM" }
func (NFM) OutputSampleRate() int { return 48000 }
func (WFM) OutputSampleRate() int { return 192000 }

func (NFM) Demod(iq []complex64, sampleRate int) []float32 {
return fmDiscrim(iq)
}

func (WFM) Demod(iq []complex64, sampleRate int) []float32 {
return fmDiscrim(iq)
}

func fmDiscrim(iq []complex64) []float32 {
if len(iq) < 2 {
return nil
}
out := make([]float32, len(iq)-1)
for i := 1; i < len(iq); i++ {
p := iq[i-1]
c := iq[i]
num := float64(real(p))*float64(imag(c)) - float64(imag(p))*float64(real(c))
den := float64(real(p))*float64(real(c)) + float64(imag(p))*float64(imag(c))
out[i-1] = float32(math.Atan2(num, den))
}
return out
}

func init() {
Register(NFM{})
Register(WFM{})
}

+ 42
- 0
internal/demod/ssb.go Bestand weergeven

@@ -0,0 +1,42 @@
package demod

import "math"

type USB struct{}

type LSB struct{}

func (USB) Name() string { return "USB" }
func (LSB) Name() string { return "LSB" }
func (USB) OutputSampleRate() int { return 48000 }
func (LSB) OutputSampleRate() int { return 48000 }

func (USB) Demod(iq []complex64, sampleRate int) []float32 { return ssb(iq, sampleRate, true) }
func (LSB) Demod(iq []complex64, sampleRate int) []float32 { return ssb(iq, sampleRate, false) }

func ssb(iq []complex64, sampleRate int, usb bool) []float32 {
if len(iq) == 0 {
return nil
}
out := make([]float32, len(iq))
bfo := 700.0
if !usb {
bfo = -700.0
}
phase := 0.0
inc := 2 * math.Pi * bfo / float64(sampleRate)
for i := 0; i < len(iq); i++ {
phase += inc
c := math.Cos(phase)
s := math.Sin(phase)
v := iq[i]
// product detector
out[i] = float32(float64(real(v))*c - float64(imag(v))*s)
}
return out
}

func init() {
Register(USB{})
Register(LSB{})
}

+ 66
- 0
internal/dsp/fir.go Bestand weergeven

@@ -0,0 +1,66 @@
package dsp

import "math"

// LowpassFIR returns windowed-sinc lowpass taps (Hann).
func LowpassFIR(cutoffHz float64, sampleRate int, taps int) []float64 {
if taps%2 == 0 {
taps++
}
out := make([]float64, taps)
fc := cutoffHz / float64(sampleRate)
if fc <= 0 {
return out
}
m := float64(taps-1) / 2.0
for n := 0; n < taps; n++ {
x := float64(n) - m
var sinc float64
if x == 0 {
sinc = 2 * fc
} else {
sinc = math.Sin(2*math.Pi*fc*x) / (math.Pi * x)
}
w := 0.5 * (1 - math.Cos(2*math.Pi*float64(n)/float64(taps-1)))
out[n] = float64(sinc) * w
}
return out
}

// ApplyFIR applies real FIR taps to complex IQ.
func ApplyFIR(iq []complex64, taps []float64) []complex64 {
if len(iq) == 0 || len(taps) == 0 {
return nil
}
out := make([]complex64, len(iq))
n := len(taps)
for i := 0; i < len(iq); i++ {
var accR, accI float64
for k := 0; k < n; k++ {
idx := i - k
if idx < 0 {
break
}
v := iq[idx]
w := taps[k]
accR += float64(real(v)) * w
accI += float64(imag(v)) * w
}
out[i] = complex(float32(accR), float32(accI))
}
return out
}

// Decimate keeps every nth sample.
func Decimate(iq []complex64, factor int) []complex64 {
if factor <= 1 {
out := make([]complex64, len(iq))
copy(out, iq)
return out
}
out := make([]complex64, 0, len(iq)/factor+1)
for i := 0; i < len(iq); i += factor {
out = append(out, iq[i])
}
return out
}

+ 23
- 0
internal/dsp/freqshift.go Bestand weergeven

@@ -0,0 +1,23 @@
package dsp

import "math"

// FreqShift mixes IQ by -offsetHz to shift signal to baseband.
func FreqShift(iq []complex64, sampleRate int, offsetHz float64) []complex64 {
if len(iq) == 0 || sampleRate <= 0 || offsetHz == 0 {
out := make([]complex64, len(iq))
copy(out, iq)
return out
}
out := make([]complex64, len(iq))
phase := 0.0
inc := -2 * math.Pi * offsetHz / float64(sampleRate)
for i := 0; i < len(iq); i++ {
phase += inc
re := math.Cos(phase)
im := math.Sin(phase)
v := iq[i]
out[i] = complex(float32(float64(real(v))*re-float64(imag(v))*im), float32(float64(real(v))*im+float64(imag(v))*re))
}
return out
}

+ 68
- 0
internal/recorder/demod.go Bestand weergeven

@@ -0,0 +1,68 @@
package recorder

import (
"errors"
"path/filepath"

"sdr-visual-suite/internal/classifier"
"sdr-visual-suite/internal/demod"
"sdr-visual-suite/internal/detector"
"sdr-visual-suite/internal/dsp"
)

func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, files map[string]any) error {
if ev.Class == nil {
return nil
}
name := mapClassToDemod(ev.Class.ModType)
if name == "" {
return nil
}
d := demod.Get(name)
if d == nil {
return errors.New("demodulator not found")
}
// 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
}
taps := dsp.LowpassFIR(cutoff, m.sampleRate, 101)
filtered := dsp.ApplyFIR(shifted, taps)
decim := m.sampleRate / (d.OutputSampleRate() * 4)
if decim < 1 {
decim = 1
}
dec := dsp.Decimate(filtered, decim)
audio := d.Demod(dec, m.sampleRate/decim)
wav := filepath.Join(dir, "audio.wav")
if err := writeWAV(wav, audio, d.OutputSampleRate()); err != nil {
return err
}
files["audio"] = "audio.wav"
files["audio_sample_rate"] = d.OutputSampleRate()
files["audio_demod"] = name
return nil
}

func mapClassToDemod(c classifier.SignalClass) string {
switch c {
case classifier.ClassAM:
return "AM"
case classifier.ClassNFM:
return "NFM"
case classifier.ClassWFM:
return "WFM"
case classifier.ClassSSBUSB:
return "USB"
case classifier.ClassSSBLSB:
return "LSB"
case classifier.ClassCW:
return "CW"
default:
return ""
}
}

+ 23
- 10
internal/recorder/recorder.go Bestand weergeven

@@ -18,6 +18,8 @@ type Policy struct {
MaxDuration time.Duration `yaml:"max_duration" json:"max_duration"`
PrerollMs int `yaml:"preroll_ms" json:"preroll_ms"`
RecordIQ bool `yaml:"record_iq" json:"record_iq"`
RecordAudio bool `yaml:"record_audio" json:"record_audio"`
AutoDemod bool `yaml:"auto_demod" json:"auto_demod"`
OutputDir string `yaml:"output_dir" json:"output_dir"`
ClassFilter []string `yaml:"class_filter" json:"class_filter"`
RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"`
@@ -28,22 +30,24 @@ type Manager struct {
ring *Ring
sampleRate int
blockSize int
centerHz float64
}

func New(sampleRate int, blockSize int, policy Policy) *Manager {
func New(sampleRate int, blockSize int, policy Policy, centerHz float64) *Manager {
if policy.OutputDir == "" {
policy.OutputDir = "data/recordings"
}
if policy.RingSeconds <= 0 {
policy.RingSeconds = 8
}
return &Manager{policy: policy, ring: NewRing(sampleRate, blockSize, policy.RingSeconds), sampleRate: sampleRate, blockSize: blockSize}
return &Manager{policy: policy, ring: NewRing(sampleRate, blockSize, policy.RingSeconds), sampleRate: sampleRate, blockSize: blockSize, centerHz: centerHz}
}

func (m *Manager) Update(sampleRate int, blockSize int, policy Policy) {
func (m *Manager) Update(sampleRate int, blockSize int, policy Policy, centerHz float64) {
m.policy = policy
m.sampleRate = sampleRate
m.blockSize = blockSize
m.centerHz = centerHz
if m.ring == nil {
m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds)
return
@@ -93,7 +97,7 @@ func (m *Manager) recordEvent(ev detector.Event) error {
return nil
}
}
if !m.policy.RecordIQ {
if !m.policy.RecordIQ && !m.policy.RecordAudio {
return nil
}

@@ -113,13 +117,22 @@ func (m *Manager) recordEvent(ev detector.Event) error {
return err
}
files := map[string]any{}
path := filepath.Join(dir, "signal.cf32")
if err := writeCF32(path, segment); err != nil {
return err
if m.policy.RecordIQ {
path := filepath.Join(dir, "signal.cf32")
if err := writeCF32(path, segment); err != nil {
return err
}
files["iq"] = "signal.cf32"
files["iq_format"] = "cf32"
files["iq_sample_rate"] = m.sampleRate
}

// Optional demod + audio
if m.policy.RecordAudio && m.policy.AutoDemod && ev.Class != nil {
if err := m.demodAndWrite(dir, ev, segment, files); err != nil {
return err
}
}
files["iq"] = "signal.cf32"
files["iq_format"] = "cf32"
files["iq_sample_rate"] = m.sampleRate

return writeMeta(dir, ev, m.sampleRate, files)
}

+ 78
- 0
internal/recorder/wavwriter.go Bestand weergeven

@@ -0,0 +1,78 @@
package recorder

import (
"encoding/binary"
"os"
)

func writeWAV(path string, samples []float32, sampleRate int) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()

// 16-bit PCM
dataSize := uint32(len(samples) * 2)
// RIFF header
if _, err := f.Write([]byte("RIFF")); err != nil {
return err
}
if err := binary.Write(f, binary.LittleEndian, uint32(36)+dataSize); err != nil {
return err
}
if _, err := f.Write([]byte("WAVE")); err != nil {
return err
}
// fmt chunk
if _, err := f.Write([]byte("fmt ")); err != nil {
return err
}
if err := binary.Write(f, binary.LittleEndian, uint32(16)); err != nil {
return err
}
if err := binary.Write(f, binary.LittleEndian, uint16(1)); err != nil { // PCM
return err
}
if err := binary.Write(f, binary.LittleEndian, uint16(1)); err != nil { // mono
return err
}
if err := binary.Write(f, binary.LittleEndian, uint32(sampleRate)); err != nil {
return err
}
byteRate := uint32(sampleRate * 2)
if err := binary.Write(f, binary.LittleEndian, byteRate); err != nil {
return err
}
if err := binary.Write(f, binary.LittleEndian, uint16(2)); err != nil { // block align
return err
}
if err := binary.Write(f, binary.LittleEndian, uint16(16)); err != nil { // bits
return err
}
// data chunk
if _, err := f.Write([]byte("data")); err != nil {
return err
}
if err := binary.Write(f, binary.LittleEndian, dataSize); err != nil {
return err
}
// samples
for _, s := range samples {
v := int16(clip(s * 32767))
if err := binary.Write(f, binary.LittleEndian, v); err != nil {
return err
}
}
return nil
}

func clip(v float32) float32 {
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return v
}

Laden…
Annuleren
Opslaan