瀏覽代碼

Fix SDR v6 report issues (UI listeners, validation, autosave, perf)

master
Jan Svabenik 3 天之前
父節點
當前提交
ef852b80d7
共有 8 個文件被更改,包括 182 次插入25 次删除
  1. +2
    -3
      cmd/sdrd/main.go
  2. +10
    -1
      internal/config/config.go
  3. +14
    -2
      internal/config/save.go
  4. +1
    -0
      internal/detector/detector.go
  5. +17
    -2
      internal/runtime/runtime.go
  6. +65
    -7
      internal/runtime/runtime_test.go
  7. +73
    -7
      web/app.js
  8. +0
    -3
      web/index.html

+ 2
- 3
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(),


+ 10
- 1
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 {


+ 14
- 2
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: <path without ext>.autosave<ext>
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
}

+ 1
- 0
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
}



+ 17
- 2
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 {


+ 65
- 7
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")
}
}
}



+ 73
- 7
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 });


+ 0
- 3
web/index.html 查看文件

@@ -355,6 +355,3 @@
</script>
</body>
</html>
t>
</body>
</html>

Loading…
取消
儲存