| @@ -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 | |||
| @@ -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, | |||
| }, | |||
| @@ -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{}) } | |||
| @@ -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{}) } | |||
| @@ -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 | |||
| } | |||
| @@ -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{}) | |||
| } | |||
| @@ -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{}) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 "" | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||