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

Add averaging/max-hold and tuner bandwidth control

master
Jan Svabenik 4 дней назад
Родитель
Сommit
4f034f17e8
12 измененных файлов: 193 добавлений и 23 удалений
  1. +5
    -3
      README.md
  2. +7
    -7
      cmd/sdrd/main.go
  3. +1
    -0
      config.yaml
  4. +5
    -0
      internal/config/config.go
  5. +1
    -1
      internal/mock/source.go
  6. +7
    -0
      internal/runtime/runtime.go
  7. +5
    -0
      internal/runtime/runtime_test.go
  8. +1
    -1
      internal/sdr/source.go
  9. +46
    -4
      internal/sdrplay/sdrplay.go
  10. +1
    -1
      internal/sdrplay/sdrplay_stub.go
  11. +83
    -6
      web/app.js
  12. +31
    -0
      web/index.html

+ 5
- 3
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).


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


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


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


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


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


+ 5
- 0
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: &center,
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) {


+ 1
- 1
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")

+ 46
- 4
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()


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

+ 83
- 6
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)) {


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

@@ -46,6 +46,20 @@
</select>
</div>

<label class="control-label" for="bwSelect">Tuner BW (kHz)</label>
<div class="control-row">
<select id="bwSelect">
<option value="200">200</option>
<option value="300">300</option>
<option value="600">600</option>
<option value="1536">1536</option>
<option value="5000">5000</option>
<option value="6000">6000</option>
<option value="7000">7000</option>
<option value="8000">8000</option>
</select>
</div>

<label class="control-label" for="fftSelect">FFT Size</label>
<div class="control-row">
<select id="fftSelect">
@@ -87,6 +101,23 @@
<span>IQ Balance</span>
</label>
</div>

<label class="control-label">Display</label>
<div class="control-row">
<select id="avgSelect">
<option value="0">Averaging Off</option>
<option value="0.4">Averaging Fast</option>
<option value="0.2">Averaging Medium</option>
<option value="0.1">Averaging Slow</option>
</select>
</div>
<div class="toggle-row">
<label class="toggle">
<input id="maxHoldToggle" type="checkbox" />
<span>Max Hold</span>
</label>
<button id="maxHoldReset" type="button" class="preset-btn">Reset Max</button>
</div>
</div>
</section>
<section class="panel spectrum-panel">


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