diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index a312b3c..bcfa178 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -12,8 +12,6 @@ import ( "path/filepath" "runtime/debug" "sort" - - "sdr-visual-suite/internal/config" "strconv" "strings" "sync" @@ -23,6 +21,7 @@ import ( "github.com/gorilla/websocket" "sdr-visual-suite/internal/classifier" + "sdr-visual-suite/internal/config" "sdr-visual-suite/internal/detector" "sdr-visual-suite/internal/dsp" "sdr-visual-suite/internal/events" @@ -853,7 +852,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * } eventMu.Unlock() if rec != nil { - rec.OnEvents(finished) + go rec.OnEvents(finished) } h.broadcast(SpectrumFrame{ Timestamp: now.UnixMilli(), diff --git a/internal/config/config.go b/internal/config/config.go index 43578ef..48e21b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -115,6 +115,11 @@ func Default() Config { 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 @@ -122,6 +127,10 @@ func Load(path string) (Config, error) { 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 } @@ -182,7 +191,7 @@ func Load(path string) (Config, error) { if cfg.Recorder.RingSeconds <= 0 { cfg.Recorder.RingSeconds = 8 } - return cfg, nil + return cfg } func (c Config) FrameInterval() time.Duration { diff --git a/internal/config/save.go b/internal/config/save.go index a81358f..3d630d9 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -2,15 +2,27 @@ package config import ( "os" + "path/filepath" + "strings" "gopkg.in/yaml.v3" ) -// Save writes the current config back to disk. +// Save writes the current config to an autosave file to preserve the original YAML formatting/comments. +// Autosave path: .autosave func Save(path string, cfg Config) error { b, err := yaml.Marshal(cfg) if err != nil { return err } - return os.WriteFile(path, b, 0o644) + return os.WriteFile(autosavePath(path), b, 0o644) +} + +func autosavePath(path string) string { + ext := filepath.Ext(path) + if ext == "" { + return path + ".autosave" + } + base := strings.TrimSuffix(path, ext) + return base + ".autosave" + ext } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 8f1f469..57222a4 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -257,6 +257,7 @@ func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { v := spectrum[i] d.ema[i] = alpha*v + (1-alpha)*d.ema[i] } + // IMPORTANT: caller must not modify returned slice return d.ema } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 428594f..6e41579 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -2,6 +2,7 @@ package runtime import ( "errors" + "math" "sync" "sdr-visual-suite/internal/config" @@ -131,15 +132,29 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { next.Detector.HoldMs = *update.Detector.HoldMs } if update.Detector.EmaAlpha != nil { - next.Detector.EmaAlpha = *update.Detector.EmaAlpha + v := *update.Detector.EmaAlpha + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 1 { + return m.cfg, errors.New("ema_alpha must be between 0 and 1") + } + next.Detector.EmaAlpha = v } if update.Detector.HysteresisDb != nil { - next.Detector.HysteresisDb = *update.Detector.HysteresisDb + v := *update.Detector.HysteresisDb + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return m.cfg, errors.New("hysteresis_db must be >= 0") + } + next.Detector.HysteresisDb = v } if update.Detector.MinStableFrames != nil { + if *update.Detector.MinStableFrames < 1 { + return m.cfg, errors.New("min_stable_frames must be >= 1") + } next.Detector.MinStableFrames = *update.Detector.MinStableFrames } if update.Detector.GapToleranceMs != nil { + if *update.Detector.GapToleranceMs < 0 { + return m.cfg, errors.New("gap_tolerance_ms must be >= 0") + } next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs } if update.Detector.CFAREnabled != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 9bed40b..00c949b 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -72,14 +72,72 @@ func TestApplyConfigUpdate(t *testing.T) { func TestApplyConfigRejectsInvalid(t *testing.T) { cfg := config.Default() - mgr := New(cfg) - bad := 0 - if _, err := mgr.ApplyConfig(ConfigUpdate{SampleRate: &bad}); err == nil { - t.Fatalf("expected error") + + { + mgr := New(cfg) + bad := 0 + if _, err := mgr.ApplyConfig(ConfigUpdate{SampleRate: &bad}); err == nil { + t.Fatalf("expected error") + } + snap := mgr.Snapshot() + if snap.SampleRate != cfg.SampleRate { + t.Fatalf("sample rate changed on error") + } + } + + { + mgr := New(cfg) + badAlpha := -0.5 + if _, err := mgr.ApplyConfig(ConfigUpdate{Detector: &DetectorUpdate{EmaAlpha: &badAlpha}}); err == nil { + t.Fatalf("expected ema_alpha error") + } + if mgr.Snapshot().Detector.EmaAlpha != cfg.Detector.EmaAlpha { + t.Fatalf("ema_alpha changed on error") + } + } + + { + mgr := New(cfg) + badAlpha := 1.5 + if _, err := mgr.ApplyConfig(ConfigUpdate{Detector: &DetectorUpdate{EmaAlpha: &badAlpha}}); err == nil { + t.Fatalf("expected ema_alpha upper bound error") + } + if mgr.Snapshot().Detector.EmaAlpha != cfg.Detector.EmaAlpha { + t.Fatalf("ema_alpha changed on error") + } } - snap := mgr.Snapshot() - if snap.SampleRate != cfg.SampleRate { - t.Fatalf("sample rate changed on error") + + { + mgr := New(cfg) + badHyst := -1.0 + if _, err := mgr.ApplyConfig(ConfigUpdate{Detector: &DetectorUpdate{HysteresisDb: &badHyst}}); err == nil { + t.Fatalf("expected hysteresis_db error") + } + if mgr.Snapshot().Detector.HysteresisDb != cfg.Detector.HysteresisDb { + t.Fatalf("hysteresis_db changed on error") + } + } + + { + mgr := New(cfg) + badStable := 0 + if _, err := mgr.ApplyConfig(ConfigUpdate{Detector: &DetectorUpdate{MinStableFrames: &badStable}}); err == nil { + t.Fatalf("expected min_stable_frames error") + } + if mgr.Snapshot().Detector.MinStableFrames != cfg.Detector.MinStableFrames { + t.Fatalf("min_stable_frames changed on error") + } + } + + { + mgr := New(cfg) + badGap := -10 + if _, err := mgr.ApplyConfig(ConfigUpdate{Detector: &DetectorUpdate{GapToleranceMs: &badGap}}); err == nil { + t.Fatalf("expected gap_tolerance_ms error") + } + if mgr.Snapshot().Detector.GapToleranceMs != cfg.Detector.GapToleranceMs { + t.Fatalf("gap_tolerance_ms changed on error") + } } } diff --git a/web/app.js b/web/app.js index 37bf491..9832faa 100644 --- a/web/app.js +++ b/web/app.js @@ -34,6 +34,8 @@ const cfarGuardInput = qs('cfarGuardInput'); const cfarTrainInput = qs('cfarTrainInput'); const cfarRankInput = qs('cfarRankInput'); const cfarScaleInput = qs('cfarScaleInput'); +const minDurationInput = qs('minDurationInput'); +const holdInput = qs('holdInput'); const emaAlphaInput = qs('emaAlphaInput'); const hysteresisInput = qs('hysteresisInput'); const stableFramesInput = qs('stableFramesInput'); @@ -83,6 +85,7 @@ const liveListenEventBtn = qs('liveListenEventBtn'); const decodeEventBtn = qs('decodeEventBtn'); const decodeModeSelect = qs('decodeMode'); const recordingMetaEl = qs('recordingMeta'); +const decodeResultEl = qs('decodeResult'); const recordingMetaLink = qs('recordingMetaLink'); const recordingIQLink = qs('recordingIQLink'); const recordingAudioLink = qs('recordingAudioLink'); @@ -98,6 +101,8 @@ const railTabs = Array.from(document.querySelectorAll('.rail-tab')); const tabPanels = Array.from(document.querySelectorAll('.tab-panel')); const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); const liveListenBtn = qs('liveListenBtn'); +const listenSecondsInput = qs('listenSeconds'); +const listenModeSelect = qs('listenMode'); let latest = null; let currentConfig = null; @@ -113,6 +118,9 @@ let avgAlpha = 0; let avgSpectrum = null; let maxSpectrum = null; let lastFFTSize = null; +let processedSpectrum = null; +let processedSpectrumSource = null; +let processingDirty = true; let pendingConfigUpdate = null; let pendingSettingsUpdate = null; @@ -201,6 +209,10 @@ function maxInBinRange(spectrum, b0, b1) { return max; } +function markSpectrumDirty() { + processingDirty = true; +} + function processSpectrum(spectrum) { if (!spectrum) return spectrum; let base = spectrum; @@ -230,6 +242,18 @@ function processSpectrum(spectrum) { function resetProcessingCaches() { avgSpectrum = null; maxSpectrum = null; + processedSpectrum = null; + processedSpectrumSource = null; + processingDirty = true; +} + +function getProcessedSpectrum() { + if (!latest?.spectrum_db) return null; + if (!processingDirty && processedSpectrumSource === latest.spectrum_db) return processedSpectrum; + processedSpectrum = processSpectrum(latest.spectrum_db); + processedSpectrumSource = latest.spectrum_db; + processingDirty = false; + return processedSpectrum; } function resizeCanvas(canvas) { @@ -311,6 +335,7 @@ function applyConfigToUI(cfg) { if (recDecodeToggle) recDecodeToggle.checked = !!cfg.recorder.auto_decode; if (recMinSNR) recMinSNR.value = cfg.recorder.min_snr_db ?? 10; if (recMaxDisk) recMaxDisk.value = cfg.recorder.max_disk_mb ?? 0; + if (recClassFilter) recClassFilter.value = (cfg.recorder.class_filter || []).join(', '); } spanInput.value = (cfg.sample_rate / zoom / 1e6).toFixed(3); isSyncingConfig = false; @@ -460,7 +485,8 @@ function renderBandNavigator() { const h = navCanvas.height; ctx.clearRect(0, 0, w, h); - const display = processSpectrum(latest.spectrum_db); + const display = getProcessedSpectrum(); + if (!display) return; const minDb = -120; const maxDb = 0; @@ -523,7 +549,8 @@ function renderSpectrum() { const h = spectrumCanvas.height; ctx.clearRect(0, 0, w, h); - const display = processSpectrum(latest.spectrum_db); + const display = getProcessedSpectrum(); + if (!display) return; const n = display.length; const span = latest.sample_rate / zoom; const startHz = latest.center_hz - span / 2 + pan * span; @@ -612,7 +639,8 @@ function renderWaterfall() { const prev = ctx.getImageData(0, 0, w, h - 1); ctx.putImageData(prev, 0, 1); - const display = processSpectrum(latest.spectrum_db); + const display = getProcessedSpectrum(); + if (!display) return; const n = display.length; const span = latest.sample_rate / zoom; const startHz = latest.center_hz - span / 2 + pan * span; @@ -766,7 +794,8 @@ function renderDetailSpectrogram() { ctx.fillRect(0, 0, w, h); if (!latest || !ev) return; - const display = processSpectrum(latest.spectrum_db); + const display = getProcessedSpectrum(); + if (!display) return; const n = display.length; const localSpan = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 4, latest.sample_rate / 10)); const startHz = ev.center_hz - localSpan / 2; @@ -981,6 +1010,7 @@ function connect() { ws.onopen = () => setWsBadge('Live', 'ok'); ws.onmessage = (ev) => { latest = JSON.parse(ev.data); + markSpectrumDirty(); if (followLive) pan = 0; updateHeroMetrics(); renderLists(); @@ -1163,6 +1193,30 @@ if (cfarScaleInput) cfarScaleInput.addEventListener('change', () => { const v = parseFloat(cfarScaleInput.value); if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_scale_db: v } }); }); +if (minDurationInput) minDurationInput.addEventListener('change', () => { + const v = parseInt(minDurationInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { min_duration_ms: v } }); +}); +if (holdInput) holdInput.addEventListener('change', () => { + const v = parseInt(holdInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { hold_ms: v } }); +}); +if (emaAlphaInput) emaAlphaInput.addEventListener('change', () => { + const v = parseFloat(emaAlphaInput.value); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { ema_alpha: v } }); +}); +if (hysteresisInput) hysteresisInput.addEventListener('change', () => { + const v = parseFloat(hysteresisInput.value); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { hysteresis_db: v } }); +}); +if (stableFramesInput) stableFramesInput.addEventListener('change', () => { + const v = parseInt(stableFramesInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { min_stable_frames: v } }); +}); +if (gapToleranceInput) gapToleranceInput.addEventListener('change', () => { + const v = parseInt(gapToleranceInput.value, 10); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { gap_tolerance_ms: v } }); +}); agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); @@ -1175,16 +1229,27 @@ if (recDemodToggle) recDemodToggle.addEventListener('change', () => queueConfigU if (recDecodeToggle) recDecodeToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { auto_decode: recDecodeToggle.checked } })); if (recMinSNR) recMinSNR.addEventListener('change', () => queueConfigUpdate({ recorder: { min_snr_db: parseFloat(recMinSNR.value) } })); if (recMaxDisk) recMaxDisk.addEventListener('change', () => queueConfigUpdate({ recorder: { max_disk_mb: parseInt(recMaxDisk.value || '0', 10) } })); +if (recClassFilter) recClassFilter.addEventListener('change', () => { + const list = (recClassFilter.value || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + queueConfigUpdate({ recorder: { class_filter: list } }); +}); avgSelect.addEventListener('change', () => { avgAlpha = parseFloat(avgSelect.value) || 0; - avgSpectrum = null; + resetProcessingCaches(); }); maxHoldToggle.addEventListener('change', () => { maxHold = maxHoldToggle.checked; - if (!maxHold) maxSpectrum = null; + maxSpectrum = null; + markSpectrumDirty(); +}); +resetMaxBtn.addEventListener('click', () => { + maxSpectrum = null; + markSpectrumDirty(); }); -resetMaxBtn.addEventListener('click', () => { maxSpectrum = null; }); followBtn.addEventListener('click', () => { followLive = true; pan = 0; }); fitBtn.addEventListener('click', fitView); timelineFollowBtn.addEventListener('click', () => { timelineFrozen = false; }); @@ -1338,6 +1403,7 @@ window.addEventListener('keydown', (ev) => { maxHold = !maxHold; maxHoldToggle.checked = maxHold; if (!maxHold) maxSpectrum = null; + markSpectrumDirty(); } else if (ev.key.toLowerCase() === 'g') { gpuToggle.checked = !gpuToggle.checked; queueConfigUpdate({ use_gpu_fft: gpuToggle.checked }); diff --git a/web/index.html b/web/index.html index 13a2c32..024b8a1 100644 --- a/web/index.html +++ b/web/index.html @@ -355,6 +355,3 @@ -t> - -