| @@ -777,12 +777,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||
| } | |||
| now := time.Now() | |||
| finished, signals := det.Process(now, spectrum, cfg.CenterHz) | |||
| // enrich classification with temporal IQ features | |||
| // enrich classification with temporal IQ features on per-signal snippet | |||
| if len(iq) > 0 { | |||
| for i := range signals { | |||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb}, spectrum, cfg.SampleRate, cfg.FFTSize, iq) | |||
| snip := extractSignalIQ(iq, cfg.SampleRate, cfg.CenterHz, signals[i].CenterHz, signals[i].BWHz) | |||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb}, spectrum, cfg.SampleRate, cfg.FFTSize, snip) | |||
| signals[i].Class = cls | |||
| } | |||
| det.UpdateClasses(signals) | |||
| } | |||
| if sigSnap != nil { | |||
| sigSnap.set(signals) | |||
| @@ -850,6 +852,28 @@ func decoderKeys(cfg config.Config) []string { | |||
| return keys | |||
| } | |||
| func extractSignalIQ(iq []complex64, sampleRate int, centerHz float64, sigHz float64, bwHz float64) []complex64 { | |||
| if len(iq) == 0 || sampleRate <= 0 { | |||
| return nil | |||
| } | |||
| offset := sigHz - centerHz | |||
| shifted := dsp.FreqShift(iq, sampleRate, offset) | |||
| cutoff := bwHz / 2 | |||
| if cutoff < 200 { | |||
| cutoff = 200 | |||
| } | |||
| if cutoff > float64(sampleRate)/2-1 { | |||
| cutoff = float64(sampleRate)/2 - 1 | |||
| } | |||
| taps := dsp.LowpassFIR(cutoff, sampleRate, 101) | |||
| filtered := dsp.ApplyFIR(shifted, taps) | |||
| decim := sampleRate / 200000 | |||
| if decim < 1 { | |||
| decim = 1 | |||
| } | |||
| return dsp.Decimate(filtered, decim) | |||
| } | |||
| func parseSince(raw string) (time.Time, error) { | |||
| if raw == "" { | |||
| return time.Time{}, nil | |||
| @@ -16,7 +16,11 @@ func ExtractFeatures(s SignalInput, spectrum []float64, sampleRate int, fftSize | |||
| if s.LastBin >= len(spectrum) { | |||
| s.LastBin = len(spectrum) - 1 | |||
| } | |||
| binHz := float64(sampleRate) / float64(max(1, fftSize)) | |||
| den := fftSize | |||
| if den < 1 { | |||
| den = 1 | |||
| } | |||
| binHz := float64(sampleRate) / float64(den) | |||
| // slice | |||
| start := s.FirstBin | |||
| end := s.LastBin | |||
| @@ -158,10 +162,3 @@ func clamp01(v float64) float64 { | |||
| } | |||
| return v | |||
| } | |||
| func max(a, b int) int { | |||
| if a > b { | |||
| return a | |||
| } | |||
| return b | |||
| } | |||
| @@ -26,20 +26,24 @@ func RuleClassify(feat Features) Classification { | |||
| if feat.InstFreqStd < 0.5 && feat.EnvVariance < 0.3 { | |||
| second = ClassDMR | |||
| } | |||
| case bw >= 2000 && bw < 3000: | |||
| // candidate for FT8 | |||
| if feat.EnvVariance < 0.5 && feat.InstFreqStd < 0.7 { | |||
| best = ClassFT8 | |||
| conf = 0.55 | |||
| } | |||
| case bw >= 150 && bw < 500: | |||
| // candidate for WSPR | |||
| if feat.EnvVariance < 0.4 && feat.InstFreqStd < 0.5 { | |||
| best = ClassWSPR | |||
| conf = 0.55 | |||
| case bw >= 3e3 && bw < 6e3: | |||
| // wider SSB/AM | |||
| if sym > 0.2 { | |||
| best = ClassSSBUSB | |||
| conf = 0.65 | |||
| } else if sym < -0.2 { | |||
| best = ClassSSBLSB | |||
| conf = 0.65 | |||
| } else if p2a > 2.5 && flat < 0.5 { | |||
| best = ClassAM | |||
| conf = 0.6 | |||
| } | |||
| case bw >= 500 && bw < 3e3: | |||
| if sym > 0.2 { | |||
| // narrow SSB/AM + digital | |||
| if feat.EnvVariance < 0.6 && feat.InstFreqStd < 0.7 && bw >= 2000 && bw < 3000 { | |||
| best = ClassFT8 | |||
| conf = 0.55 | |||
| } else if sym > 0.2 { | |||
| best = ClassSSBUSB | |||
| conf = 0.7 | |||
| } else if sym < -0.2 { | |||
| @@ -55,6 +59,11 @@ func RuleClassify(feat Features) Classification { | |||
| best = ClassPSK | |||
| conf = 0.5 | |||
| } | |||
| case bw >= 150 && bw < 500: | |||
| if feat.EnvVariance < 0.4 && feat.InstFreqStd < 0.5 { | |||
| best = ClassWSPR | |||
| conf = 0.55 | |||
| } | |||
| case bw < 150: | |||
| best = ClassCW | |||
| conf = 0.7 | |||
| @@ -82,6 +82,21 @@ func (d *Detector) Process(now time.Time, spectrum []float64, centerHz float64) | |||
| return finished, signals | |||
| } | |||
| // UpdateClasses refreshes active event classes from current signals. | |||
| func (d *Detector) UpdateClasses(signals []Signal) { | |||
| for _, s := range signals { | |||
| for _, ev := range d.active { | |||
| if overlapHz(s.CenterHz, s.BWHz, ev.centerHz, ev.bwHz) && math.Abs(s.CenterHz-ev.centerHz) < (s.BWHz+ev.bwHz)/2.0 { | |||
| if s.Class != nil { | |||
| if ev.class == nil || s.Class.Confidence >= ev.class.Confidence { | |||
| ev.class = s.Class | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal { | |||
| n := len(spectrum) | |||
| if n == 0 { | |||
| @@ -122,7 +137,6 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||
| centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth | |||
| bw := float64(last-first+1) * d.binWidth | |||
| snr := peak - noise | |||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins, nil) | |||
| return Signal{ | |||
| FirstBin: first, | |||
| LastBin: last, | |||
| @@ -130,7 +144,6 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||
| BWHz: bw, | |||
| PeakDb: peak, | |||
| SNRDb: snr, | |||
| Class: cls, | |||
| } | |||
| } | |||
| @@ -102,11 +102,12 @@ func decodeBlock(bits []int) (block, bool) { | |||
| } | |||
| data := uint16(raw >> 10) | |||
| synd := crcSyndrome(raw) | |||
| if synd == 0 { | |||
| switch synd { | |||
| case offA, offB, offC, offD: | |||
| return block{data: data, offset: uint16(synd)}, true | |||
| default: | |||
| return block{}, false | |||
| } | |||
| // use syndrome as offset word | |||
| return block{data: data, offset: uint16(synd)}, true | |||
| } | |||
| func crcSyndrome(raw uint32) uint16 { | |||
| @@ -2,6 +2,7 @@ package recorder | |||
| import ( | |||
| "errors" | |||
| "math" | |||
| "path/filepath" | |||
| "sdr-visual-suite/internal/classifier" | |||
| @@ -32,21 +33,19 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f | |||
| } | |||
| taps := dsp.LowpassFIR(cutoff, m.sampleRate, 101) | |||
| filtered := dsp.ApplyFIR(shifted, taps) | |||
| decim := m.sampleRate / d.OutputSampleRate() | |||
| if decim < 1 { | |||
| decim = 1 | |||
| } | |||
| decim := int(math.Round(float64(m.sampleRate) / float64(d.OutputSampleRate()))) | |||
| if decim < 1 { | |||
| decim = 1 | |||
| } | |||
| dec := dsp.Decimate(filtered, decim) | |||
| audio := d.Demod(dec, m.sampleRate/decim) | |||
| inputRate := m.sampleRate / decim | |||
| audio := d.Demod(dec, inputRate) | |||
| wav := filepath.Join(dir, "audio.wav") | |||
| if err := writeWAV(wav, audio, d.OutputSampleRate(), d.Channels()); err != nil { | |||
| if err := writeWAV(wav, audio, inputRate, d.Channels()); err != nil { | |||
| return err | |||
| } | |||
| files["audio"] = "audio.wav" | |||
| files["audio_sample_rate"] = d.OutputSampleRate() | |||
| files["audio_sample_rate"] = inputRate | |||
| files["audio_channels"] = d.Channels() | |||
| files["audio_demod"] = name | |||
| if name == "WFM_STEREO" { | |||
| @@ -3,6 +3,7 @@ package recorder | |||
| import ( | |||
| "bytes" | |||
| "errors" | |||
| "math" | |||
| "time" | |||
| "sdr-visual-suite/internal/demod" | |||
| @@ -47,15 +48,16 @@ func (m *Manager) DemodLive(centerHz float64, bw float64, mode string, seconds i | |||
| } | |||
| taps := dsp.LowpassFIR(cutoff, m.sampleRate, 101) | |||
| filtered := dsp.ApplyFIR(shifted, taps) | |||
| decim := m.sampleRate / d.OutputSampleRate() | |||
| decim := int(math.Round(float64(m.sampleRate) / float64(d.OutputSampleRate()))) | |||
| if decim < 1 { | |||
| decim = 1 | |||
| } | |||
| dec := dsp.Decimate(filtered, decim) | |||
| audio := d.Demod(dec, m.sampleRate/decim) | |||
| inputRate := m.sampleRate / decim | |||
| audio := d.Demod(dec, inputRate) | |||
| buf := &bytes.Buffer{} | |||
| if err := writeWAVTo(buf, audio, d.OutputSampleRate(), d.Channels()); err != nil { | |||
| if err := writeWAVTo(buf, audio, inputRate, d.Channels()); err != nil { | |||
| return nil, 0, err | |||
| } | |||
| return buf.Bytes(), d.OutputSampleRate(), nil | |||
| return buf.Bytes(), inputRate, nil | |||
| } | |||
| @@ -1,8 +1,9 @@ | |||
| package recorder | |||
| import ( | |||
| "encoding/binary" | |||
| "bufio" | |||
| "os" | |||
| "unsafe" | |||
| ) | |||
| func writeCF32(path string, samples []complex64) error { | |||
| @@ -11,13 +12,12 @@ func writeCF32(path string, samples []complex64) error { | |||
| return err | |||
| } | |||
| defer f.Close() | |||
| for _, v := range samples { | |||
| if err := binary.Write(f, binary.LittleEndian, real(v)); err != nil { | |||
| return err | |||
| } | |||
| if err := binary.Write(f, binary.LittleEndian, imag(v)); err != nil { | |||
| return err | |||
| } | |||
| if len(samples) == 0 { | |||
| return nil | |||
| } | |||
| return nil | |||
| w := bufio.NewWriterSize(f, 1<<20) | |||
| defer w.Flush() | |||
| b := unsafe.Slice((*byte)(unsafe.Pointer(&samples[0])), len(samples)*8) | |||
| _, err = w.Write(b) | |||
| return err | |||
| } | |||
| @@ -34,6 +34,7 @@ type Manager struct { | |||
| blockSize int | |||
| centerHz float64 | |||
| decodeCommands map[string]string | |||
| queue chan detector.Event | |||
| } | |||
| func New(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeCommands map[string]string) *Manager { | |||
| @@ -43,7 +44,9 @@ func New(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeC | |||
| if policy.RingSeconds <= 0 { | |||
| policy.RingSeconds = 8 | |||
| } | |||
| return &Manager{policy: policy, ring: NewRing(sampleRate, blockSize, policy.RingSeconds), sampleRate: sampleRate, blockSize: blockSize, centerHz: centerHz, decodeCommands: decodeCommands} | |||
| m := &Manager{policy: policy, ring: NewRing(sampleRate, blockSize, policy.RingSeconds), sampleRate: sampleRate, blockSize: blockSize, centerHz: centerHz, decodeCommands: decodeCommands, queue: make(chan detector.Event, 64)} | |||
| go m.worker() | |||
| return m | |||
| } | |||
| func (m *Manager) Update(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeCommands map[string]string) { | |||
| @@ -71,6 +74,16 @@ func (m *Manager) OnEvents(events []detector.Event) { | |||
| return | |||
| } | |||
| for _, ev := range events { | |||
| select { | |||
| case m.queue <- ev: | |||
| default: | |||
| // drop if queue full | |||
| } | |||
| } | |||
| } | |||
| func (m *Manager) worker() { | |||
| for ev := range m.queue { | |||
| _ = m.recordEvent(ev) | |||
| } | |||
| } | |||
| @@ -1307,26 +1307,4 @@ setInterval(() => fetchEvents(false), 2000); | |||
| setInterval(fetchRecordings, 5000); | |||
| setInterval(loadSignals, 1500); | |||
| setInterval(loadDecoders, 10000); | |||
| lse if (ev.key === 'ArrowLeft') { | |||
| pan = Math.max(-0.5, pan - 0.04); | |||
| followLive = false; | |||
| } else if (ev.key === 'ArrowRight') { | |||
| pan = Math.min(0.5, pan + 0.04); | |||
| followLive = false; | |||
| } | |||
| }); | |||
| loadConfig(); | |||
| loadStats(); | |||
| loadGPU(); | |||
| fetchEvents(true); | |||
| fetchRecordings(); | |||
| connect(); | |||
| requestAnimationFrame(renderLoop); | |||
| setInterval(loadStats, 1000); | |||
| setInterval(loadGPU, 1000); | |||
| setInterval(() => fetchEvents(false), 2000); | |||
| setInterval(fetchRecordings, 5000); | |||
| setInterval(loadSignals, 1500); | |||
| ordings, 5000); | |||
| setInterval(loadSignals, 1500); | |||