Просмотр исходного кода

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

master
Jan Svabenik 3 дней назад
Родитель
Сommit
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" "path/filepath"
"runtime/debug" "runtime/debug"
"sort" "sort"

"sdr-visual-suite/internal/config"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -23,6 +21,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"


"sdr-visual-suite/internal/classifier" "sdr-visual-suite/internal/classifier"
"sdr-visual-suite/internal/config"
"sdr-visual-suite/internal/detector" "sdr-visual-suite/internal/detector"
"sdr-visual-suite/internal/dsp" "sdr-visual-suite/internal/dsp"
"sdr-visual-suite/internal/events" "sdr-visual-suite/internal/events"
@@ -853,7 +852,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
} }
eventMu.Unlock() eventMu.Unlock()
if rec != nil { if rec != nil {
rec.OnEvents(finished)
go rec.OnEvents(finished)
} }
h.broadcast(SpectrumFrame{ h.broadcast(SpectrumFrame{
Timestamp: now.UnixMilli(), Timestamp: now.UnixMilli(),


+ 10
- 1
internal/config/config.go Просмотреть файл

@@ -115,6 +115,11 @@ func Default() Config {


func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Default() 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) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return cfg, err return cfg, err
@@ -122,6 +127,10 @@ func Load(path string) (Config, error) {
if err := yaml.Unmarshal(b, &cfg); err != nil { if err := yaml.Unmarshal(b, &cfg); err != nil {
return cfg, err return cfg, err
} }
return applyDefaults(cfg), nil
}

func applyDefaults(cfg Config) Config {
if cfg.Detector.MinDurationMs <= 0 { if cfg.Detector.MinDurationMs <= 0 {
cfg.Detector.MinDurationMs = 250 cfg.Detector.MinDurationMs = 250
} }
@@ -182,7 +191,7 @@ func Load(path string) (Config, error) {
if cfg.Recorder.RingSeconds <= 0 { if cfg.Recorder.RingSeconds <= 0 {
cfg.Recorder.RingSeconds = 8 cfg.Recorder.RingSeconds = 8
} }
return cfg, nil
return cfg
} }


func (c Config) FrameInterval() time.Duration { func (c Config) FrameInterval() time.Duration {


+ 14
- 2
internal/config/save.go Просмотреть файл

@@ -2,15 +2,27 @@ package config


import ( import (
"os" "os"
"path/filepath"
"strings"


"gopkg.in/yaml.v3" "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 { func Save(path string, cfg Config) error {
b, err := yaml.Marshal(cfg) b, err := yaml.Marshal(cfg)
if err != nil { if err != nil {
return err 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] v := spectrum[i]
d.ema[i] = alpha*v + (1-alpha)*d.ema[i] d.ema[i] = alpha*v + (1-alpha)*d.ema[i]
} }
// IMPORTANT: caller must not modify returned slice
return d.ema return d.ema
} }




+ 17
- 2
internal/runtime/runtime.go Просмотреть файл

@@ -2,6 +2,7 @@ package runtime


import ( import (
"errors" "errors"
"math"
"sync" "sync"


"sdr-visual-suite/internal/config" "sdr-visual-suite/internal/config"
@@ -131,15 +132,29 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {
next.Detector.HoldMs = *update.Detector.HoldMs next.Detector.HoldMs = *update.Detector.HoldMs
} }
if update.Detector.EmaAlpha != nil { 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 { 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 != nil {
if *update.Detector.MinStableFrames < 1 {
return m.cfg, errors.New("min_stable_frames must be >= 1")
}
next.Detector.MinStableFrames = *update.Detector.MinStableFrames next.Detector.MinStableFrames = *update.Detector.MinStableFrames
} }
if update.Detector.GapToleranceMs != nil { 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 next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs
} }
if update.Detector.CFAREnabled != nil { 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) { func TestApplyConfigRejectsInvalid(t *testing.T) {
cfg := config.Default() 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 cfarTrainInput = qs('cfarTrainInput');
const cfarRankInput = qs('cfarRankInput'); const cfarRankInput = qs('cfarRankInput');
const cfarScaleInput = qs('cfarScaleInput'); const cfarScaleInput = qs('cfarScaleInput');
const minDurationInput = qs('minDurationInput');
const holdInput = qs('holdInput');
const emaAlphaInput = qs('emaAlphaInput'); const emaAlphaInput = qs('emaAlphaInput');
const hysteresisInput = qs('hysteresisInput'); const hysteresisInput = qs('hysteresisInput');
const stableFramesInput = qs('stableFramesInput'); const stableFramesInput = qs('stableFramesInput');
@@ -83,6 +85,7 @@ const liveListenEventBtn = qs('liveListenEventBtn');
const decodeEventBtn = qs('decodeEventBtn'); const decodeEventBtn = qs('decodeEventBtn');
const decodeModeSelect = qs('decodeMode'); const decodeModeSelect = qs('decodeMode');
const recordingMetaEl = qs('recordingMeta'); const recordingMetaEl = qs('recordingMeta');
const decodeResultEl = qs('decodeResult');
const recordingMetaLink = qs('recordingMetaLink'); const recordingMetaLink = qs('recordingMetaLink');
const recordingIQLink = qs('recordingIQLink'); const recordingIQLink = qs('recordingIQLink');
const recordingAudioLink = qs('recordingAudioLink'); 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 tabPanels = Array.from(document.querySelectorAll('.tab-panel'));
const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); const presetButtons = Array.from(document.querySelectorAll('.preset-btn'));
const liveListenBtn = qs('liveListenBtn'); const liveListenBtn = qs('liveListenBtn');
const listenSecondsInput = qs('listenSeconds');
const listenModeSelect = qs('listenMode');


let latest = null; let latest = null;
let currentConfig = null; let currentConfig = null;
@@ -113,6 +118,9 @@ let avgAlpha = 0;
let avgSpectrum = null; let avgSpectrum = null;
let maxSpectrum = null; let maxSpectrum = null;
let lastFFTSize = null; let lastFFTSize = null;
let processedSpectrum = null;
let processedSpectrumSource = null;
let processingDirty = true;


let pendingConfigUpdate = null; let pendingConfigUpdate = null;
let pendingSettingsUpdate = null; let pendingSettingsUpdate = null;
@@ -201,6 +209,10 @@ function maxInBinRange(spectrum, b0, b1) {
return max; return max;
} }


function markSpectrumDirty() {
processingDirty = true;
}

function processSpectrum(spectrum) { function processSpectrum(spectrum) {
if (!spectrum) return spectrum; if (!spectrum) return spectrum;
let base = spectrum; let base = spectrum;
@@ -230,6 +242,18 @@ function processSpectrum(spectrum) {
function resetProcessingCaches() { function resetProcessingCaches() {
avgSpectrum = null; avgSpectrum = null;
maxSpectrum = 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) { function resizeCanvas(canvas) {
@@ -311,6 +335,7 @@ function applyConfigToUI(cfg) {
if (recDecodeToggle) recDecodeToggle.checked = !!cfg.recorder.auto_decode; if (recDecodeToggle) recDecodeToggle.checked = !!cfg.recorder.auto_decode;
if (recMinSNR) recMinSNR.value = cfg.recorder.min_snr_db ?? 10; if (recMinSNR) recMinSNR.value = cfg.recorder.min_snr_db ?? 10;
if (recMaxDisk) recMaxDisk.value = cfg.recorder.max_disk_mb ?? 0; 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); spanInput.value = (cfg.sample_rate / zoom / 1e6).toFixed(3);
isSyncingConfig = false; isSyncingConfig = false;
@@ -460,7 +485,8 @@ function renderBandNavigator() {
const h = navCanvas.height; const h = navCanvas.height;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);


const display = processSpectrum(latest.spectrum_db);
const display = getProcessedSpectrum();
if (!display) return;
const minDb = -120; const minDb = -120;
const maxDb = 0; const maxDb = 0;


@@ -523,7 +549,8 @@ function renderSpectrum() {
const h = spectrumCanvas.height; const h = spectrumCanvas.height;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);


const display = processSpectrum(latest.spectrum_db);
const display = getProcessedSpectrum();
if (!display) return;
const n = display.length; const n = display.length;
const span = latest.sample_rate / zoom; const span = latest.sample_rate / zoom;
const startHz = latest.center_hz - span / 2 + pan * span; const startHz = latest.center_hz - span / 2 + pan * span;
@@ -612,7 +639,8 @@ function renderWaterfall() {
const prev = ctx.getImageData(0, 0, w, h - 1); const prev = ctx.getImageData(0, 0, w, h - 1);
ctx.putImageData(prev, 0, 1); ctx.putImageData(prev, 0, 1);


const display = processSpectrum(latest.spectrum_db);
const display = getProcessedSpectrum();
if (!display) return;
const n = display.length; const n = display.length;
const span = latest.sample_rate / zoom; const span = latest.sample_rate / zoom;
const startHz = latest.center_hz - span / 2 + pan * span; const startHz = latest.center_hz - span / 2 + pan * span;
@@ -766,7 +794,8 @@ function renderDetailSpectrogram() {
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
if (!latest || !ev) return; if (!latest || !ev) return;


const display = processSpectrum(latest.spectrum_db);
const display = getProcessedSpectrum();
if (!display) return;
const n = display.length; const n = display.length;
const localSpan = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 4, latest.sample_rate / 10)); const localSpan = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 4, latest.sample_rate / 10));
const startHz = ev.center_hz - localSpan / 2; const startHz = ev.center_hz - localSpan / 2;
@@ -981,6 +1010,7 @@ function connect() {
ws.onopen = () => setWsBadge('Live', 'ok'); ws.onopen = () => setWsBadge('Live', 'ok');
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
latest = JSON.parse(ev.data); latest = JSON.parse(ev.data);
markSpectrumDirty();
if (followLive) pan = 0; if (followLive) pan = 0;
updateHeroMetrics(); updateHeroMetrics();
renderLists(); renderLists();
@@ -1163,6 +1193,30 @@ if (cfarScaleInput) cfarScaleInput.addEventListener('change', () => {
const v = parseFloat(cfarScaleInput.value); const v = parseFloat(cfarScaleInput.value);
if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_scale_db: v } }); 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 })); agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked }));
dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.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 (recDecodeToggle) recDecodeToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { auto_decode: recDecodeToggle.checked } }));
if (recMinSNR) recMinSNR.addEventListener('change', () => queueConfigUpdate({ recorder: { min_snr_db: parseFloat(recMinSNR.value) } })); 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 (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', () => { avgSelect.addEventListener('change', () => {
avgAlpha = parseFloat(avgSelect.value) || 0; avgAlpha = parseFloat(avgSelect.value) || 0;
avgSpectrum = null;
resetProcessingCaches();
}); });
maxHoldToggle.addEventListener('change', () => { maxHoldToggle.addEventListener('change', () => {
maxHold = maxHoldToggle.checked; 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; }); followBtn.addEventListener('click', () => { followLive = true; pan = 0; });
fitBtn.addEventListener('click', fitView); fitBtn.addEventListener('click', fitView);
timelineFollowBtn.addEventListener('click', () => { timelineFrozen = false; }); timelineFollowBtn.addEventListener('click', () => { timelineFrozen = false; });
@@ -1338,6 +1403,7 @@ window.addEventListener('keydown', (ev) => {
maxHold = !maxHold; maxHold = !maxHold;
maxHoldToggle.checked = maxHold; maxHoldToggle.checked = maxHold;
if (!maxHold) maxSpectrum = null; if (!maxHold) maxSpectrum = null;
markSpectrumDirty();
} else if (ev.key.toLowerCase() === 'g') { } else if (ev.key.toLowerCase() === 'g') {
gpuToggle.checked = !gpuToggle.checked; gpuToggle.checked = !gpuToggle.checked;
queueConfigUpdate({ use_gpu_fft: gpuToggle.checked }); queueConfigUpdate({ use_gpu_fft: gpuToggle.checked });


+ 0
- 3
web/index.html Просмотреть файл

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

Загрузка…
Отмена
Сохранить