| @@ -777,12 +777,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| } | } | ||||
| now := time.Now() | now := time.Now() | ||||
| finished, signals := det.Process(now, spectrum, cfg.CenterHz) | 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 { | if len(iq) > 0 { | ||||
| for i := range signals { | 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 | signals[i].Class = cls | ||||
| } | } | ||||
| det.UpdateClasses(signals) | |||||
| } | } | ||||
| if sigSnap != nil { | if sigSnap != nil { | ||||
| sigSnap.set(signals) | sigSnap.set(signals) | ||||
| @@ -850,6 +852,28 @@ func decoderKeys(cfg config.Config) []string { | |||||
| return keys | 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) { | func parseSince(raw string) (time.Time, error) { | ||||
| if raw == "" { | if raw == "" { | ||||
| return time.Time{}, nil | return time.Time{}, nil | ||||
| @@ -16,7 +16,11 @@ func ExtractFeatures(s SignalInput, spectrum []float64, sampleRate int, fftSize | |||||
| if s.LastBin >= len(spectrum) { | if s.LastBin >= len(spectrum) { | ||||
| s.LastBin = len(spectrum) - 1 | 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 | // slice | ||||
| start := s.FirstBin | start := s.FirstBin | ||||
| end := s.LastBin | end := s.LastBin | ||||
| @@ -158,10 +162,3 @@ func clamp01(v float64) float64 { | |||||
| } | } | ||||
| return v | 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 { | if feat.InstFreqStd < 0.5 && feat.EnvVariance < 0.3 { | ||||
| second = ClassDMR | 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: | 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 | best = ClassSSBUSB | ||||
| conf = 0.7 | conf = 0.7 | ||||
| } else if sym < -0.2 { | } else if sym < -0.2 { | ||||
| @@ -55,6 +59,11 @@ func RuleClassify(feat Features) Classification { | |||||
| best = ClassPSK | best = ClassPSK | ||||
| conf = 0.5 | 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: | case bw < 150: | ||||
| best = ClassCW | best = ClassCW | ||||
| conf = 0.7 | conf = 0.7 | ||||
| @@ -82,6 +82,21 @@ func (d *Detector) Process(now time.Time, spectrum []float64, centerHz float64) | |||||
| return finished, signals | 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 { | func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal { | ||||
| n := len(spectrum) | n := len(spectrum) | ||||
| if n == 0 { | 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 | centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth | ||||
| bw := float64(last-first+1) * d.binWidth | bw := float64(last-first+1) * d.binWidth | ||||
| snr := peak - noise | snr := peak - noise | ||||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins, nil) | |||||
| return Signal{ | return Signal{ | ||||
| FirstBin: first, | FirstBin: first, | ||||
| LastBin: last, | LastBin: last, | ||||
| @@ -130,7 +144,6 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||||
| BWHz: bw, | BWHz: bw, | ||||
| PeakDb: peak, | PeakDb: peak, | ||||
| SNRDb: snr, | SNRDb: snr, | ||||
| Class: cls, | |||||
| } | } | ||||
| } | } | ||||
| @@ -102,11 +102,12 @@ func decodeBlock(bits []int) (block, bool) { | |||||
| } | } | ||||
| data := uint16(raw >> 10) | data := uint16(raw >> 10) | ||||
| synd := crcSyndrome(raw) | 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 | return block{}, false | ||||
| } | } | ||||
| // use syndrome as offset word | |||||
| return block{data: data, offset: uint16(synd)}, true | |||||
| } | } | ||||
| func crcSyndrome(raw uint32) uint16 { | func crcSyndrome(raw uint32) uint16 { | ||||
| @@ -2,6 +2,7 @@ package recorder | |||||
| import ( | import ( | ||||
| "errors" | "errors" | ||||
| "math" | |||||
| "path/filepath" | "path/filepath" | ||||
| "sdr-visual-suite/internal/classifier" | "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) | taps := dsp.LowpassFIR(cutoff, m.sampleRate, 101) | ||||
| filtered := dsp.ApplyFIR(shifted, taps) | 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 { | if decim < 1 { | ||||
| decim = 1 | decim = 1 | ||||
| } | } | ||||
| dec := dsp.Decimate(filtered, decim) | 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") | 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 | return err | ||||
| } | } | ||||
| files["audio"] = "audio.wav" | files["audio"] = "audio.wav" | ||||
| files["audio_sample_rate"] = d.OutputSampleRate() | |||||
| files["audio_sample_rate"] = inputRate | |||||
| files["audio_channels"] = d.Channels() | files["audio_channels"] = d.Channels() | ||||
| files["audio_demod"] = name | files["audio_demod"] = name | ||||
| if name == "WFM_STEREO" { | if name == "WFM_STEREO" { | ||||
| @@ -3,6 +3,7 @@ package recorder | |||||
| import ( | import ( | ||||
| "bytes" | "bytes" | ||||
| "errors" | "errors" | ||||
| "math" | |||||
| "time" | "time" | ||||
| "sdr-visual-suite/internal/demod" | "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) | taps := dsp.LowpassFIR(cutoff, m.sampleRate, 101) | ||||
| filtered := dsp.ApplyFIR(shifted, taps) | filtered := dsp.ApplyFIR(shifted, taps) | ||||
| decim := m.sampleRate / d.OutputSampleRate() | |||||
| decim := int(math.Round(float64(m.sampleRate) / float64(d.OutputSampleRate()))) | |||||
| if decim < 1 { | if decim < 1 { | ||||
| decim = 1 | decim = 1 | ||||
| } | } | ||||
| dec := dsp.Decimate(filtered, decim) | dec := dsp.Decimate(filtered, decim) | ||||
| audio := d.Demod(dec, m.sampleRate/decim) | |||||
| inputRate := m.sampleRate / decim | |||||
| audio := d.Demod(dec, inputRate) | |||||
| buf := &bytes.Buffer{} | 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 nil, 0, err | ||||
| } | } | ||||
| return buf.Bytes(), d.OutputSampleRate(), nil | |||||
| return buf.Bytes(), inputRate, nil | |||||
| } | } | ||||
| @@ -1,8 +1,9 @@ | |||||
| package recorder | package recorder | ||||
| import ( | import ( | ||||
| "encoding/binary" | |||||
| "bufio" | |||||
| "os" | "os" | ||||
| "unsafe" | |||||
| ) | ) | ||||
| func writeCF32(path string, samples []complex64) error { | func writeCF32(path string, samples []complex64) error { | ||||
| @@ -11,13 +12,12 @@ func writeCF32(path string, samples []complex64) error { | |||||
| return err | return err | ||||
| } | } | ||||
| defer f.Close() | 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 | blockSize int | ||||
| centerHz float64 | centerHz float64 | ||||
| decodeCommands map[string]string | decodeCommands map[string]string | ||||
| queue chan detector.Event | |||||
| } | } | ||||
| func New(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeCommands map[string]string) *Manager { | 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 { | if policy.RingSeconds <= 0 { | ||||
| policy.RingSeconds = 8 | 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) { | 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 | return | ||||
| } | } | ||||
| for _, ev := range events { | 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) | _ = m.recordEvent(ev) | ||||
| } | } | ||||
| } | } | ||||
| @@ -1307,26 +1307,4 @@ setInterval(() => fetchEvents(false), 2000); | |||||
| setInterval(fetchRecordings, 5000); | setInterval(fetchRecordings, 5000); | ||||
| setInterval(loadSignals, 1500); | setInterval(loadSignals, 1500); | ||||
| setInterval(loadDecoders, 10000); | 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); | |||||