| @@ -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 | ## Features | ||||
| - Live spectrum + waterfall web UI (WebSocket streaming) | - Live spectrum + waterfall web UI (WebSocket streaming) | ||||
| - Event timeline view (time vs frequency) + detail drawer | - Event timeline view (time vs frequency) + detail drawer | ||||
| - Live signal list + classifier insights | - 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` | - Optional GPU FFT (cuFFT) + `/api/gpu` | ||||
| - IQ/audio recording + recordings list | - IQ/audio recording + recordings list | ||||
| - Live demod endpoint + WebSocket live-listen audio | - Live demod endpoint + WebSocket live-listen audio | ||||
| - WFM stereo + RDS baseband | - WFM stereo + RDS baseband | ||||
| - Mock mode for testing without hardware | - 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 | ## Configuration | ||||
| Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | 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` | - `center_hz`, `sample_rate`, `fft_size`, `gain_db`, `tuner_bw_khz` | ||||
| - `use_gpu_fft`, `agc`, `dc_block`, `iq_balance` | - `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` | **CFAR modes:** `OFF`, `CA`, `OS`, `GOSCA`, `CASO` | ||||
| @@ -21,6 +21,7 @@ import ( | |||||
| "sdr-visual-suite/internal/fft/gpufft" | "sdr-visual-suite/internal/fft/gpufft" | ||||
| "sdr-visual-suite/internal/rds" | "sdr-visual-suite/internal/rds" | ||||
| "sdr-visual-suite/internal/recorder" | "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) { | 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() | dcBlocker.Reset() | ||||
| ticker.Reset(cfg.FrameInterval()) | ticker.Reset(cfg.FrameInterval()) | ||||
| case <-ticker.C: | 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. | // Read all available IQ data — not just one FFT block. | ||||
| // This ensures the ring buffer captures 100% of IQ for recording/demod. | // This ensures the ring buffer captures 100% of IQ for recording/demod. | ||||
| available := cfg.FFTSize | available := cfg.FFTSize | ||||
| @@ -214,31 +221,29 @@ 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, detectedSignals := det.Process(now, spectrum, cfg.CenterHz) | |||||
| candidates := pipeline.CandidatesFromSignals(detectedSignals, "surveillance-detector") | |||||
| thresholds := det.LastThresholds() | thresholds := det.LastThresholds() | ||||
| noiseFloor := det.LastNoiseFloor() | noiseFloor := det.LastNoiseFloor() | ||||
| var displaySignals []detector.Signal | var displaySignals []detector.Signal | ||||
| if len(iq) > 0 { | 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 | // RDS decode for WFM — async, uses ring buffer for continuous IQ | ||||
| if (cls.ModType == classifier.ClassWFM || cls.ModType == classifier.ClassWFMStereo) && rec != nil { | 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/config" | ||||
| "sdr-visual-suite/internal/detector" | "sdr-visual-suite/internal/detector" | ||||
| "sdr-visual-suite/internal/events" | "sdr-visual-suite/internal/events" | ||||
| "sdr-visual-suite/internal/pipeline" | |||||
| fftutil "sdr-visual-suite/internal/fft" | fftutil "sdr-visual-suite/internal/fft" | ||||
| "sdr-visual-suite/internal/recorder" | "sdr-visual-suite/internal/recorder" | ||||
| "sdr-visual-suite/internal/runtime" | "sdr-visual-suite/internal/runtime" | ||||
| @@ -156,6 +157,15 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| } | } | ||||
| _ = json.NewEncoder(w).Encode(sigSnap.get()) | _ = 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) { | mux.HandleFunc("/api/decoders", func(w http.ResponseWriter, r *http.Request) { | ||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| _ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot())) | _ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot())) | ||||
| @@ -11,6 +11,25 @@ use_gpu_fft: true | |||||
| agc: false | agc: false | ||||
| dc_block: false | dc_block: false | ||||
| iq_balance: 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: | detector: | ||||
| threshold_db: -20 | threshold_db: -20 | ||||
| min_duration_ms: 250 | min_duration_ms: 250 | ||||
| @@ -70,26 +70,58 @@ type DecoderConfig struct { | |||||
| PSKCmd string `yaml:"psk_cmd" json:"psk_cmd"` | 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 { | 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 { | func Default() Config { | ||||
| @@ -107,6 +139,28 @@ func Default() Config { | |||||
| AGC: false, | AGC: false, | ||||
| DCBlock: false, | DCBlock: false, | ||||
| IQBalance: 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{ | Detector: DetectorConfig{ | ||||
| ThresholdDb: -20, | ThresholdDb: -20, | ||||
| MinDurationMs: 250, | MinDurationMs: 250, | ||||
| @@ -238,6 +292,33 @@ func applyDefaults(cfg Config) Config { | |||||
| if cfg.Detector.ClassSwitchRatio <= 0 || cfg.Detector.ClassSwitchRatio > 1 { | if cfg.Detector.ClassSwitchRatio <= 0 || cfg.Detector.ClassSwitchRatio > 1 { | ||||
| cfg.Detector.ClassSwitchRatio = 0.6 | 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 { | if cfg.FrameRate <= 0 { | ||||
| cfg.FrameRate = 15 | cfg.FrameRate = 15 | ||||
| } | } | ||||
| @@ -267,6 +348,11 @@ func applyDefaults(cfg Config) Config { | |||||
| if cfg.FFTSize <= 0 { | if cfg.FFTSize <= 0 { | ||||
| cfg.FFTSize = 2048 | cfg.FFTSize = 2048 | ||||
| } | } | ||||
| if cfg.Surveillance.AnalysisFFTSize > 0 { | |||||
| cfg.FFTSize = cfg.Surveillance.AnalysisFFTSize | |||||
| } else { | |||||
| cfg.Surveillance.AnalysisFFTSize = cfg.FFTSize | |||||
| } | |||||
| if cfg.TunerBwKHz <= 0 { | if cfg.TunerBwKHz <= 0 { | ||||
| cfg.TunerBwKHz = 1536 | cfg.TunerBwKHz = 1536 | ||||
| } | } | ||||
| @@ -23,12 +23,24 @@ func TestLoadConfig(t *testing.T) { | |||||
| if cfg.CenterHz != 100.0e6 { | if cfg.CenterHz != 100.0e6 { | ||||
| t.Fatalf("center hz: %v", cfg.CenterHz) | t.Fatalf("center hz: %v", cfg.CenterHz) | ||||
| } | } | ||||
| if cfg.FFTSize != 1024 { | |||||
| if cfg.FFTSize != 2048 { | |||||
| t.Fatalf("fft size: %v", cfg.FFTSize) | 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 { | if cfg.FrameRate <= 0 { | ||||
| t.Fatalf("frame rate default not applied") | 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 == "" { | if cfg.EventPath == "" { | ||||
| t.Fatalf("event path default not applied") | 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") | return m.cfg, errors.New("fft_size must be a power of 2") | ||||
| } | } | ||||
| next.FFTSize = *update.FFTSize | next.FFTSize = *update.FFTSize | ||||
| next.Surveillance.AnalysisFFTSize = *update.FFTSize | |||||
| } | } | ||||
| if update.GainDb != nil { | if update.GainDb != nil { | ||||
| next.GainDb = *update.GainDb | next.GainDb = *update.GainDb | ||||
| @@ -49,6 +49,9 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| if updated.FFTSize != fftSize { | if updated.FFTSize != fftSize { | ||||
| t.Fatalf("fft size: %v", updated.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 { | if updated.Detector.ThresholdDb != threshold { | ||||
| t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | ||||
| } | } | ||||