From f654dfeb51e11e933a04f620c5b79e29e30cb784 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Wed, 18 Mar 2026 12:40:51 +0100 Subject: [PATCH] Fix frontend corruption and recorder/classifier issues --- cmd/sdrd/main.go | 28 ++++++++++++++++++++++++++-- internal/classifier/features.go | 13 +++++-------- internal/classifier/rules.go | 33 +++++++++++++++++++++------------ internal/detector/detector.go | 17 +++++++++++++++-- internal/rds/rds.go | 7 ++++--- internal/recorder/demod.go | 13 ++++++------- internal/recorder/demod_live.go | 10 ++++++---- internal/recorder/iqwriter.go | 18 +++++++++--------- internal/recorder/recorder.go | 15 ++++++++++++++- web/app.js | 22 ---------------------- 10 files changed, 106 insertions(+), 70 deletions(-) diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index c47a2f6..c398d79 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -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 diff --git a/internal/classifier/features.go b/internal/classifier/features.go index a30159a..ed5c5c9 100644 --- a/internal/classifier/features.go +++ b/internal/classifier/features.go @@ -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 -} diff --git a/internal/classifier/rules.go b/internal/classifier/rules.go index e74cdb0..4d2e3f4 100644 --- a/internal/classifier/rules.go +++ b/internal/classifier/rules.go @@ -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 diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 5fa8cee..5dd99e3 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -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, } } diff --git a/internal/rds/rds.go b/internal/rds/rds.go index 70a4d75..02236dc 100644 --- a/internal/rds/rds.go +++ b/internal/rds/rds.go @@ -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 { diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go index b5425c1..cf8970f 100644 --- a/internal/recorder/demod.go +++ b/internal/recorder/demod.go @@ -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" { diff --git a/internal/recorder/demod_live.go b/internal/recorder/demod_live.go index a3e8ad5..6f5fdae 100644 --- a/internal/recorder/demod_live.go +++ b/internal/recorder/demod_live.go @@ -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 } diff --git a/internal/recorder/iqwriter.go b/internal/recorder/iqwriter.go index 2b697f8..6a18415 100644 --- a/internal/recorder/iqwriter.go +++ b/internal/recorder/iqwriter.go @@ -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 } diff --git a/internal/recorder/recorder.go b/internal/recorder/recorder.go index 613f313..56f3889 100644 --- a/internal/recorder/recorder.go +++ b/internal/recorder/recorder.go @@ -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) } } diff --git a/web/app.js b/web/app.js index 41a3aae..38ec495 100644 --- a/web/app.js +++ b/web/app.js @@ -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);