| @@ -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 | |||
| @@ -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` | |||
| @@ -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 { | |||
| @@ -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())) | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| @@ -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) | |||
| } | |||