diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..f26839f --- /dev/null +++ b/PLAN.md @@ -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 diff --git a/README.md b/README.md index 9435a16..cd0c2e1 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/cmd/sdrd/dsp_loop.go b/cmd/sdrd/dsp_loop.go index 70565bd..d088521 100644 --- a/cmd/sdrd/dsp_loop.go +++ b/cmd/sdrd/dsp_loop.go @@ -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 { diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index 483b8cc..b6571dc 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -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())) diff --git a/config.yaml b/config.yaml index 92bbb4f..a717041 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 110b038..447a80a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c01f486..e7b28e1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") } diff --git a/internal/pipeline/refiner.go b/internal/pipeline/refiner.go new file mode 100644 index 0000000..c92baa5 --- /dev/null +++ b/internal/pipeline/refiner.go @@ -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 +} diff --git a/internal/pipeline/types.go b/internal/pipeline/types.go new file mode 100644 index 0000000..426e911 --- /dev/null +++ b/internal/pipeline/types.go @@ -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 +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 026dbd5..38802fb 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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 diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index cbc1641..4ac8489 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -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) }