package config import ( "math" "os" "time" "gopkg.in/yaml.v3" ) type Band struct { Name string `yaml:"name" json:"name"` StartHz float64 `yaml:"start_hz" json:"start_hz"` EndHz float64 `yaml:"end_hz" json:"end_hz"` } type DetectorConfig struct { ThresholdDb float64 `yaml:"threshold_db" json:"threshold_db"` MinDurationMs int `yaml:"min_duration_ms" json:"min_duration_ms"` HoldMs int `yaml:"hold_ms" json:"hold_ms"` EmaAlpha float64 `yaml:"ema_alpha" json:"ema_alpha"` HysteresisDb float64 `yaml:"hysteresis_db" json:"hysteresis_db"` MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"` CFARMode string `yaml:"cfar_mode" json:"cfar_mode"` CFARGuardHz float64 `yaml:"cfar_guard_hz" json:"cfar_guard_hz"` CFARTrainHz float64 `yaml:"cfar_train_hz" json:"cfar_train_hz"` CFARGuardCells int `yaml:"cfar_guard_cells,omitempty" json:"cfar_guard_cells,omitempty"` CFARTrainCells int `yaml:"cfar_train_cells,omitempty" json:"cfar_train_cells,omitempty"` CFARRank int `yaml:"cfar_rank" json:"cfar_rank"` CFARScaleDb float64 `yaml:"cfar_scale_db" json:"cfar_scale_db"` CFARWrapAround bool `yaml:"cfar_wrap_around" json:"cfar_wrap_around"` EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` MaxSignalBwHz float64 `yaml:"max_signal_bw_hz" json:"max_signal_bw_hz"` MergeGapHz float64 `yaml:"merge_gap_hz" json:"merge_gap_hz"` ClassHistorySize int `yaml:"class_history_size" json:"class_history_size"` ClassSwitchRatio float64 `yaml:"class_switch_ratio" json:"class_switch_ratio"` // Deprecated (backward compatibility) CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` } type RecorderConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` MinSNRDb float64 `yaml:"min_snr_db" json:"min_snr_db"` MinDuration string `yaml:"min_duration" json:"min_duration"` MaxDuration string `yaml:"max_duration" json:"max_duration"` PrerollMs int `yaml:"preroll_ms" json:"preroll_ms"` RecordIQ bool `yaml:"record_iq" json:"record_iq"` RecordAudio bool `yaml:"record_audio" json:"record_audio"` AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` MaxDiskMB int `yaml:"max_disk_mb" json:"max_disk_mb"` OutputDir string `yaml:"output_dir" json:"output_dir"` ClassFilter []string `yaml:"class_filter" json:"class_filter"` RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` // Audio quality settings (AQ-2, AQ-3, AQ-5) DeemphasisUs float64 `yaml:"deemphasis_us" json:"deemphasis_us"` // De-emphasis time constant in µs. 50=Europe, 75=US/Japan, 0=disabled. Default: 50 ExtractionTaps int `yaml:"extraction_fir_taps" json:"extraction_fir_taps"` // FIR tap count for extraction filter. Default: 101, max 301 ExtractionBwMult float64 `yaml:"extraction_bw_mult" json:"extraction_bw_mult"` // BW multiplier for extraction. Default: 1.2 (20% wider than detected) } type DecoderConfig struct { FT8Cmd string `yaml:"ft8_cmd" json:"ft8_cmd"` WSPRCmd string `yaml:"wspr_cmd" json:"wspr_cmd"` DMRCmd string `yaml:"dmr_cmd" json:"dmr_cmd"` DStarCmd string `yaml:"dstar_cmd" json:"dstar_cmd"` FSKCmd string `yaml:"fsk_cmd" json:"fsk_cmd"` PSKCmd string `yaml:"psk_cmd" json:"psk_cmd"` } type PipelineGoalConfig struct { Intent string `yaml:"intent" json:"intent"` MonitorStartHz float64 `yaml:"monitor_start_hz" json:"monitor_start_hz"` MonitorEndHz float64 `yaml:"monitor_end_hz" json:"monitor_end_hz"` MonitorSpanHz float64 `yaml:"monitor_span_hz" json:"monitor_span_hz"` SignalPriorities []string `yaml:"signal_priorities" json:"signal_priorities"` AutoRecordClasses []string `yaml:"auto_record_classes" json:"auto_record_classes"` AutoDecodeClasses []string `yaml:"auto_decode_classes" json:"auto_decode_classes"` } type PipelineConfig struct { Mode string `yaml:"mode" json:"mode"` Goals PipelineGoalConfig `yaml:"goals" json:"goals"` } 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"` DisplayBins int `yaml:"display_bins" json:"display_bins"` DisplayFPS int `yaml:"display_fps" json:"display_fps"` } 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"` MinSpanHz float64 `yaml:"min_span_hz" json:"min_span_hz"` MaxSpanHz float64 `yaml:"max_span_hz" json:"max_span_hz"` AutoSpan bool `yaml:"auto_span" json:"auto_span"` } 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"` Pipeline *PipelineConfig `yaml:"pipeline,omitempty" json:"pipeline,omitempty"` Surveillance *SurveillanceConfig `yaml:"surveillance,omitempty" json:"surveillance,omitempty"` Refinement *RefinementConfig `yaml:"refinement,omitempty" json:"refinement,omitempty"` Resources *ResourceConfig `yaml:"resources,omitempty" json:"resources,omitempty"` } 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"` 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 { return Config{ Bands: []Band{ {Name: "example", StartHz: 99.5e6, EndHz: 100.5e6}, }, CenterHz: 100.0e6, SampleRate: 2_048_000, FFTSize: 2048, GainDb: 30, TunerBwKHz: 1536, UseGPUFFT: false, ClassifierMode: "combined", AGC: false, DCBlock: false, IQBalance: false, Pipeline: PipelineConfig{ Mode: "legacy", Goals: PipelineGoalConfig{ Intent: "general-monitoring", }, }, Surveillance: SurveillanceConfig{ AnalysisFFTSize: 2048, FrameRate: 15, Strategy: "single-resolution", DisplayBins: 2048, DisplayFPS: 15, }, Refinement: RefinementConfig{ Enabled: true, MaxConcurrent: 8, MinCandidateSNRDb: 0, MinSpanHz: 0, MaxSpanHz: 0, AutoSpan: true, }, Resources: ResourceConfig{ PreferGPU: true, MaxRefinementJobs: 8, MaxRecordingStreams: 16, }, Profiles: []ProfileConfig{ {Name: "legacy", Description: "Current single-band pipeline behavior", Pipeline: &PipelineConfig{Mode: "legacy", Goals: PipelineGoalConfig{Intent: "general-monitoring"}}}, {Name: "wideband-balanced", Description: "Prepared baseline for scalable wideband surveillance", Pipeline: &PipelineConfig{Mode: "wideband-balanced", Goals: PipelineGoalConfig{Intent: "wideband-surveillance"}}}, {Name: "wideband-aggressive", Description: "Higher surveillance/refinement budgets for future broad-span monitoring", Pipeline: &PipelineConfig{Mode: "wideband-aggressive", Goals: PipelineGoalConfig{Intent: "high-density-wideband-surveillance"}}}, {Name: "archive", Description: "Record-first monitoring profile", Pipeline: &PipelineConfig{Mode: "archive", Goals: PipelineGoalConfig{Intent: "archive-and-triage"}}}, }, Detector: DetectorConfig{ ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3, MinStableFrames: 3, GapToleranceMs: 500, CFARMode: "GOSCA", CFARGuardHz: 500, CFARTrainHz: 5000, CFARGuardCells: 3, CFARTrainCells: 24, CFARRank: 36, CFARScaleDb: 6, CFARWrapAround: true, EdgeMarginDb: 3.0, MaxSignalBwHz: 150000, MergeGapHz: 5000, ClassHistorySize: 10, ClassSwitchRatio: 0.6, }, Recorder: RecorderConfig{ Enabled: false, MinSNRDb: 10, MinDuration: "1s", MaxDuration: "300s", PrerollMs: 500, RecordIQ: true, RecordAudio: false, AutoDemod: true, AutoDecode: false, MaxDiskMB: 0, OutputDir: "data/recordings", RingSeconds: 8, DeemphasisUs: 50, ExtractionTaps: 101, ExtractionBwMult: 1.2, }, Decoder: DecoderConfig{}, WebAddr: ":8080", EventPath: "data/events.jsonl", FrameRate: 15, WaterfallLines: 200, WebRoot: "web", } } func Load(path string) (Config, error) { cfg := Default() if b, err := os.ReadFile(autosavePath(path)); err == nil { if err := yaml.Unmarshal(b, &cfg); err == nil { return applyDefaults(cfg), nil } } b, err := os.ReadFile(path) if err != nil { return cfg, err } if err := yaml.Unmarshal(b, &cfg); err != nil { return cfg, err } return applyDefaults(cfg), nil } func applyDefaults(cfg Config) Config { if cfg.Detector.MinDurationMs <= 0 { cfg.Detector.MinDurationMs = 250 } if cfg.Detector.HoldMs <= 0 { cfg.Detector.HoldMs = 500 } if cfg.Detector.MinStableFrames <= 0 { cfg.Detector.MinStableFrames = 3 } if cfg.Detector.GapToleranceMs <= 0 { cfg.Detector.GapToleranceMs = cfg.Detector.HoldMs } if cfg.Detector.CFARMode == "" { if cfg.Detector.CFAREnabled != nil { if *cfg.Detector.CFAREnabled { cfg.Detector.CFARMode = "OS" } else { cfg.Detector.CFARMode = "OFF" } } else { cfg.Detector.CFARMode = "GOSCA" } } if cfg.Detector.CFARGuardHz <= 0 && cfg.Detector.CFARGuardCells > 0 { cfg.Detector.CFARGuardHz = float64(cfg.Detector.CFARGuardCells) * 62.5 } if cfg.Detector.CFARTrainHz <= 0 && cfg.Detector.CFARTrainCells > 0 { cfg.Detector.CFARTrainHz = float64(cfg.Detector.CFARTrainCells) * 62.5 } if cfg.Detector.CFARGuardHz <= 0 { cfg.Detector.CFARGuardHz = 500 } if cfg.Detector.CFARTrainHz <= 0 { cfg.Detector.CFARTrainHz = 5000 } if cfg.Detector.CFARGuardCells <= 0 { cfg.Detector.CFARGuardCells = 3 } if cfg.Detector.CFARTrainCells <= 0 { cfg.Detector.CFARTrainCells = 24 } if cfg.Detector.CFARRank <= 0 || cfg.Detector.CFARRank > 2*cfg.Detector.CFARTrainCells { cfg.Detector.CFARRank = int(math.Round(0.75 * float64(2*cfg.Detector.CFARTrainCells))) if cfg.Detector.CFARRank <= 0 { cfg.Detector.CFARRank = 1 } } if cfg.Detector.CFARScaleDb <= 0 { cfg.Detector.CFARScaleDb = 6 } if cfg.Detector.EdgeMarginDb <= 0 { cfg.Detector.EdgeMarginDb = 3.0 } if cfg.Detector.MaxSignalBwHz <= 0 { cfg.Detector.MaxSignalBwHz = 150000 } if cfg.Detector.MergeGapHz <= 0 { cfg.Detector.MergeGapHz = 5000 } if cfg.Detector.ClassHistorySize <= 0 { cfg.Detector.ClassHistorySize = 10 } if cfg.Detector.ClassSwitchRatio <= 0 || cfg.Detector.ClassSwitchRatio > 1 { cfg.Detector.ClassSwitchRatio = 0.6 } if cfg.Pipeline.Mode == "" { cfg.Pipeline.Mode = "legacy" } if cfg.Pipeline.Goals.Intent == "" { cfg.Pipeline.Goals.Intent = "general-monitoring" } if cfg.Pipeline.Goals.MonitorSpanHz <= 0 && cfg.Pipeline.Goals.MonitorStartHz != 0 && cfg.Pipeline.Goals.MonitorEndHz != 0 && cfg.Pipeline.Goals.MonitorEndHz > cfg.Pipeline.Goals.MonitorStartHz { cfg.Pipeline.Goals.MonitorSpanHz = cfg.Pipeline.Goals.MonitorEndHz - cfg.Pipeline.Goals.MonitorStartHz } 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.Surveillance.DisplayBins <= 0 { cfg.Surveillance.DisplayBins = cfg.FFTSize } if cfg.Surveillance.DisplayFPS <= 0 { cfg.Surveillance.DisplayFPS = cfg.FrameRate } 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.Refinement.MinSpanHz < 0 { cfg.Refinement.MinSpanHz = 0 } if cfg.Refinement.MaxSpanHz < 0 { cfg.Refinement.MaxSpanHz = 0 } if cfg.Refinement.MaxSpanHz > 0 && cfg.Refinement.MinSpanHz > cfg.Refinement.MaxSpanHz { cfg.Refinement.MaxSpanHz = cfg.Refinement.MinSpanHz } 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 } if cfg.WaterfallLines <= 0 { cfg.WaterfallLines = 200 } if cfg.WebRoot == "" { cfg.WebRoot = "web" } if cfg.WebAddr == "" { cfg.WebAddr = ":8080" } if cfg.EventPath == "" { cfg.EventPath = "data/events.jsonl" } if cfg.SampleRate <= 0 { cfg.SampleRate = 2_048_000 } if cfg.ClassifierMode == "" { cfg.ClassifierMode = "combined" } switch cfg.ClassifierMode { case "rule", "math", "combined": default: cfg.ClassifierMode = "combined" } 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 } if cfg.CenterHz == 0 { cfg.CenterHz = 100.0e6 } if cfg.Recorder.OutputDir == "" { cfg.Recorder.OutputDir = "data/recordings" } if cfg.Recorder.RingSeconds <= 0 { cfg.Recorder.RingSeconds = 8 } if cfg.Recorder.DeemphasisUs == 0 { cfg.Recorder.DeemphasisUs = 50 } if cfg.Recorder.ExtractionTaps <= 0 { cfg.Recorder.ExtractionTaps = 101 } if cfg.Recorder.ExtractionTaps > 301 { cfg.Recorder.ExtractionTaps = 301 } if cfg.Recorder.ExtractionTaps%2 == 0 { cfg.Recorder.ExtractionTaps++ // must be odd } if cfg.Recorder.ExtractionBwMult <= 0 { cfg.Recorder.ExtractionBwMult = 1.2 } return cfg } func (c Config) FrameInterval() time.Duration { fps := c.FrameRate if fps <= 0 { fps = 15 } return time.Second / time.Duration(fps) }