Explorar el Código

feat: lay wideband pipeline foundation

master
Jan Svabenik hace 7 horas
padre
commit
3c7d4f648e
Se han modificado 11 ficheros con 485 adiciones y 48 borrados
  1. +176
    -0
      PLAN.md
  2. +28
    -7
      README.md
  3. +26
    -21
      cmd/sdrd/dsp_loop.go
  4. +10
    -0
      cmd/sdrd/http_handlers.go
  5. +19
    -0
      config.yaml
  6. +105
    -19
      internal/config/config.go
  7. +13
    -1
      internal/config/config_test.go
  8. +56
    -0
      internal/pipeline/refiner.go
  9. +48
    -0
      internal/pipeline/types.go
  10. +1
    -0
      internal/runtime/runtime.go
  11. +3
    -0
      internal/runtime/runtime_test.go

+ 176
- 0
PLAN.md Ver fichero

@@ -0,0 +1,176 @@
# SDR Wideband Suite — Umbauplan

## Zielbild

Aus `sdr-visual-suite` wird eine **skalierbare, policy-gesteuerte Wideband-SDR-Engine**.

Ziel ist:
- gleiche Grundfunktionen wie heute: Live-Spectrum, Waterfall, Events, Tracking, Classification, Live-Listen, Recording, Decoder
- aber deutlich flexibler und zukunftsfähig
- Bandbreite soll konfigurierbar skalieren: von ~2.5 MHz bis zu den Grenzen von SDR, I/O, GPU und gewähltem Modus
- spätere Nutzung soll über **Konfiguration/Profiles/Policies** erfolgen, nicht über Code-Umbau

## Leitprinzipien

1. **UI entkoppelt von Analysequalität**
- Anzeigeparameter dürfen nicht mehr direkt die Kernanalyse verschlechtern/ändern.
2. **Multi-Resolution statt Ein-FFT-für-alles**
- Globaler Surveillance-Layer mittelfein
- lokale Refinement-Layer hoch / sehr hoch aufgelöst
3. **Candidate-driven Processing**
- Detectoren erzeugen Kandidaten
- Refiner verfeinert Kandidaten lokal
- Classifier/PLL/Decoder arbeiten auf verfeinerten Kandidaten
4. **Policy-driven Operation**
- gewünschtes Verhalten über Betriebsprofile steuerbar
5. **Ressourcenbewusst**
- GPU/CPU/Storage/Latency-Budgets sind Teil der Architektur

## Nicht-Ziele in Phase 1

- Keine vollständige 20–80 MHz-Endlösung in einem Schritt
- Keine perfekte neue GPU-Pipeline über Nacht
- Kein Big-Bang-Delete der bisherigen Pipeline

Phase 1 baut die **Architekturgrundlage**, so dass spätere Skalierung ohne erneuten Komplettumbau möglich ist.

---

## Zielarchitektur

### 1. Acquisition Layer
Verantwortung:
- IQ aus Quelle lesen
- Source-Config anwenden
- Ringbuffer/Chunking verwalten

### 2. Spectrum Engine
Verantwortung:
- Surveillance-Spectrum erzeugen
- später mehrere Resolution-Levels erzeugen
- UI-geeignete decimierte Views ableiten

### 3. Candidate Detection Layer
Verantwortung:
- breitbandig Aktivität/Kandidaten finden
- coarse estimates liefern: center/bw/snr/type-hints

### 4. Refinement Layer
Verantwortung:
- Kandidaten lokal höher aufgelöst nachanalysieren
- center/bw/snr stabilisieren
- IQ-/Feature-Snippets für Classifier vorbereiten

### 5. Classification + Decode Layer
Verantwortung:
- Signaltypen klassifizieren
- PLL / Stereo / RDS / Decoder anwenden
- hochwertige Signalmetadaten erzeugen

### 6. Tracking/Event Layer
Verantwortung:
- Kandidaten über Zeit stabil tracken
- Events erzeugen/schließen
- UI und Recorder mit stabilen Signalen füttern

### 7. Presentation Layer
Verantwortung:
- Overview Spectrum
- decimierte WS-Frames
- Detail-Views
- UI-State ohne Einfluss auf Kernanalyse

---

## Phase-1-Umbau (dieser Arbeitslauf)

### A. Benennung / Projektidentität
- Projektname auf `sdr-wideband-suite` umstellen
- README auf Zielbild anpassen

### B. Konfigurationsmodell vorbereiten
Neue Konfig-Teile einführen:
- `pipeline.mode`
- `surveillance.*`
- `refinement.*`
- `resources.*`
- optionale `profiles.*`

Wichtig:
- Abwärtskompatibilität zur bisherigen Config möglichst erhalten
- bisherige Felder weiterhin nutzbar

### C. Analyse von UI trennen
- `fft_size` als primär **analysis/surveillance**-Parameter behandeln
- UI-seitige Bin-/FPS-Wünsche als reine Presentation-Ebene behandeln
- klare Trennung im Code etablieren

### D. Candidate-/Refinement-Modell einziehen
- neue Candidate-/Refinement-Datentypen einführen
- zunächst mit CPU-/bestehendem GPU-Extraction-Pfad implementieren
- Detector bleibt vorerst Kern der Candidate-Erzeugung
- Refiner sitzt danach explizit als eigener Schritt in der Pipeline

### E. Pipeline-Orchestrierung modularisieren
- `runDSP()` entflechten
- Schritte explizit machen:
- ingest
- spectrum
- detect
- refine
- classify
- track
- present
- record

### F. Dokumentierte Betriebsprofile
- initiale Profile definieren, z. B.:
- `legacy`
- `wideband-balanced`
- `wideband-aggressive`
- `archive`

### G. Tests / Build grün halten
- Go tests ausführen
- Build testen
- erst danach commit/push

---

## Spätere Phasen

### Phase 2
- echte mehrstufige Surveillance-Resolution-Engine
- GPU-seitige Reduction/Decimation
- UI-Detailfenster an Refinement koppeln

### Phase 3
- Scheduler/Priority/Budget-Engine
- Kandidatenpriorisierung
- automatische Decoder-Slot-Vergabe

### Phase 4
- breitbandige Multi-Span-Profile
- 20–80 MHz konkrete Betriebsmodi
- adaptive Quality-of-Service

---

## Erfolgskriterien für Phase 1

- Fork existiert als neues Repo
- Projekt ist logisch als Wideband-Fork positioniert
- neue Architekturbegriffe sind im Code und in der Config sichtbar
- bestehende Kernfunktionen bleiben lauffähig
- Grundlage für spätere skalierbare, autonome Arbeitsweise ist gelegt

---

## Arbeitsmodus

Umbau erfolgt autonom im Fork.

Guardrails:
- Keine Pushes vor erfolgreichen Tests/Build
- Schrittweise Migration statt Big-Bang
- Bestehende Funktionalität möglichst erhalten

+ 28
- 7
README.md Ver fichero

@@ -1,17 +1,18 @@
# SDR Visual Suite
# SDR Wideband Suite

Go-based SDRplay RSP1b live spectrum + waterfall visualizer with event tracking, classifier, and demod/recording pipeline.
Go-based SDR analysis engine and live spectrum/waterfall UI, evolved from the original `sdr-visual-suite` into a more scalable foundation for wideband monitoring, candidate-driven refinement, classification, and demod/recording.

## Features
- Live spectrum + waterfall web UI (WebSocket streaming)
- Event timeline view (time vs frequency) + detail drawer
- Live signal list + classifier insights
- Runtime UI controls: center, span, sample rate, tuner bandwidth, FFT size, gain, AGC, DC block, IQ balance, detector settings
- Runtime UI controls: center, span, sample rate, tuner bandwidth, analysis FFT size, gain, AGC, DC block, IQ balance, detector settings
- Optional GPU FFT (cuFFT) + `/api/gpu`
- IQ/audio recording + recordings list
- Live demod endpoint + WebSocket live-listen audio
- WFM stereo + RDS baseband
- Mock mode for testing without hardware
- Phase-1 wideband architecture foundation: explicit pipeline/surveillance/refinement/resources config model and candidate/refinement pipeline scaffolding

---

@@ -46,12 +47,32 @@ Open `http://localhost:8080`.
## Configuration
Edit `config.yaml` (autosave goes to `config.autosave.yaml`).

Common fields:
### Legacy-compatible core fields
- `center_hz`, `sample_rate`, `fft_size`, `gain_db`, `tuner_bw_khz`
- `use_gpu_fft`, `agc`, `dc_block`, `iq_balance`
- `detector.*` (e.g. `threshold_db`, `cfar_mode`, `cfar_guard_hz`, `cfar_train_hz`, `min_duration_ms`, `hold_ms`, ...)
- `recorder.*` (enable IQ/audio recording, output path, ring buffer, etc.)
- `decoder.*` (external decoder commands)
- `detector.*`
- `recorder.*`
- `decoder.*`

### New phase-1 pipeline fields
- `pipeline.mode` — operating mode label (`legacy`, `wideband-balanced`, ...)
- `surveillance.analysis_fft_size` — analysis FFT size used by the surveillance layer
- `surveillance.frame_rate` — surveillance cadence target
- `surveillance.strategy` — currently `single-resolution`, reserved for future multi-resolution modes
- `refinement.enabled` — enables explicit candidate refinement stage
- `refinement.max_concurrent` — refinement budget hint
- `refinement.min_candidate_snr_db` — floor for future scheduling decisions
- `resources.prefer_gpu` — GPU preference hint
- `resources.max_refinement_jobs` — processing budget hint
- `resources.max_recording_streams` — recording/streaming budget hint
- `profiles[]` — named operating profiles/intent metadata

In phase 1, the engine stays backward compatible, but the config model now reflects the intended separation between:
- acquisition
- surveillance analysis
- local refinement
- resource policy
- presentation

**CFAR modes:** `OFF`, `CA`, `OS`, `GOSCA`, `CASO`



+ 26
- 21
cmd/sdrd/dsp_loop.go Ver fichero

@@ -21,6 +21,7 @@ import (
"sdr-visual-suite/internal/fft/gpufft"
"sdr-visual-suite/internal/rds"
"sdr-visual-suite/internal/recorder"
"sdr-visual-suite/internal/pipeline"
)

func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File, eventMu *sync.RWMutex, updates <-chan dspUpdate, gpuState *gpuStatus, rec *recorder.Manager, sigSnap *signalSnapshot, extractMgr *extractionManager) {
@@ -141,6 +142,12 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
dcBlocker.Reset()
ticker.Reset(cfg.FrameInterval())
case <-ticker.C:
// Pipeline phases:
// 1) ingest IQ
// 2) build surveillance spectrum
// 3) detect coarse candidates
// 4) refine locally with IQ snippets + classification
// 5) update tracking / events / recorder / presentation
// Read all available IQ data — not just one FFT block.
// This ensures the ring buffer captures 100% of IQ for recording/demod.
available := cfg.FFTSize
@@ -214,31 +221,29 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
}
}
now := time.Now()
finished, signals := det.Process(now, spectrum, cfg.CenterHz)
finished, detectedSignals := det.Process(now, spectrum, cfg.CenterHz)
candidates := pipeline.CandidatesFromSignals(detectedSignals, "surveillance-detector")
thresholds := det.LastThresholds()
noiseFloor := det.LastNoiseFloor()
var displaySignals []detector.Signal
if len(iq) > 0 {
snips, snipRates := extractSignalIQBatch(extractMgr, iq, cfg.SampleRate, cfg.CenterHz, signals)
for i := range signals {
var snip []complex64
if i < len(snips) {
snip = snips[i]
}
// Determine actual sample rate of the extracted snippet
snipRate := cfg.SampleRate
if i < len(snipRates) && snipRates[i] > 0 {
snipRate = snipRates[i]
}
cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb, CenterHz: signals[i].CenterHz, BWHz: signals[i].BWHz}, spectrum, cfg.SampleRate, cfg.FFTSize, snip, classifier.ClassifierMode(cfg.ClassifierMode))
signals[i].Class = cls
if cls != nil && snip != nil && len(snip) > 256 {
pll := classifier.EstimateExactFrequency(snip, snipRate, signals[i].CenterHz, cls.ModType)
cls.PLL = &pll
signals[i].PLL = &pll
// Upgrade WFM → WFM_STEREO if stereo pilot detected
if cls.ModType == classifier.ClassWFM && pll.Stereo {
cls.ModType = classifier.ClassWFMStereo
snips, snipRates := extractSignalIQBatch(extractMgr, iq, cfg.SampleRate, cfg.CenterHz, detectedSignals)
refined := pipeline.RefineCandidates(candidates, spectrum, cfg.SampleRate, cfg.FFTSize, snips, snipRates, classifier.ClassifierMode(cfg.ClassifierMode))
signals := make([]detector.Signal, 0, len(refined))
for i, ref := range refined {
sig := ref.Signal
signals = append(signals, sig)
cls := sig.Class
snipRate := ref.SnippetRate
if cls != nil {
pll := classifier.PLLResult{}
if i < len(snips) && snips[i] != nil && len(snips[i]) > 256 {
pll = classifier.EstimateExactFrequency(snips[i], snipRate, signals[i].CenterHz, cls.ModType)
cls.PLL = &pll
signals[i].PLL = &pll
if cls.ModType == classifier.ClassWFM && pll.Stereo {
cls.ModType = classifier.ClassWFMStereo
}
}
// RDS decode for WFM — async, uses ring buffer for continuous IQ
if (cls.ModType == classifier.ClassWFM || cls.ModType == classifier.ClassWFMStereo) && rec != nil {


+ 10
- 0
cmd/sdrd/http_handlers.go Ver fichero

@@ -15,6 +15,7 @@ import (
"sdr-visual-suite/internal/config"
"sdr-visual-suite/internal/detector"
"sdr-visual-suite/internal/events"
"sdr-visual-suite/internal/pipeline"
fftutil "sdr-visual-suite/internal/fft"
"sdr-visual-suite/internal/recorder"
"sdr-visual-suite/internal/runtime"
@@ -156,6 +157,15 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime
}
_ = json.NewEncoder(w).Encode(sigSnap.get())
})
mux.HandleFunc("/api/candidates", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if sigSnap == nil {
_ = json.NewEncoder(w).Encode([]pipeline.Candidate{})
return
}
sigs := sigSnap.get()
_ = json.NewEncoder(w).Encode(pipeline.CandidatesFromSignals(sigs, "tracked-signal-snapshot"))
})
mux.HandleFunc("/api/decoders", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot()))


+ 19
- 0
config.yaml Ver fichero

@@ -11,6 +11,25 @@ use_gpu_fft: true
agc: false
dc_block: false
iq_balance: false
pipeline:
mode: wideband-balanced
surveillance:
analysis_fft_size: 2048
frame_rate: 15
strategy: single-resolution
refinement:
enabled: true
max_concurrent: 8
min_candidate_snr_db: 0
resources:
prefer_gpu: true
max_refinement_jobs: 8
max_recording_streams: 16
profiles:
- name: legacy
description: Current single-band legacy behavior
- name: wideband-balanced
description: Prepared baseline for future scalable wideband monitoring
detector:
threshold_db: -20
min_duration_ms: 250


+ 105
- 19
internal/config/config.go Ver fichero

@@ -70,26 +70,58 @@ type DecoderConfig struct {
PSKCmd string `yaml:"psk_cmd" json:"psk_cmd"`
}

type PipelineConfig struct {
Mode string `yaml:"mode" json:"mode"`
}

type SurveillanceConfig struct {
AnalysisFFTSize int `yaml:"analysis_fft_size" json:"analysis_fft_size"`
FrameRate int `yaml:"frame_rate" json:"frame_rate"`
Strategy string `yaml:"strategy" json:"strategy"`
}

type RefinementConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
MaxConcurrent int `yaml:"max_concurrent" json:"max_concurrent"`
MinCandidateSNRDb float64 `yaml:"min_candidate_snr_db" json:"min_candidate_snr_db"`
}

type ResourceConfig struct {
PreferGPU bool `yaml:"prefer_gpu" json:"prefer_gpu"`
MaxRefinementJobs int `yaml:"max_refinement_jobs" json:"max_refinement_jobs"`
MaxRecordingStreams int `yaml:"max_recording_streams" json:"max_recording_streams"`
}

type ProfileConfig struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
}

type Config struct {
Bands []Band `yaml:"bands" json:"bands"`
CenterHz float64 `yaml:"center_hz" json:"center_hz"`
SampleRate int `yaml:"sample_rate" json:"sample_rate"`
FFTSize int `yaml:"fft_size" json:"fft_size"`
GainDb float64 `yaml:"gain_db" json:"gain_db"`
TunerBwKHz int `yaml:"tuner_bw_khz" json:"tuner_bw_khz"`
UseGPUFFT bool `yaml:"use_gpu_fft" json:"use_gpu_fft"`
ClassifierMode string `yaml:"classifier_mode" json:"classifier_mode"`
AGC bool `yaml:"agc" json:"agc"`
DCBlock bool `yaml:"dc_block" json:"dc_block"`
IQBalance bool `yaml:"iq_balance" json:"iq_balance"`
Detector DetectorConfig `yaml:"detector" json:"detector"`
Recorder RecorderConfig `yaml:"recorder" json:"recorder"`
Decoder DecoderConfig `yaml:"decoder" json:"decoder"`
WebAddr string `yaml:"web_addr" json:"web_addr"`
EventPath string `yaml:"event_path" json:"event_path"`
FrameRate int `yaml:"frame_rate" json:"frame_rate"`
WaterfallLines int `yaml:"waterfall_lines" json:"waterfall_lines"`
WebRoot string `yaml:"web_root" json:"web_root"`
Bands []Band `yaml:"bands" json:"bands"`
CenterHz float64 `yaml:"center_hz" json:"center_hz"`
SampleRate int `yaml:"sample_rate" json:"sample_rate"`
FFTSize int `yaml:"fft_size" json:"fft_size"`
GainDb float64 `yaml:"gain_db" json:"gain_db"`
TunerBwKHz int `yaml:"tuner_bw_khz" json:"tuner_bw_khz"`
UseGPUFFT bool `yaml:"use_gpu_fft" json:"use_gpu_fft"`
ClassifierMode string `yaml:"classifier_mode" json:"classifier_mode"`
AGC bool `yaml:"agc" json:"agc"`
DCBlock bool `yaml:"dc_block" json:"dc_block"`
IQBalance bool `yaml:"iq_balance" json:"iq_balance"`
Pipeline PipelineConfig `yaml:"pipeline" json:"pipeline"`
Surveillance SurveillanceConfig `yaml:"surveillance" json:"surveillance"`
Refinement RefinementConfig `yaml:"refinement" json:"refinement"`
Resources ResourceConfig `yaml:"resources" json:"resources"`
Profiles []ProfileConfig `yaml:"profiles" json:"profiles"`
Detector DetectorConfig `yaml:"detector" json:"detector"`
Recorder RecorderConfig `yaml:"recorder" json:"recorder"`
Decoder DecoderConfig `yaml:"decoder" json:"decoder"`
WebAddr string `yaml:"web_addr" json:"web_addr"`
EventPath string `yaml:"event_path" json:"event_path"`
FrameRate int `yaml:"frame_rate" json:"frame_rate"`
WaterfallLines int `yaml:"waterfall_lines" json:"waterfall_lines"`
WebRoot string `yaml:"web_root" json:"web_root"`
}

func Default() Config {
@@ -107,6 +139,28 @@ func Default() Config {
AGC: false,
DCBlock: false,
IQBalance: false,
Pipeline: PipelineConfig{
Mode: "legacy",
},
Surveillance: SurveillanceConfig{
AnalysisFFTSize: 2048,
FrameRate: 15,
Strategy: "single-resolution",
},
Refinement: RefinementConfig{
Enabled: true,
MaxConcurrent: 8,
MinCandidateSNRDb: 0,
},
Resources: ResourceConfig{
PreferGPU: true,
MaxRefinementJobs: 8,
MaxRecordingStreams: 16,
},
Profiles: []ProfileConfig{
{Name: "legacy", Description: "Current single-band pipeline behavior"},
{Name: "wideband-balanced", Description: "Prepared profile for scalable wideband surveillance"},
},
Detector: DetectorConfig{
ThresholdDb: -20,
MinDurationMs: 250,
@@ -238,6 +292,33 @@ func applyDefaults(cfg Config) Config {
if cfg.Detector.ClassSwitchRatio <= 0 || cfg.Detector.ClassSwitchRatio > 1 {
cfg.Detector.ClassSwitchRatio = 0.6
}
if cfg.Pipeline.Mode == "" {
cfg.Pipeline.Mode = "legacy"
}
if cfg.Surveillance.AnalysisFFTSize <= 0 {
cfg.Surveillance.AnalysisFFTSize = cfg.FFTSize
}
if cfg.Surveillance.FrameRate <= 0 {
cfg.Surveillance.FrameRate = cfg.FrameRate
}
if cfg.Surveillance.Strategy == "" {
cfg.Surveillance.Strategy = "single-resolution"
}
if !cfg.Refinement.Enabled {
// keep explicit false if user disabled it; enable by default only when unset-like zero config
if cfg.Refinement.MaxConcurrent == 0 && cfg.Refinement.MinCandidateSNRDb == 0 {
cfg.Refinement.Enabled = true
}
}
if cfg.Refinement.MaxConcurrent <= 0 {
cfg.Refinement.MaxConcurrent = 8
}
if cfg.Resources.MaxRefinementJobs <= 0 {
cfg.Resources.MaxRefinementJobs = cfg.Refinement.MaxConcurrent
}
if cfg.Resources.MaxRecordingStreams <= 0 {
cfg.Resources.MaxRecordingStreams = 16
}
if cfg.FrameRate <= 0 {
cfg.FrameRate = 15
}
@@ -267,6 +348,11 @@ func applyDefaults(cfg Config) Config {
if cfg.FFTSize <= 0 {
cfg.FFTSize = 2048
}
if cfg.Surveillance.AnalysisFFTSize > 0 {
cfg.FFTSize = cfg.Surveillance.AnalysisFFTSize
} else {
cfg.Surveillance.AnalysisFFTSize = cfg.FFTSize
}
if cfg.TunerBwKHz <= 0 {
cfg.TunerBwKHz = 1536
}


+ 13
- 1
internal/config/config_test.go Ver fichero

@@ -23,12 +23,24 @@ func TestLoadConfig(t *testing.T) {
if cfg.CenterHz != 100.0e6 {
t.Fatalf("center hz: %v", cfg.CenterHz)
}
if cfg.FFTSize != 1024 {
if cfg.FFTSize != 2048 {
t.Fatalf("fft size: %v", cfg.FFTSize)
}
if cfg.Surveillance.AnalysisFFTSize != 2048 {
t.Fatalf("analysis fft size: %v", cfg.Surveillance.AnalysisFFTSize)
}
if cfg.FrameRate <= 0 {
t.Fatalf("frame rate default not applied")
}
if cfg.Surveillance.AnalysisFFTSize != cfg.FFTSize {
t.Fatalf("analysis fft size not aligned: %d vs %d", cfg.Surveillance.AnalysisFFTSize, cfg.FFTSize)
}
if cfg.Pipeline.Mode == "" {
t.Fatalf("pipeline mode default not applied")
}
if !cfg.Refinement.Enabled {
t.Fatalf("refinement default not applied")
}
if cfg.EventPath == "" {
t.Fatalf("event path default not applied")
}


+ 56
- 0
internal/pipeline/refiner.go Ver fichero

@@ -0,0 +1,56 @@
package pipeline

import (
"sdr-visual-suite/internal/classifier"
"sdr-visual-suite/internal/detector"
)

// RefineCandidates upgrades coarse detector candidates into refined signals
// by attaching local IQ-derived classification and PLL metadata.
func RefineCandidates(candidates []Candidate, spectrum []float64, sampleRate int, fftSize int, snippets [][]complex64, snippetRates []int, mode classifier.ClassifierMode) []Refinement {
out := make([]Refinement, 0, len(candidates))
for i, c := range candidates {
sig := detector.Signal{
ID: c.ID,
FirstBin: c.FirstBin,
LastBin: c.LastBin,
CenterHz: c.CenterHz,
BWHz: c.BandwidthHz,
PeakDb: c.PeakDb,
SNRDb: c.SNRDb,
NoiseDb: c.NoiseDb,
}
var snip []complex64
if i < len(snippets) {
snip = snippets[i]
}
snipRate := sampleRate
if i < len(snippetRates) && snippetRates[i] > 0 {
snipRate = snippetRates[i]
}
cls := classifier.Classify(classifier.SignalInput{
FirstBin: sig.FirstBin,
LastBin: sig.LastBin,
SNRDb: sig.SNRDb,
CenterHz: sig.CenterHz,
BWHz: sig.BWHz,
}, spectrum, sampleRate, fftSize, snip, mode)
sig.Class = cls
if cls != nil && snip != nil && len(snip) > 256 {
pll := classifier.EstimateExactFrequency(snip, snipRate, sig.CenterHz, cls.ModType)
cls.PLL = &pll
sig.PLL = &pll
if cls.ModType == classifier.ClassWFM && pll.Stereo {
cls.ModType = classifier.ClassWFMStereo
}
}
out = append(out, Refinement{
Candidate: c,
Signal: sig,
SnippetRate: snipRate,
Class: cls,
Stage: "local-iq",
})
}
return out
}

+ 48
- 0
internal/pipeline/types.go Ver fichero

@@ -0,0 +1,48 @@
package pipeline

import (
"sdr-visual-suite/internal/classifier"
"sdr-visual-suite/internal/detector"
)

// Candidate is the coarse output of the surveillance detector.
// It intentionally stays lightweight and cheap to produce.
type Candidate struct {
ID int64 `json:"id"`
CenterHz float64 `json:"center_hz"`
BandwidthHz float64 `json:"bandwidth_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
NoiseDb float64 `json:"noise_db,omitempty"`
Source string `json:"source,omitempty"`
Hint string `json:"hint,omitempty"`
}

// Refinement contains higher-cost local analysis derived from a candidate.
type Refinement struct {
Candidate Candidate `json:"candidate"`
Signal detector.Signal `json:"signal"`
SnippetRate int `json:"snippet_rate"`
Class *classifier.Classification `json:"class,omitempty"`
Stage string `json:"stage,omitempty"`
}

func CandidatesFromSignals(signals []detector.Signal, source string) []Candidate {
out := make([]Candidate, 0, len(signals))
for _, s := range signals {
out = append(out, Candidate{
ID: s.ID,
CenterHz: s.CenterHz,
BandwidthHz: s.BWHz,
PeakDb: s.PeakDb,
SNRDb: s.SNRDb,
FirstBin: s.FirstBin,
LastBin: s.LastBin,
NoiseDb: s.NoiseDb,
Source: source,
})
}
return out
}

+ 1
- 0
internal/runtime/runtime.go Ver fichero

@@ -111,6 +111,7 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {
return m.cfg, errors.New("fft_size must be a power of 2")
}
next.FFTSize = *update.FFTSize
next.Surveillance.AnalysisFFTSize = *update.FFTSize
}
if update.GainDb != nil {
next.GainDb = *update.GainDb


+ 3
- 0
internal/runtime/runtime_test.go Ver fichero

@@ -49,6 +49,9 @@ func TestApplyConfigUpdate(t *testing.T) {
if updated.FFTSize != fftSize {
t.Fatalf("fft size: %v", updated.FFTSize)
}
if updated.Surveillance.AnalysisFFTSize != fftSize {
t.Fatalf("analysis fft size: %v", updated.Surveillance.AnalysisFFTSize)
}
if updated.Detector.ThresholdDb != threshold {
t.Fatalf("threshold: %v", updated.Detector.ThresholdDb)
}


Cargando…
Cancelar
Guardar