diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index b5c00be..5c24cbf 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index cf7187c..62a3d97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, }, diff --git a/internal/demod/am.go b/internal/demod/am.go new file mode 100644 index 0000000..dcdc26f --- /dev/null +++ b/internal/demod/am.go @@ -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{}) } diff --git a/internal/demod/cw.go b/internal/demod/cw.go new file mode 100644 index 0000000..55c6970 --- /dev/null +++ b/internal/demod/cw.go @@ -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{}) } diff --git a/internal/demod/demod.go b/internal/demod/demod.go new file mode 100644 index 0000000..ad11034 --- /dev/null +++ b/internal/demod/demod.go @@ -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 +} diff --git a/internal/demod/fm.go b/internal/demod/fm.go new file mode 100644 index 0000000..23bbe7d --- /dev/null +++ b/internal/demod/fm.go @@ -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{}) +} diff --git a/internal/demod/ssb.go b/internal/demod/ssb.go new file mode 100644 index 0000000..d488021 --- /dev/null +++ b/internal/demod/ssb.go @@ -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{}) +} diff --git a/internal/dsp/fir.go b/internal/dsp/fir.go new file mode 100644 index 0000000..a2b1f92 --- /dev/null +++ b/internal/dsp/fir.go @@ -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 +} diff --git a/internal/dsp/freqshift.go b/internal/dsp/freqshift.go new file mode 100644 index 0000000..d38da6b --- /dev/null +++ b/internal/dsp/freqshift.go @@ -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 +} diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go new file mode 100644 index 0000000..be05c0e --- /dev/null +++ b/internal/recorder/demod.go @@ -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 "" + } +} diff --git a/internal/recorder/recorder.go b/internal/recorder/recorder.go index a3aab31..76b0063 100644 --- a/internal/recorder/recorder.go +++ b/internal/recorder/recorder.go @@ -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) } diff --git a/internal/recorder/wavwriter.go b/internal/recorder/wavwriter.go new file mode 100644 index 0000000..b74be89 --- /dev/null +++ b/internal/recorder/wavwriter.go @@ -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 +}