From d1ea21181f753e26bc34f8a6025f1a43208986b1 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Tue, 17 Mar 2026 10:23:20 +0100 Subject: [PATCH] Add UI controls and runtime SDR settings --- README.md | 12 ++ cmd/sdrd/main.go | 219 +++++++++++++++++++++++++++++-- config.yaml | 11 +- internal/config/config.go | 40 +++--- internal/dsp/dsp.go | 79 +++++++++++ internal/mock/source.go | 9 ++ internal/runtime/runtime.go | 113 ++++++++++++++++ internal/runtime/runtime_test.go | 73 +++++++++++ internal/sdr/source.go | 4 + internal/sdrplay/sdrplay.go | 86 ++++++++++-- web/app.js | 196 +++++++++++++++++++++++++++ web/index.html | 65 ++++++++- web/style.css | 103 ++++++++++++++- 13 files changed, 961 insertions(+), 49 deletions(-) create mode 100644 internal/dsp/dsp.go create mode 100644 internal/runtime/runtime.go create mode 100644 internal/runtime/runtime_test.go diff --git a/README.md b/README.md index 79eedd2..0b2a1dc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ 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 - Recorded clips list placeholder (metadata only for now) - Windows + Linux support - Mock mode for testing without hardware @@ -48,16 +49,27 @@ Edit `config.yaml`: - `sample_rate`: sample rate - `fft_size`: FFT size - `gain_db`: device gain +- `agc`: enable automatic gain control +- `dc_block`: enable DC blocking filter +- `iq_balance`: enable basic IQ imbalance correction - `detector.threshold_db`: power threshold in dB - `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge ## Web UI 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. + ### Event Timeline - The timeline panel displays recent events (time vs frequency). - Click any event block to open the detail drawer with event stats and a mini spectrogram slice from the latest frame. +### Config API +- `GET /api/config`: returns the current runtime configuration. +- `POST /api/config`: updates `center_hz`, `sample_rate`, `fft_size`, `gain_db`, and `detector.threshold_db` at runtime. +- `POST /api/sdr/settings`: updates `agc`, `dc_block`, and `iq_balance` at runtime. + ### Events API `/api/events` reads from the JSONL event log and returns the most recent events: - `limit` (optional): max number of events (default 200, max 2000) diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index 742f3df..a93709a 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -18,9 +18,11 @@ import ( "sdr-visual-suite/internal/config" "sdr-visual-suite/internal/detector" + "sdr-visual-suite/internal/dsp" "sdr-visual-suite/internal/events" fftutil "sdr-visual-suite/internal/fft" "sdr-visual-suite/internal/mock" + "sdr-visual-suite/internal/runtime" "sdr-visual-suite/internal/sdr" "sdr-visual-suite/internal/sdrplay" ) @@ -64,6 +66,80 @@ func (h *hub) broadcast(frame SpectrumFrame) { } } +type sourceManager struct { + mu sync.RWMutex + src sdr.Source + newSource func(cfg config.Config) (sdr.Source, error) +} + +func newSourceManager(src sdr.Source, newSource func(cfg config.Config) (sdr.Source, error)) *sourceManager { + return &sourceManager{src: src, newSource: newSource} +} + +func (m *sourceManager) Start() error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.src.Start() +} + +func (m *sourceManager) Stop() error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.src.Stop() +} + +func (m *sourceManager) ReadIQ(n int) ([]complex64, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.src.ReadIQ(n) +} + +func (m *sourceManager) ApplyConfig(cfg config.Config) error { + m.mu.Lock() + 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 { + return nil + } + } + + old := m.src + _ = old.Stop() + next, err := m.newSource(cfg) + if err != nil { + _ = old.Start() + return err + } + if err := next.Start(); err != nil { + _ = next.Stop() + _ = old.Start() + return err + } + m.src = next + return nil +} + +type dspUpdate struct { + cfg config.Config + det *detector.Detector + window []float64 + dcBlock bool + iqBalance bool +} + +func pushDSPUpdate(ch chan dspUpdate, update dspUpdate) { + select { + case ch <- update: + default: + select { + case <-ch: + default: + } + ch <- update + } +} + func main() { var cfgPath string var mockFlag bool @@ -76,19 +152,35 @@ func main() { log.Fatalf("load config: %v", err) } - var src sdr.Source - if mockFlag { - src = mock.New(cfg.SampleRate) - } else { - src, err = sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb) + cfgManager := runtime.New(cfg) + + newSource := func(cfg config.Config) (sdr.Source, error) { + 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) + } + return src, nil + } + src, err := sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb) if err != nil { - log.Fatalf("sdrplay init failed: %v (try --mock or build with -tags sdrplay)", err) + return nil, err + } + if updatable, ok := src.(sdr.ConfigurableSource); ok { + _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC) } + return src, nil + } + + src, err := newSource(cfg) + if err != nil { + log.Fatalf("sdrplay init failed: %v (try --mock or build with -tags sdrplay)", err) } - if err := src.Start(); err != nil { + srcMgr := newSourceManager(src, newSource) + if err := srcMgr.Start(); err != nil { log.Fatalf("source start: %v", err) } - defer src.Stop() + defer srcMgr.Stop() if err := os.MkdirAll(filepath.Dir(cfg.EventPath), 0o755); err != nil { log.Fatalf("event path: %v", err) @@ -106,11 +198,12 @@ func main() { window := fftutil.Hann(cfg.FFTSize) h := newHub() + dspUpdates := make(chan dspUpdate, 1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go runDSP(ctx, src, cfg, det, window, h, eventFile) + go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, dspUpdates) upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { @@ -133,7 +226,90 @@ func main() { http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(cfg) + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(cfgManager.Snapshot()) + case http.MethodPost: + var update runtime.ConfigUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + prev := cfgManager.Snapshot() + next, err := cfgManager.ApplyConfig(update) + if err != nil { + 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 + if sourceChanged { + if err := srcMgr.ApplyConfig(next); err != nil { + cfgManager.Replace(prev) + http.Error(w, "failed to apply source config", http.StatusInternalServerError) + return + } + } + detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || + prev.Detector.MinDurationMs != next.Detector.MinDurationMs || + prev.Detector.HoldMs != next.Detector.HoldMs || + prev.SampleRate != next.SampleRate || + prev.FFTSize != next.FFTSize + windowChanged := prev.FFTSize != next.FFTSize + var newDet *detector.Detector + var newWindow []float64 + if detChanged { + newDet = detector.New(next.Detector.ThresholdDb, next.SampleRate, next.FFTSize, + time.Duration(next.Detector.MinDurationMs)*time.Millisecond, + time.Duration(next.Detector.HoldMs)*time.Millisecond) + } + if windowChanged { + newWindow = fftutil.Hann(next.FFTSize) + } + pushDSPUpdate(dspUpdates, dspUpdate{ + cfg: next, + det: newDet, + window: newWindow, + dcBlock: next.DCBlock, + iqBalance: next.IQBalance, + }) + _ = json.NewEncoder(w).Encode(next) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + http.HandleFunc("/api/sdr/settings", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var update runtime.SettingsUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + prev := cfgManager.Snapshot() + next, err := cfgManager.ApplySettings(update) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if prev.AGC != next.AGC { + if err := srcMgr.ApplyConfig(next); err != nil { + cfgManager.Replace(prev) + http.Error(w, "failed to apply agc", http.StatusInternalServerError) + return + } + } + if prev.DCBlock != next.DCBlock || prev.IQBalance != next.IQBalance { + pushDSPUpdate(dspUpdates, dspUpdate{ + cfg: next, + dcBlock: next.DCBlock, + iqBalance: next.IQBalance, + }) + } + _ = json.NewEncoder(w).Encode(next) }) http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { @@ -179,21 +355,42 @@ func main() { _ = server.Shutdown(ctxTimeout) } -func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File) { +func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File, updates <-chan dspUpdate) { ticker := time.NewTicker(cfg.FrameInterval()) defer ticker.Stop() enc := json.NewEncoder(eventFile) + dcBlocker := dsp.NewDCBlocker(0.995) + dcEnabled := cfg.DCBlock + iqEnabled := cfg.IQBalance for { select { case <-ctx.Done(): return + case upd := <-updates: + cfg = upd.cfg + if upd.det != nil { + det = upd.det + } + if upd.window != nil { + window = upd.window + } + dcEnabled = upd.dcBlock + iqEnabled = upd.iqBalance + dcBlocker.Reset() + ticker.Reset(cfg.FrameInterval()) case <-ticker.C: iq, err := src.ReadIQ(cfg.FFTSize) if err != nil { log.Printf("read IQ: %v", err) continue } + if dcEnabled { + dcBlocker.Apply(iq) + } + if iqEnabled { + dsp.IQBalance(iq) + } spectrum := fftutil.Spectrum(iq, window) now := time.Now() finished, signals := det.Process(now, spectrum, cfg.CenterHz) diff --git a/config.yaml b/config.yaml index e78b914..c615eff 100644 --- a/config.yaml +++ b/config.yaml @@ -1,11 +1,14 @@ bands: - - name: fm-test - start_hz: 99.5e6 - end_hz: 100.5e6 -center_hz: 100.0e6 + - name: 40m + start_hz: 7.0e6 + end_hz: 7.2e6 +center_hz: 7.1e6 sample_rate: 2048000 fft_size: 2048 gain_db: 30 +agc: false +dc_block: false +iq_balance: false detector: threshold_db: -20 min_duration_ms: 250 diff --git a/internal/config/config.go b/internal/config/config.go index a5d85e3..86afb3d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,29 +8,32 @@ import ( ) type Band struct { - Name string `yaml:"name"` - StartHz float64 `yaml:"start_hz"` - EndHz float64 `yaml:"end_hz"` + Name string `yaml:"name" json:"name"` + StartHz float64 `yaml:"start_hz" json:"start_hz"` + EndHz float64 `yaml:"end_hz" json:"end_hz"` } type DetectorConfig struct { - ThresholdDb float64 `yaml:"threshold_db"` - MinDurationMs int `yaml:"min_duration_ms"` - HoldMs int `yaml:"hold_ms"` + ThresholdDb float64 `yaml:"threshold_db" json:"threshold_db"` + MinDurationMs int `yaml:"min_duration_ms" json:"min_duration_ms"` + HoldMs int `yaml:"hold_ms" json:"hold_ms"` } type Config struct { - Bands []Band `yaml:"bands"` - CenterHz float64 `yaml:"center_hz"` - SampleRate int `yaml:"sample_rate"` - FFTSize int `yaml:"fft_size"` - GainDb float64 `yaml:"gain_db"` - Detector DetectorConfig `yaml:"detector"` - WebAddr string `yaml:"web_addr"` - EventPath string `yaml:"event_path"` - FrameRate int `yaml:"frame_rate"` - WaterfallLines int `yaml:"waterfall_lines"` - WebRoot string `yaml:"web_root"` + Bands []Band `yaml:"bands" json:"bands"` + CenterHz float64 `yaml:"center_hz" json:"center_hz"` + SampleRate int `yaml:"sample_rate" json:"sample_rate"` + FFTSize int `yaml:"fft_size" json:"fft_size"` + GainDb float64 `yaml:"gain_db" json:"gain_db"` + AGC bool `yaml:"agc" json:"agc"` + DCBlock bool `yaml:"dc_block" json:"dc_block"` + IQBalance bool `yaml:"iq_balance" json:"iq_balance"` + Detector DetectorConfig `yaml:"detector" json:"detector"` + WebAddr string `yaml:"web_addr" json:"web_addr"` + EventPath string `yaml:"event_path" json:"event_path"` + FrameRate int `yaml:"frame_rate" json:"frame_rate"` + WaterfallLines int `yaml:"waterfall_lines" json:"waterfall_lines"` + WebRoot string `yaml:"web_root" json:"web_root"` } func Default() Config { @@ -42,6 +45,9 @@ func Default() Config { SampleRate: 2_048_000, FFTSize: 2048, GainDb: 30, + AGC: false, + DCBlock: false, + IQBalance: false, Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500}, WebAddr: ":8080", EventPath: "data/events.jsonl", diff --git a/internal/dsp/dsp.go b/internal/dsp/dsp.go new file mode 100644 index 0000000..f1eafd5 --- /dev/null +++ b/internal/dsp/dsp.go @@ -0,0 +1,79 @@ +package dsp + +import "math" + +type DCBlocker struct { + r float64 + prevX complex64 + prevY complex64 +} + +func NewDCBlocker(r float64) *DCBlocker { + if r <= 0 || r >= 1 { + r = 0.995 + } + return &DCBlocker{r: r} +} + +func (d *DCBlocker) Reset() { + d.prevX = 0 + d.prevY = 0 +} + +func (d *DCBlocker) Apply(iq []complex64) { + if d == nil { + return + } + for i := 0; i < len(iq); i++ { + x := iq[i] + y := complex( + float32(float64(real(x)-real(d.prevX))+d.r*float64(real(d.prevY))), + float32(float64(imag(x)-imag(d.prevX))+d.r*float64(imag(d.prevY))), + ) + d.prevX = x + d.prevY = y + iq[i] = y + } +} + +func IQBalance(iq []complex64) { + if len(iq) == 0 { + return + } + var sumI, sumQ float64 + for _, v := range iq { + sumI += float64(real(v)) + sumQ += float64(imag(v)) + } + meanI := sumI / float64(len(iq)) + meanQ := sumQ / float64(len(iq)) + + var varI, varQ, cov float64 + for _, v := range iq { + i := float64(real(v)) - meanI + q := float64(imag(v)) - meanQ + varI += i * i + varQ += q * q + cov += i * q + } + n := float64(len(iq)) + varI /= n + varQ /= n + cov /= n + if varI <= 0 || varQ <= 0 { + return + } + + gain := math.Sqrt(varI / varQ) + phi := 0.5 * math.Atan2(2*cov, varI-varQ) + cosP := math.Cos(phi) + sinP := math.Sin(phi) + + for i := 0; i < len(iq); i++ { + re := float64(real(iq[i])) - meanI + im := (float64(imag(iq[i])) - meanQ) * gain + i2 := re*cosP - im*sinP + q2 := re*sinP + im*cosP + iq[i] = complex(float32(i2+meanI), float32(q2+meanQ)) + } +} diff --git a/internal/mock/source.go b/internal/mock/source.go index 0ed0047..6a1a77e 100644 --- a/internal/mock/source.go +++ b/internal/mock/source.go @@ -27,6 +27,15 @@ 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 { + s.mu.Lock() + defer s.mu.Unlock() + if sampleRate > 0 { + s.sampleRate = float64(sampleRate) + } + return nil +} + func (s *Source) ReadIQ(n int) ([]complex64, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 0000000..abf9c93 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,113 @@ +package runtime + +import ( + "errors" + "sync" + + "sdr-visual-suite/internal/config" +) + +type ConfigUpdate struct { + CenterHz *float64 `json:"center_hz"` + SampleRate *int `json:"sample_rate"` + FFTSize *int `json:"fft_size"` + GainDb *float64 `json:"gain_db"` + Detector *DetectorUpdate `json:"detector"` +} + +type DetectorUpdate struct { + ThresholdDb *float64 `json:"threshold_db"` + MinDuration *int `json:"min_duration_ms"` + HoldMs *int `json:"hold_ms"` +} + +type SettingsUpdate struct { + AGC *bool `json:"agc"` + DCBlock *bool `json:"dc_block"` + IQBalance *bool `json:"iq_balance"` +} + +type Manager struct { + mu sync.RWMutex + cfg config.Config +} + +func New(cfg config.Config) *Manager { + return &Manager{cfg: cfg} +} + +func (m *Manager) Snapshot() config.Config { + m.mu.RLock() + defer m.mu.RUnlock() + return m.cfg +} + +func (m *Manager) Replace(cfg config.Config) { + m.mu.Lock() + defer m.mu.Unlock() + m.cfg = cfg +} + +func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { + m.mu.Lock() + defer m.mu.Unlock() + + next := m.cfg + if update.CenterHz != nil { + next.CenterHz = *update.CenterHz + } + if update.SampleRate != nil { + if *update.SampleRate <= 0 { + return m.cfg, errors.New("sample_rate must be > 0") + } + next.SampleRate = *update.SampleRate + } + if update.FFTSize != nil { + if *update.FFTSize <= 0 { + return m.cfg, errors.New("fft_size must be > 0") + } + next.FFTSize = *update.FFTSize + } + if update.GainDb != nil { + next.GainDb = *update.GainDb + } + if update.Detector != nil { + if update.Detector.ThresholdDb != nil { + next.Detector.ThresholdDb = *update.Detector.ThresholdDb + } + if update.Detector.MinDuration != nil { + if *update.Detector.MinDuration <= 0 { + return m.cfg, errors.New("min_duration_ms must be > 0") + } + next.Detector.MinDurationMs = *update.Detector.MinDuration + } + if update.Detector.HoldMs != nil { + if *update.Detector.HoldMs <= 0 { + return m.cfg, errors.New("hold_ms must be > 0") + } + next.Detector.HoldMs = *update.Detector.HoldMs + } + } + + m.cfg = next + return m.cfg, nil +} + +func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) { + m.mu.Lock() + defer m.mu.Unlock() + + next := m.cfg + if update.AGC != nil { + next.AGC = *update.AGC + } + if update.DCBlock != nil { + next.DCBlock = *update.DCBlock + } + if update.IQBalance != nil { + next.IQBalance = *update.IQBalance + } + + m.cfg = next + return m.cfg, nil +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 0000000..4373b0d --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,73 @@ +package runtime + +import ( + "testing" + + "sdr-visual-suite/internal/config" +) + +func TestApplyConfigUpdate(t *testing.T) { + cfg := config.Default() + mgr := New(cfg) + + center := 7.2e6 + sampleRate := 1_024_000 + fftSize := 4096 + threshold := -35.0 + + updated, err := mgr.ApplyConfig(ConfigUpdate{ + CenterHz: ¢er, + SampleRate: &sampleRate, + FFTSize: &fftSize, + Detector: &DetectorUpdate{ + ThresholdDb: &threshold, + }, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if updated.CenterHz != center { + t.Fatalf("center hz: %v", updated.CenterHz) + } + if updated.SampleRate != sampleRate { + t.Fatalf("sample rate: %v", updated.SampleRate) + } + if updated.FFTSize != fftSize { + t.Fatalf("fft size: %v", updated.FFTSize) + } + if updated.Detector.ThresholdDb != threshold { + t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) + } +} + +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") + } + snap := mgr.Snapshot() + if snap.SampleRate != cfg.SampleRate { + t.Fatalf("sample rate changed on error") + } +} + +func TestApplySettings(t *testing.T) { + cfg := config.Default() + mgr := New(cfg) + agc := true + dc := true + iq := true + updated, err := mgr.ApplySettings(SettingsUpdate{ + AGC: &agc, + DCBlock: &dc, + IQBalance: &iq, + }) + if err != nil { + t.Fatalf("apply settings: %v", err) + } + if !updated.AGC || !updated.DCBlock || !updated.IQBalance { + t.Fatalf("settings not applied: %+v", updated) + } +} diff --git a/internal/sdr/source.go b/internal/sdr/source.go index 26c25a1..59d8a43 100644 --- a/internal/sdr/source.go +++ b/internal/sdr/source.go @@ -8,4 +8,8 @@ type Source interface { ReadIQ(n int) ([]complex64, error) } +type ConfigurableSource interface { + UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) 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 a8dad5b..bd3cde3 100644 --- a/internal/sdrplay/sdrplay.go +++ b/internal/sdrplay/sdrplay.go @@ -7,6 +7,7 @@ package sdrplay #cgo linux LDFLAGS: -lsdrplay_api #include "sdrplay_api.h" #include +#include extern void goStreamCallback(short *xi, short *xq, unsigned int numSamples, void *cbContext); @@ -20,6 +21,15 @@ static void EventCallback(sdrplay_api_EventT eventId, sdrplay_api_TunerSelectT t (void)eventId; (void)tuner; (void)params; (void)cbContext; } +static sdrplay_api_CallbackFnsT sdrplay_get_callbacks() { + sdrplay_api_CallbackFnsT cb; + memset(&cb, 0, sizeof(cb)); + cb.StreamACbFn = StreamACallback; + cb.StreamBCbFn = NULL; + cb.EventCbFn = EventCallback; + return cb; +} + static void sdrplay_set_fs(sdrplay_api_DeviceParamsT *p, double fsHz) { if (p && p->devParams) p->devParams->fsFreq.fsHz = fsHz; } @@ -39,6 +49,15 @@ static void sdrplay_set_if_zero(sdrplay_api_DeviceParamsT *p) { static void sdrplay_disable_agc(sdrplay_api_DeviceParamsT *p) { if (p && p->rxChannelA) p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_DISABLE; } + +static void sdrplay_set_agc(sdrplay_api_DeviceParamsT *p, int enable) { + if (!p || !p->rxChannelA) return; + if (enable) { + p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_100; + } else { + p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_DISABLE; + } +} */ import "C" @@ -53,17 +72,24 @@ import ( ) type Source struct { - mu sync.Mutex - dev C.sdrplay_api_DeviceT - params *C.sdrplay_api_DeviceParamsT - ch chan []complex64 - handle cgo.Handle - open bool + mu sync.Mutex + dev C.sdrplay_api_DeviceT + params *C.sdrplay_api_DeviceParamsT + ch chan []complex64 + handle cgo.Handle + open bool + sampleRate int + centerHz float64 + gainDb float64 + agc bool } func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { s := &Source{ - ch: make(chan []complex64, 16), + ch: make(chan []complex64, 16), + sampleRate: sampleRate, + centerHz: centerHz, + gainDb: gainDb, } s.handle = cgo.NewHandle(s) return s, s.configure(sampleRate, centerHz, gainDb) @@ -99,10 +125,7 @@ func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) err C.sdrplay_set_if_zero(s.params) C.sdrplay_disable_agc(s.params) - cb := C.sdrplay_api_CallbackFnsT{} - cb.StreamACbFn = (C.sdrplay_api_StreamCallback_t)(unsafe.Pointer(C.StreamACallback)) - cb.StreamBCbFn = nil - cb.EventCbFn = (C.sdrplay_api_EventCallback_t)(unsafe.Pointer(C.EventCallback)) + cb := C.sdrplay_get_callbacks() if err := cErr(C.sdrplay_api_Init(s.dev.dev, &cb, unsafe.Pointer(uintptr(s.handle)))); err != nil { return fmt.Errorf("sdrplay_api_Init: %w", err) @@ -112,6 +135,47 @@ 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 { + s.mu.Lock() + defer s.mu.Unlock() + if s.params == nil { + return errors.New("sdrplay not initialized") + } + + updateReasons := C.int(0) + if sampleRate > 0 && sampleRate != s.sampleRate { + C.sdrplay_set_fs(s.params, C.double(sampleRate)) + updateReasons |= C.int(C.sdrplay_api_Update_Dev_Fs) + s.sampleRate = sampleRate + } + if centerHz != 0 && centerHz != s.centerHz { + C.sdrplay_set_rf(s.params, C.double(centerHz)) + updateReasons |= C.int(C.sdrplay_api_Update_Tuner_Frf) + s.centerHz = centerHz + } + if gainDb != s.gainDb { + C.sdrplay_set_gain(s.params, C.uint(gainDb)) + updateReasons |= C.int(C.sdrplay_api_Update_Tuner_Gr) + s.gainDb = gainDb + } + if agc != s.agc { + if agc { + C.sdrplay_set_agc(s.params, 1) + } else { + C.sdrplay_set_agc(s.params, 0) + } + updateReasons |= C.int(C.sdrplay_api_Update_Ctrl_Agc) + s.agc = agc + } + if updateReasons == 0 { + return nil + } + if err := cErr(C.sdrplay_api_Update(s.dev.dev, C.sdrplay_api_Tuner_A, C.sdrplay_api_UpdateReasonT(updateReasons), C.sdrplay_api_Update_Ext1_None)); err != nil { + return err + } + return nil +} + func (s *Source) Stop() error { s.mu.Lock() defer s.mu.Unlock() diff --git a/web/app.js b/web/app.js index 4d73ddd..ec5d932 100644 --- a/web/app.js +++ b/web/app.js @@ -13,6 +13,18 @@ const detailEndEl = document.getElementById('detailEnd'); const detailSnrEl = document.getElementById('detailSnr'); const detailDurEl = document.getElementById('detailDur'); const detailSpectrogram = document.getElementById('detailSpectrogram'); +const configStatusEl = document.getElementById('configStatus'); +const centerInput = document.getElementById('centerInput'); +const spanInput = document.getElementById('spanInput'); +const fftSelect = document.getElementById('fftSelect'); +const gainRange = document.getElementById('gainRange'); +const gainInput = document.getElementById('gainInput'); +const thresholdRange = document.getElementById('thresholdRange'); +const thresholdInput = document.getElementById('thresholdInput'); +const agcToggle = document.getElementById('agcToggle'); +const dcToggle = document.getElementById('dcToggle'); +const iqToggle = document.getElementById('iqToggle'); +const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); let latest = null; let zoom = 1.0; @@ -22,6 +34,12 @@ let dragStartX = 0; let dragStartPan = 0; let timelineDirty = true; let detailDirty = false; +let currentConfig = null; +let isSyncingConfig = false; +let pendingConfigUpdate = null; +let pendingSettingsUpdate = null; +let configTimer = null; +let settingsTimer = null; const events = []; const eventsById = new Map(); @@ -51,6 +69,114 @@ function resize() { window.addEventListener('resize', resize); resize(); +function setConfigStatus(text) { + if (configStatusEl) { + configStatusEl.textContent = text; + } +} + +function toMHz(hz) { + return hz / 1e6; +} + +function fromMHz(mhz) { + return mhz * 1e6; +} + +function applyConfigToUI(cfg) { + if (!cfg) return; + isSyncingConfig = true; + centerInput.value = toMHz(cfg.center_hz).toFixed(6); + spanInput.value = toMHz(cfg.sample_rate).toFixed(3); + fftSelect.value = String(cfg.fft_size); + gainRange.value = cfg.gain_db; + gainInput.value = cfg.gain_db; + thresholdRange.value = cfg.detector.threshold_db; + thresholdInput.value = cfg.detector.threshold_db; + agcToggle.checked = !!cfg.agc; + dcToggle.checked = !!cfg.dc_block; + iqToggle.checked = !!cfg.iq_balance; + isSyncingConfig = false; +} + +async function loadConfig() { + try { + const res = await fetch('/api/config'); + if (!res.ok) { + setConfigStatus('Failed to load'); + return; + } + const data = await res.json(); + currentConfig = data; + applyConfigToUI(currentConfig); + setConfigStatus('Synced'); + } catch (err) { + setConfigStatus('Offline'); + } +} + +function queueConfigUpdate(partial) { + if (isSyncingConfig) return; + pendingConfigUpdate = { ...(pendingConfigUpdate || {}), ...partial }; + setConfigStatus('Updating...'); + if (configTimer) clearTimeout(configTimer); + configTimer = setTimeout(sendConfigUpdate, 200); +} + +function queueSettingsUpdate(partial) { + if (isSyncingConfig) return; + pendingSettingsUpdate = { ...(pendingSettingsUpdate || {}), ...partial }; + setConfigStatus('Updating...'); + if (settingsTimer) clearTimeout(settingsTimer); + settingsTimer = setTimeout(sendSettingsUpdate, 100); +} + +async function sendConfigUpdate() { + if (!pendingConfigUpdate) return; + const payload = pendingConfigUpdate; + pendingConfigUpdate = null; + try { + const res = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + setConfigStatus('Apply failed'); + return; + } + const data = await res.json(); + currentConfig = data; + applyConfigToUI(currentConfig); + setConfigStatus('Applied'); + } catch (err) { + setConfigStatus('Offline'); + } +} + +async function sendSettingsUpdate() { + if (!pendingSettingsUpdate) return; + const payload = pendingSettingsUpdate; + pendingSettingsUpdate = null; + try { + const res = await fetch('/api/sdr/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + setConfigStatus('Apply failed'); + return; + } + const data = await res.json(); + currentConfig = data; + applyConfigToUI(currentConfig); + setConfigStatus('Applied'); + } catch (err) { + setConfigStatus('Offline'); + } +} + function colorMap(v) { // v in [0..1] const r = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 0.6)))); @@ -330,6 +456,75 @@ window.addEventListener('mousemove', (ev) => { pan = Math.max(-0.5, Math.min(0.5, pan)); }); +centerInput.addEventListener('change', () => { + const mhz = parseFloat(centerInput.value); + if (Number.isFinite(mhz)) { + queueConfigUpdate({ center_hz: fromMHz(mhz) }); + } +}); + +spanInput.addEventListener('change', () => { + const mhz = parseFloat(spanInput.value); + if (Number.isFinite(mhz) && mhz > 0) { + queueConfigUpdate({ sample_rate: Math.round(fromMHz(mhz)) }); + } +}); + +fftSelect.addEventListener('change', () => { + const size = parseInt(fftSelect.value, 10); + if (Number.isFinite(size)) { + queueConfigUpdate({ fft_size: size }); + } +}); + +gainRange.addEventListener('input', () => { + gainInput.value = gainRange.value; + queueConfigUpdate({ gain_db: parseFloat(gainRange.value) }); +}); + +gainInput.addEventListener('change', () => { + const v = parseFloat(gainInput.value); + if (Number.isFinite(v)) { + gainRange.value = v; + queueConfigUpdate({ gain_db: v }); + } +}); + +thresholdRange.addEventListener('input', () => { + thresholdInput.value = thresholdRange.value; + queueConfigUpdate({ detector: { threshold_db: parseFloat(thresholdRange.value) } }); +}); + +thresholdInput.addEventListener('change', () => { + const v = parseFloat(thresholdInput.value); + if (Number.isFinite(v)) { + thresholdRange.value = v; + queueConfigUpdate({ detector: { threshold_db: v } }); + } +}); + +agcToggle.addEventListener('change', () => { + queueSettingsUpdate({ agc: agcToggle.checked }); +}); + +dcToggle.addEventListener('change', () => { + queueSettingsUpdate({ dc_block: dcToggle.checked }); +}); + +iqToggle.addEventListener('change', () => { + queueSettingsUpdate({ iq_balance: iqToggle.checked }); +}); + +for (const btn of presetButtons) { + btn.addEventListener('click', () => { + const mhz = parseFloat(btn.dataset.center); + if (Number.isFinite(mhz)) { + centerInput.value = mhz.toFixed(3); + queueConfigUpdate({ center_hz: fromMHz(mhz) }); + } + }); +} + function normalizeEvent(ev) { const startMs = new Date(ev.start).getTime(); const endMs = new Date(ev.end).getTime(); @@ -428,6 +623,7 @@ timelineCanvas.addEventListener('click', (ev) => { } }); +loadConfig(); connect(); requestAnimationFrame(tick); fetchEvents(true); diff --git a/web/index.html b/web/index.html index 37a4671..a26654e 100644 --- a/web/index.html +++ b/web/index.html @@ -12,10 +12,71 @@
-
+
+
+
Radio Controls
+
Loading...
+
+
+ +
+ +
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+
+
+
-
+
diff --git a/web/style.css b/web/style.css index a5100d3..69a704c 100644 --- a/web/style.css +++ b/web/style.css @@ -47,7 +47,7 @@ main { flex: 1; display: grid; grid-template-columns: 2fr 1fr; - grid-template-rows: 1fr 1.2fr; + grid-template-rows: auto 1fr; gap: 12px; padding: 12px; } @@ -67,12 +67,104 @@ canvas { background: #06090d; } +.controls-panel { + display: flex; + flex-direction: column; + gap: 12px; + grid-column: 2; + grid-row: 1; +} + +.spectrum-panel { + grid-column: 1; + grid-row: 1; +} + +.waterfall-panel { + grid-column: 1; + grid-row: 2; +} + +.timeline-panel { + grid-column: 2; + grid-row: 2; +} + +.controls-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + font-size: 0.9rem; +} + +.control-label { + color: var(--muted); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.08em; +} + +.control-row { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.control-row input[type="number"], +.control-row select { + width: 100%; + background: #0b111a; + border: 1px solid #20344b; + color: var(--text); + border-radius: 8px; + padding: 6px 8px; +} + +.control-row input[type="range"] { + width: 100%; +} + +.preset-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.preset-btn { + background: #142233; + color: var(--text); + border: 1px solid #1f3248; + border-radius: 8px; + padding: 4px 10px; + cursor: pointer; +} + +.preset-btn:hover { + border-color: #2b4b68; +} + +.toggle-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--text); +} + +.toggle input { + accent-color: var(--accent); +} + .timeline-panel { display: flex; flex-direction: column; gap: 8px; - grid-row: 1 / span 2; - grid-column: 2; } .timeline-panel canvas { @@ -180,9 +272,12 @@ canvas { @media (max-width: 820px) { main { grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr 1fr; + grid-template-rows: auto auto auto auto; } + .controls-panel, + .spectrum-panel, + .waterfall-panel, .timeline-panel { grid-row: auto; grid-column: auto;