From 4f034f17e80f78e9d0f5deed174b398912c74281 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Tue, 17 Mar 2026 11:22:33 +0100 Subject: [PATCH] Add averaging/max-hold and tuner bandwidth control --- README.md | 8 +-- cmd/sdrd/main.go | 14 ++--- config.yaml | 1 + internal/config/config.go | 5 ++ internal/mock/source.go | 2 +- internal/runtime/runtime.go | 7 +++ internal/runtime/runtime_test.go | 5 ++ internal/sdr/source.go | 2 +- internal/sdrplay/sdrplay.go | 50 ++++++++++++++++-- internal/sdrplay/sdrplay_stub.go | 2 +- web/app.js | 89 +++++++++++++++++++++++++++++--- web/index.html | 31 +++++++++++ 12 files changed, 193 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0b2a1dc..19bf7d5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event - In-browser spectrogram slice for selected events - Basic detector with event JSONL output (`data/events.jsonl`) - Events API (`/api/events?limit=...&since=...`) -- Runtime UI controls for center frequency, span, FFT size, gain, AGC, DC block, IQ balance, detector threshold +- Runtime UI controls for center frequency, span, sample rate, tuner bandwidth, FFT size, gain, AGC, DC block, IQ balance, detector threshold +- Display controls: averaging + max-hold - Recorded clips list placeholder (metadata only for now) - Windows + Linux support - Mock mode for testing without hardware @@ -48,7 +49,8 @@ Edit `config.yaml`: - `center_hz`: center frequency - `sample_rate`: sample rate - `fft_size`: FFT size -- `gain_db`: device gain +- `gain_db`: device gain (gain reduction) +- `tuner_bw_khz`: tuner bandwidth (200/300/600/1536/5000/6000/7000/8000) - `agc`: enable automatic gain control - `dc_block`: enable DC blocking filter - `iq_balance`: enable basic IQ imbalance correction @@ -59,7 +61,7 @@ Edit `config.yaml`: The UI is served from `web/` and connects to `/ws` for spectrum frames. ### Controls Panel -Use the right-side controls to adjust center frequency, span, FFT size, gain, AGC, DC block, IQ balance, and detector threshold. Preset buttons provide quick jumps to 40m/20m/17m. +Use the right-side controls to adjust center frequency, span (zoom), sample rate, tuner bandwidth, FFT size, gain, AGC, DC block, IQ balance, and detector threshold. Preset buttons provide quick jumps to 40m/20m/17m. ### Event Timeline - The timeline panel displays recent events (time vs frequency). diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index 2ded0b9..6c684a5 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -99,7 +99,7 @@ func (m *sourceManager) ApplyConfig(cfg config.Config) error { defer m.mu.Unlock() if updatable, ok := m.src.(sdr.ConfigurableSource); ok { - if err := updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC); err == nil { + if err := updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz); err == nil { return nil } } @@ -158,16 +158,16 @@ func main() { if mockFlag { src := mock.New(cfg.SampleRate) if updatable, ok := interface{}(src).(sdr.ConfigurableSource); ok { - _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC) + _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz) } return src, nil } - src, err := sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb) + src, err := sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.TunerBwKHz) if err != nil { return nil, err } if updatable, ok := src.(sdr.ConfigurableSource); ok { - _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC) + _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz) } return src, nil } @@ -241,7 +241,7 @@ func main() { http.Error(w, err.Error(), http.StatusBadRequest) return } - sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC + sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz if sourceChanged { if err := srcMgr.ApplyConfig(next); err != nil { cfgManager.Replace(prev) @@ -295,10 +295,10 @@ func main() { http.Error(w, err.Error(), http.StatusBadRequest) return } - if prev.AGC != next.AGC { + if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz { if err := srcMgr.ApplyConfig(next); err != nil { cfgManager.Replace(prev) - http.Error(w, "failed to apply agc", http.StatusInternalServerError) + http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError) return } } diff --git a/config.yaml b/config.yaml index c615eff..b37bd4b 100644 --- a/config.yaml +++ b/config.yaml @@ -6,6 +6,7 @@ center_hz: 7.1e6 sample_rate: 2048000 fft_size: 2048 gain_db: 30 +tuner_bw_khz: 1536 agc: false dc_block: false iq_balance: false diff --git a/internal/config/config.go b/internal/config/config.go index 86afb3d..b1c8d6b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type Config struct { 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"` AGC bool `yaml:"agc" json:"agc"` DCBlock bool `yaml:"dc_block" json:"dc_block"` IQBalance bool `yaml:"iq_balance" json:"iq_balance"` @@ -45,6 +46,7 @@ func Default() Config { SampleRate: 2_048_000, FFTSize: 2048, GainDb: 30, + TunerBwKHz: 1536, AGC: false, DCBlock: false, IQBalance: false, @@ -93,6 +95,9 @@ func Load(path string) (Config, error) { if cfg.FFTSize <= 0 { cfg.FFTSize = 2048 } + if cfg.TunerBwKHz <= 0 { + cfg.TunerBwKHz = 1536 + } if cfg.CenterHz == 0 { cfg.CenterHz = 100.0e6 } diff --git a/internal/mock/source.go b/internal/mock/source.go index 6a1a77e..412966b 100644 --- a/internal/mock/source.go +++ b/internal/mock/source.go @@ -27,7 +27,7 @@ func New(sampleRate int) *Source { func (s *Source) Start() error { return nil } func (s *Source) Stop() error { return nil } -func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) error { +func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool, bwKHz int) error { s.mu.Lock() defer s.mu.Unlock() if sampleRate > 0 { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index abf9c93..364185c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -12,6 +12,7 @@ type ConfigUpdate struct { SampleRate *int `json:"sample_rate"` FFTSize *int `json:"fft_size"` GainDb *float64 `json:"gain_db"` + TunerBwKHz *int `json:"tuner_bw_khz"` Detector *DetectorUpdate `json:"detector"` } @@ -71,6 +72,12 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { if update.GainDb != nil { next.GainDb = *update.GainDb } + if update.TunerBwKHz != nil { + if *update.TunerBwKHz <= 0 { + return m.cfg, errors.New("tuner_bw_khz must be > 0") + } + next.TunerBwKHz = *update.TunerBwKHz + } if update.Detector != nil { if update.Detector.ThresholdDb != nil { next.Detector.ThresholdDb = *update.Detector.ThresholdDb diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 4373b0d..d020de2 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -14,11 +14,13 @@ func TestApplyConfigUpdate(t *testing.T) { sampleRate := 1_024_000 fftSize := 4096 threshold := -35.0 + bw := 1536 updated, err := mgr.ApplyConfig(ConfigUpdate{ CenterHz: ¢er, SampleRate: &sampleRate, FFTSize: &fftSize, + TunerBwKHz: &bw, Detector: &DetectorUpdate{ ThresholdDb: &threshold, }, @@ -38,6 +40,9 @@ func TestApplyConfigUpdate(t *testing.T) { if updated.Detector.ThresholdDb != threshold { t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) } + if updated.TunerBwKHz != bw { + t.Fatalf("tuner bw: %v", updated.TunerBwKHz) + } } func TestApplyConfigRejectsInvalid(t *testing.T) { diff --git a/internal/sdr/source.go b/internal/sdr/source.go index 59d8a43..f8565b0 100644 --- a/internal/sdr/source.go +++ b/internal/sdr/source.go @@ -9,7 +9,7 @@ type Source interface { } type ConfigurableSource interface { - UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) error + UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool, bwKHz int) error } var ErrNotImplemented = errors.New("sdrplay support not built; build with -tags sdrplay or use --mock") diff --git a/internal/sdrplay/sdrplay.go b/internal/sdrplay/sdrplay.go index f84f6af..31f1140 100644 --- a/internal/sdrplay/sdrplay.go +++ b/internal/sdrplay/sdrplay.go @@ -41,6 +41,10 @@ static void sdrplay_set_gain(sdrplay_api_DeviceParamsT *p, unsigned int grDb) { if (p && p->rxChannelA) p->rxChannelA->tunerParams.gain.gRdB = grDb; } +static void sdrplay_set_bw(sdrplay_api_DeviceParamsT *p, sdrplay_api_Bw_MHzT bw) { + if (p && p->rxChannelA) p->rxChannelA->tunerParams.bwType = bw; +} + static void sdrplay_set_if_zero(sdrplay_api_DeviceParamsT *p) { if (p && p->rxChannelA) p->rxChannelA->tunerParams.ifType = sdrplay_api_IF_Zero; } @@ -87,20 +91,22 @@ type Source struct { gainDb float64 agc bool buf []complex64 + bwKHz int } -func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { +func New(sampleRate int, centerHz float64, gainDb float64, bwKHz int) (sdr.Source, error) { s := &Source{ ch: make(chan []complex64, 16), sampleRate: sampleRate, centerHz: centerHz, gainDb: gainDb, + bwKHz: bwKHz, } s.handle = cgo.NewHandle(s) - return s, s.configure(sampleRate, centerHz, gainDb) + return s, s.configure(sampleRate, centerHz, gainDb, bwKHz) } -func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) error { +func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64, bwKHz int) error { if err := cErr(C.sdrplay_api_Open()); err != nil { return fmt.Errorf("sdrplay_api_Open: %w", err) } @@ -127,6 +133,12 @@ func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) err C.sdrplay_set_fs(s.params, C.double(sampleRate)) C.sdrplay_set_rf(s.params, C.double(centerHz)) C.sdrplay_set_gain(s.params, C.uint(gainDb)) + if bw := bwEnum(bwKHz); bw != 0 { + C.sdrplay_set_bw(s.params, bw) + if bwKHz > 0 { + s.bwKHz = bwKHz + } + } C.sdrplay_set_if_zero(s.params) C.sdrplay_disable_agc(s.params) @@ -140,7 +152,7 @@ func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) err func (s *Source) Start() error { return nil } -func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) error { +func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool, bwKHz int) error { s.mu.Lock() defer s.mu.Unlock() if s.params == nil { @@ -172,6 +184,13 @@ func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, updateReasons |= C.int(C.sdrplay_api_Update_Ctrl_Agc) s.agc = agc } + if bwKHz > 0 && bwKHz != s.bwKHz { + if bw := bwEnum(bwKHz); bw != 0 { + C.sdrplay_set_bw(s.params, bw) + updateReasons |= C.int(C.sdrplay_api_Update_Tuner_BwType) + s.bwKHz = bwKHz + } + } if updateReasons == 0 { return nil } @@ -181,6 +200,29 @@ func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, return nil } +func bwEnum(khz int) C.sdrplay_api_Bw_MHzT { + switch khz { + case 200: + return C.sdrplay_api_BW_0_200 + case 300: + return C.sdrplay_api_BW_0_300 + case 600: + return C.sdrplay_api_BW_0_600 + case 1536: + return C.sdrplay_api_BW_1_536 + case 5000: + return C.sdrplay_api_BW_5_000 + case 6000: + return C.sdrplay_api_BW_6_000 + case 7000: + return C.sdrplay_api_BW_7_000 + case 8000: + return C.sdrplay_api_BW_8_000 + default: + return 0 + } +} + func (s *Source) Stop() error { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/sdrplay/sdrplay_stub.go b/internal/sdrplay/sdrplay_stub.go index 7fbc83a..643c90d 100644 --- a/internal/sdrplay/sdrplay_stub.go +++ b/internal/sdrplay/sdrplay_stub.go @@ -4,6 +4,6 @@ package sdrplay import "sdr-visual-suite/internal/sdr" -func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { +func New(sampleRate int, centerHz float64, gainDb float64, bwKHz int) (sdr.Source, error) { return nil, sdr.ErrNotImplemented } diff --git a/web/app.js b/web/app.js index 13ad4df..ae7d203 100644 --- a/web/app.js +++ b/web/app.js @@ -18,6 +18,7 @@ const centerInput = document.getElementById('centerInput'); const spanInput = document.getElementById('spanInput'); const sampleRateSelect = document.getElementById('sampleRateSelect'); const fftSelect = document.getElementById('fftSelect'); +const bwSelect = document.getElementById('bwSelect'); const gainRange = document.getElementById('gainRange'); const gainInput = document.getElementById('gainInput'); const thresholdRange = document.getElementById('thresholdRange'); @@ -25,6 +26,9 @@ const thresholdInput = document.getElementById('thresholdInput'); const agcToggle = document.getElementById('agcToggle'); const dcToggle = document.getElementById('dcToggle'); const iqToggle = document.getElementById('iqToggle'); +const avgSelect = document.getElementById('avgSelect'); +const maxHoldToggle = document.getElementById('maxHoldToggle'); +const maxHoldReset = document.getElementById('maxHoldReset'); const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); let latest = null; @@ -42,6 +46,11 @@ let pendingSettingsUpdate = null; let configTimer = null; let settingsTimer = null; const GAIN_MAX = 60; +let avgAlpha = 0; +let avgSpectrum = null; +let maxHold = false; +let maxSpectrum = null; +let lastFFTSize = null; const events = []; const eventsById = new Map(); @@ -95,6 +104,14 @@ function applyConfigToUI(cfg) { const spanMHz = toMHz(cfg.sample_rate / zoom); spanInput.value = spanMHz.toFixed(3); fftSelect.value = String(cfg.fft_size); + if (lastFFTSize !== cfg.fft_size) { + avgSpectrum = null; + maxSpectrum = null; + lastFFTSize = cfg.fft_size; + } + if (bwSelect) { + bwSelect.value = String(cfg.tuner_bw_khz || 1536); + } const uiGain = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - cfg.gain_db)); gainRange.value = uiGain; gainInput.value = uiGain; @@ -213,6 +230,32 @@ function maxInBinRange(spectrum, b0, b1) { return max; } +function processSpectrum(spectrum) { + if (!spectrum) return spectrum; + let base = spectrum; + if (avgAlpha > 0) { + if (!avgSpectrum || avgSpectrum.length !== spectrum.length) { + avgSpectrum = spectrum.slice(); + } else { + for (let i = 0; i < spectrum.length; i++) { + avgSpectrum[i] = avgAlpha * spectrum[i] + (1 - avgAlpha) * avgSpectrum[i]; + } + } + base = avgSpectrum; + } + if (maxHold) { + if (!maxSpectrum || maxSpectrum.length !== base.length) { + maxSpectrum = base.slice(); + } else { + for (let i = 0; i < base.length; i++) { + if (base[i] > maxSpectrum[i]) maxSpectrum[i] = base[i]; + } + } + base = maxSpectrum; + } + return base; +} + function snrColor(snr) { const norm = Math.max(0, Math.min(1, (snr + 5) / 30)); const [r, g, b] = colorMap(norm); @@ -238,7 +281,8 @@ function renderSpectrum() { } const { spectrum_db, sample_rate, center_hz } = latest; - const n = spectrum_db.length; + const display = processSpectrum(spectrum_db); + const n = display.length; const span = sample_rate / zoom; const startHz = center_hz - span / 2 + pan * span; const endHz = center_hz + span / 2 + pan * span; @@ -257,7 +301,7 @@ function renderSpectrum() { const f2 = startHz + ((x + 1) / w) * (endHz - startHz); const b0 = binForFreq(f1, center_hz, sample_rate, n); const b1 = binForFreq(f2, center_hz, sample_rate, n); - const v = maxInBinRange(spectrum_db, b0, b1); + const v = maxInBinRange(display, b0, b1); const y = h - ((v - minDb) / (maxDb - minDb)) * h; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); @@ -296,7 +340,8 @@ function renderWaterfall() { ctx.putImageData(image, 0, 1); const { spectrum_db, sample_rate, center_hz } = latest; - const n = spectrum_db.length; + const display = processSpectrum(spectrum_db); + const n = display.length; const span = sample_rate / zoom; const startHz = center_hz - span / 2 + pan * span; const endHz = center_hz + span / 2 + pan * span; @@ -310,7 +355,7 @@ function renderWaterfall() { const b0 = binForFreq(f1, center_hz, sample_rate, n); const b1 = binForFreq(f2, center_hz, sample_rate, n); if (b0 < n && b1 >= 0) { - const v = maxInBinRange(spectrum_db, b0, b1); + const v = maxInBinRange(display, b0, b1); const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); const [r, g, b] = colorMap(norm); row.data[x * 4 + 0] = r; @@ -411,7 +456,8 @@ function renderDetailSpectrogram(ev) { const endHz = ev.center_hz + span / 2; const { spectrum_db, sample_rate, center_hz } = latest; - const n = spectrum_db.length; + const display = processSpectrum(spectrum_db); + const n = display.length; const minDb = -120; const maxDb = 0; @@ -422,7 +468,7 @@ function renderDetailSpectrogram(ev) { const b0 = binForFreq(f1, center_hz, sample_rate, n); const b1 = binForFreq(f2, center_hz, sample_rate, n); if (b0 < n && b1 >= 0) { - const v = maxInBinRange(spectrum_db, b0, b1); + const v = maxInBinRange(display, b0, b1); const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); const [r, g, b] = colorMap(norm); row.data[x * 4 + 0] = r; @@ -518,6 +564,37 @@ if (sampleRateSelect) { }); } +if (bwSelect) { + bwSelect.addEventListener('change', () => { + const bw = parseInt(bwSelect.value, 10); + if (Number.isFinite(bw)) { + queueConfigUpdate({ tuner_bw_khz: bw }); + } + }); +} + +if (avgSelect) { + avgSelect.addEventListener('change', () => { + avgAlpha = parseFloat(avgSelect.value) || 0; + avgSpectrum = null; + }); +} + +if (maxHoldToggle) { + maxHoldToggle.addEventListener('change', () => { + maxHold = maxHoldToggle.checked; + if (!maxHold) { + maxSpectrum = null; + } + }); +} + +if (maxHoldReset) { + maxHoldReset.addEventListener('click', () => { + maxSpectrum = null; + }); +} + fftSelect.addEventListener('change', () => { const size = parseInt(fftSelect.value, 10); if (Number.isFinite(size)) { diff --git a/web/index.html b/web/index.html index 305bac0..d2153d3 100644 --- a/web/index.html +++ b/web/index.html @@ -46,6 +46,20 @@ + +
+ +
+
+ + + + + +
+
+ + +