| @@ -300,10 +300,12 @@ func main() { | |||||
| MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second), | MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second), | ||||
| PrerollMs: cfg.Recorder.PrerollMs, | PrerollMs: cfg.Recorder.PrerollMs, | ||||
| RecordIQ: cfg.Recorder.RecordIQ, | RecordIQ: cfg.Recorder.RecordIQ, | ||||
| RecordAudio: cfg.Recorder.RecordAudio, | |||||
| AutoDemod: cfg.Recorder.AutoDemod, | |||||
| OutputDir: cfg.Recorder.OutputDir, | OutputDir: cfg.Recorder.OutputDir, | ||||
| ClassFilter: cfg.Recorder.ClassFilter, | ClassFilter: cfg.Recorder.ClassFilter, | ||||
| RingSeconds: cfg.Recorder.RingSeconds, | RingSeconds: cfg.Recorder.RingSeconds, | ||||
| }) | |||||
| }, cfg.CenterHz) | |||||
| go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, eventMu, dspUpdates, gpuState, recMgr) | 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), | MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second), | ||||
| PrerollMs: cfg.Recorder.PrerollMs, | PrerollMs: cfg.Recorder.PrerollMs, | ||||
| RecordIQ: cfg.Recorder.RecordIQ, | RecordIQ: cfg.Recorder.RecordIQ, | ||||
| RecordAudio: cfg.Recorder.RecordAudio, | |||||
| AutoDemod: cfg.Recorder.AutoDemod, | |||||
| OutputDir: cfg.Recorder.OutputDir, | OutputDir: cfg.Recorder.OutputDir, | ||||
| ClassFilter: cfg.Recorder.ClassFilter, | ClassFilter: cfg.Recorder.ClassFilter, | ||||
| RingSeconds: cfg.Recorder.RingSeconds, | RingSeconds: cfg.Recorder.RingSeconds, | ||||
| }) | |||||
| }, cfg.CenterHz) | |||||
| } | } | ||||
| if upd.det != nil { | if upd.det != nil { | ||||
| det = upd.det | det = upd.det | ||||
| @@ -26,6 +26,8 @@ type RecorderConfig struct { | |||||
| MaxDuration string `yaml:"max_duration" json:"max_duration"` | MaxDuration string `yaml:"max_duration" json:"max_duration"` | ||||
| PrerollMs int `yaml:"preroll_ms" json:"preroll_ms"` | PrerollMs int `yaml:"preroll_ms" json:"preroll_ms"` | ||||
| RecordIQ bool `yaml:"record_iq" json:"record_iq"` | 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"` | OutputDir string `yaml:"output_dir" json:"output_dir"` | ||||
| ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ||||
| RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | ||||
| @@ -73,6 +75,8 @@ func Default() Config { | |||||
| MaxDuration: "300s", | MaxDuration: "300s", | ||||
| PrerollMs: 500, | PrerollMs: 500, | ||||
| RecordIQ: true, | RecordIQ: true, | ||||
| RecordAudio: false, | |||||
| AutoDemod: true, | |||||
| OutputDir: "data/recordings", | OutputDir: "data/recordings", | ||||
| RingSeconds: 8, | 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"` | MaxDuration time.Duration `yaml:"max_duration" json:"max_duration"` | ||||
| PrerollMs int `yaml:"preroll_ms" json:"preroll_ms"` | PrerollMs int `yaml:"preroll_ms" json:"preroll_ms"` | ||||
| RecordIQ bool `yaml:"record_iq" json:"record_iq"` | 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"` | OutputDir string `yaml:"output_dir" json:"output_dir"` | ||||
| ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ||||
| RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | ||||
| @@ -28,22 +30,24 @@ type Manager struct { | |||||
| ring *Ring | ring *Ring | ||||
| sampleRate int | sampleRate int | ||||
| blockSize 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 == "" { | if policy.OutputDir == "" { | ||||
| policy.OutputDir = "data/recordings" | policy.OutputDir = "data/recordings" | ||||
| } | } | ||||
| if policy.RingSeconds <= 0 { | if policy.RingSeconds <= 0 { | ||||
| policy.RingSeconds = 8 | 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.policy = policy | ||||
| m.sampleRate = sampleRate | m.sampleRate = sampleRate | ||||
| m.blockSize = blockSize | m.blockSize = blockSize | ||||
| m.centerHz = centerHz | |||||
| if m.ring == nil { | if m.ring == nil { | ||||
| m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | ||||
| return | return | ||||
| @@ -93,7 +97,7 @@ func (m *Manager) recordEvent(ev detector.Event) error { | |||||
| return nil | return nil | ||||
| } | } | ||||
| } | } | ||||
| if !m.policy.RecordIQ { | |||||
| if !m.policy.RecordIQ && !m.policy.RecordAudio { | |||||
| return nil | return nil | ||||
| } | } | ||||
| @@ -113,13 +117,22 @@ func (m *Manager) recordEvent(ev detector.Event) error { | |||||
| return err | return err | ||||
| } | } | ||||
| files := map[string]any{} | 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) | 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 | |||||
| } | |||||