commit f94510c048aef1d3aed53a2292bb2b7ae687533a Author: Jan Svabenik Date: Tue Mar 17 09:52:36 2026 +0100 Initial SDR visual suite diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bcc412 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# binaries +*.exe +*.dll +*.so +*.dylib + +# build artifacts +/bin/ + +# runtime data +data/events.jsonl + +# local prompts +prompt.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4fc19f --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# SDR Visual Suite + +Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event recorder. + +## Features +- Live spectrum + waterfall web UI (WebSocket streaming) +- Basic detector with event JSONL output (`data/events.jsonl`) +- Windows + Linux support +- Mock mode for testing without hardware + +## Quick Start (Mock) +```bash +# From repo root + +go run ./cmd/sdrd --mock +``` +Open `http://localhost:8080`. + +## SDRplay Build/Run (Real Device) +This project uses the SDRplay API via cgo (`sdrplay_api.h`). Ensure the SDRplay API is installed. + +### Windows +```powershell +$env:CGO_CFLAGS='-IC:\Program Files\SDRplay\API\inc' +$env:CGO_LDFLAGS='-LC:\Program Files\SDRplay\API\x64 -lsdrplay_api' + +go build -tags sdrplay ./cmd/sdrd +.\sdrd.exe -config config.yaml +``` + +### Linux +```bash +export CGO_CFLAGS='-I/opt/sdrplay_api/include' +export CGO_LDFLAGS='-L/opt/sdrplay_api/lib -lsdrplay_api' + +go build -tags sdrplay ./cmd/sdrd +./cmd/sdrd/sdrd -config config.yaml +``` + +## Configuration +Edit `config.yaml`: +- `bands`: list of band ranges +- `center_hz`: center frequency +- `sample_rate`: sample rate +- `fft_size`: FFT size +- `gain_db`: device gain +- `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. + +## Tests +```bash + +go test ./... +``` + +## Troubleshooting +- If you see `sdrplay support not built`, rebuild with `-tags sdrplay`. +- If the SDRplay library is not found, ensure `CGO_CFLAGS` and `CGO_LDFLAGS` point to the API headers and library. +- Use `--mock` to run without hardware. diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go new file mode 100644 index 0000000..c128e8c --- /dev/null +++ b/cmd/sdrd/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/gorilla/websocket" + + "sdr-visual-suite/internal/config" + "sdr-visual-suite/internal/detector" + fftutil "sdr-visual-suite/internal/fft" + "sdr-visual-suite/internal/mock" + "sdr-visual-suite/internal/sdr" + "sdr-visual-suite/internal/sdrplay" +) + +type SpectrumFrame struct { + Timestamp int64 `json:"ts"` + CenterHz float64 `json:"center_hz"` + SampleHz int `json:"sample_rate"` + FFTSize int `json:"fft_size"` + Spectrum []float64 `json:"spectrum_db"` + Signals []detector.Signal `json:"signals"` +} + +type hub struct { + mu sync.Mutex + clients map[*websocket.Conn]struct{} +} + +func newHub() *hub { + return &hub{clients: map[*websocket.Conn]struct{}{}} +} + +func (h *hub) add(c *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + h.clients[c] = struct{}{} +} + +func (h *hub) remove(c *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.clients, c) +} + +func (h *hub) broadcast(frame SpectrumFrame) { + h.mu.Lock() + defer h.mu.Unlock() + b, _ := json.Marshal(frame) + for c := range h.clients { + _ = c.WriteMessage(websocket.TextMessage, b) + } +} + +func main() { + var cfgPath string + var mockFlag bool + flag.StringVar(&cfgPath, "config", "config.yaml", "path to config YAML") + flag.BoolVar(&mockFlag, "mock", false, "use synthetic IQ source") + flag.Parse() + + cfg, err := config.Load(cfgPath) + if err != nil { + 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) + if err != nil { + log.Fatalf("sdrplay init failed: %v (try --mock or build with -tags sdrplay)", err) + } + } + if err := src.Start(); err != nil { + log.Fatalf("source start: %v", err) + } + defer src.Stop() + + if err := os.MkdirAll(filepath.Dir(cfg.EventPath), 0o755); err != nil { + log.Fatalf("event path: %v", err) + } + + eventFile, err := os.OpenFile(cfg.EventPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.Fatalf("open events: %v", err) + } + defer eventFile.Close() + + det := detector.New(cfg.Detector.ThresholdDb, cfg.SampleRate, cfg.FFTSize, + time.Duration(cfg.Detector.MinDurationMs)*time.Millisecond, + time.Duration(cfg.Detector.HoldMs)*time.Millisecond) + + window := fftutil.Hann(cfg.FFTSize) + h := newHub() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go runDSP(ctx, src, cfg, det, window, h, eventFile) + + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + h.add(c) + defer func() { + h.remove(c) + _ = c.Close() + }() + for { + _, _, err := c.ReadMessage() + if err != nil { + return + } + } + }) + + http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(cfg) + }) + + http.Handle("/", http.FileServer(http.Dir(cfg.WebRoot))) + + server := &http.Server{Addr: cfg.WebAddr} + go func() { + log.Printf("web listening on %s", cfg.WebAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelTimeout() + _ = server.Shutdown(ctxTimeout) +} + +func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File) { + ticker := time.NewTicker(cfg.FrameInterval()) + defer ticker.Stop() + enc := json.NewEncoder(eventFile) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + iq, err := src.ReadIQ(cfg.FFTSize) + if err != nil { + log.Printf("read IQ: %v", err) + continue + } + spectrum := fftutil.Spectrum(iq, window) + now := time.Now() + finished, signals := det.Process(now, spectrum, cfg.CenterHz) + for _, ev := range finished { + _ = enc.Encode(ev) + } + h.broadcast(SpectrumFrame{ + Timestamp: now.UnixMilli(), + CenterHz: cfg.CenterHz, + SampleHz: cfg.SampleRate, + FFTSize: cfg.FFTSize, + Spectrum: spectrum, + Signals: signals, + }) + } + } +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..e78b914 --- /dev/null +++ b/config.yaml @@ -0,0 +1,17 @@ +bands: + - name: fm-test + start_hz: 99.5e6 + end_hz: 100.5e6 +center_hz: 100.0e6 +sample_rate: 2048000 +fft_size: 2048 +gain_db: 30 +detector: + threshold_db: -20 + min_duration_ms: 250 + hold_ms: 500 +web_addr: ":8080" +event_path: "data/events.jsonl" +frame_rate: 15 +waterfall_lines: 200 +web_root: "web" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..00c3e18 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module sdr-visual-suite + +go 1.22 + +require ( + github.com/gorilla/websocket v1.5.1 + gonum.org/v1/gonum v0.15.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require golang.org/x/net v0.17.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..88769af --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ= +gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a5d85e3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,102 @@ +package config + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +type Band struct { + Name string `yaml:"name"` + StartHz float64 `yaml:"start_hz"` + EndHz float64 `yaml:"end_hz"` +} + +type DetectorConfig struct { + ThresholdDb float64 `yaml:"threshold_db"` + MinDurationMs int `yaml:"min_duration_ms"` + HoldMs int `yaml:"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"` +} + +func Default() Config { + return Config{ + Bands: []Band{ + {Name: "example", StartHz: 99.5e6, EndHz: 100.5e6}, + }, + CenterHz: 100.0e6, + SampleRate: 2_048_000, + FFTSize: 2048, + GainDb: 30, + Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500}, + WebAddr: ":8080", + EventPath: "data/events.jsonl", + FrameRate: 15, + WaterfallLines: 200, + WebRoot: "web", + } +} + +func Load(path string) (Config, error) { + cfg := Default() + b, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + if err := yaml.Unmarshal(b, &cfg); err != nil { + return cfg, err + } + if cfg.Detector.MinDurationMs <= 0 { + cfg.Detector.MinDurationMs = 250 + } + if cfg.Detector.HoldMs <= 0 { + cfg.Detector.HoldMs = 500 + } + if cfg.FrameRate <= 0 { + cfg.FrameRate = 15 + } + if cfg.WaterfallLines <= 0 { + cfg.WaterfallLines = 200 + } + if cfg.WebRoot == "" { + cfg.WebRoot = "web" + } + if cfg.WebAddr == "" { + cfg.WebAddr = ":8080" + } + if cfg.EventPath == "" { + cfg.EventPath = "data/events.jsonl" + } + if cfg.SampleRate <= 0 { + cfg.SampleRate = 2_048_000 + } + if cfg.FFTSize <= 0 { + cfg.FFTSize = 2048 + } + if cfg.CenterHz == 0 { + cfg.CenterHz = 100.0e6 + } + return cfg, nil +} + +func (c Config) FrameInterval() time.Duration { + fps := c.FrameRate + if fps <= 0 { + fps = 15 + } + return time.Second / time.Duration(fps) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c01f486 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,35 @@ +package config + +import ( + "os" + "testing" +) + +func TestLoadConfig(t *testing.T) { + data := []byte("center_hz: 100.0e6\nfft_size: 1024\n") + f, err := os.CreateTemp(t.TempDir(), "cfg*.yaml") + if err != nil { + t.Fatalf("temp: %v", err) + } + if _, err := f.Write(data); err != nil { + t.Fatalf("write: %v", err) + } + _ = f.Close() + + cfg, err := Load(f.Name()) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.CenterHz != 100.0e6 { + t.Fatalf("center hz: %v", cfg.CenterHz) + } + if cfg.FFTSize != 1024 { + t.Fatalf("fft size: %v", cfg.FFTSize) + } + if cfg.FrameRate <= 0 { + t.Fatalf("frame rate default not applied") + } + if cfg.EventPath == "" { + t.Fatalf("event path default not applied") + } +} diff --git a/internal/detector/detector.go b/internal/detector/detector.go new file mode 100644 index 0000000..500725f --- /dev/null +++ b/internal/detector/detector.go @@ -0,0 +1,222 @@ +package detector + +import ( + "math" + "sort" + "time" +) + +type Event struct { + ID int64 `json:"id"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + CenterHz float64 `json:"center_hz"` + Bandwidth float64 `json:"bandwidth_hz"` + PeakDb float64 `json:"peak_db"` + SNRDb float64 `json:"snr_db"` + FirstBin int `json:"first_bin"` + LastBin int `json:"last_bin"` +} + +type Detector struct { + ThresholdDb float64 + MinDuration time.Duration + Hold time.Duration + + binWidth float64 + nbins int + + active map[int64]*activeEvent + nextID int64 +} + +type activeEvent struct { + id int64 + start time.Time + lastSeen time.Time + centerHz float64 + bwHz float64 + peakDb float64 + snrDb float64 + firstBin int + lastBin int +} + +type Signal struct { + FirstBin int `json:"first_bin"` + LastBin int `json:"last_bin"` + CenterHz float64 `json:"center_hz"` + BWHz float64 `json:"bw_hz"` + PeakDb float64 `json:"peak_db"` + SNRDb float64 `json:"snr_db"` +} + +func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration) *Detector { + if minDur <= 0 { + minDur = 250 * time.Millisecond + } + if hold <= 0 { + hold = 500 * time.Millisecond + } + return &Detector{ + ThresholdDb: thresholdDb, + MinDuration: minDur, + Hold: hold, + binWidth: float64(sampleRate) / float64(fftSize), + nbins: fftSize, + active: map[int64]*activeEvent{}, + nextID: 1, + } +} + +func (d *Detector) Process(now time.Time, spectrum []float64, centerHz float64) ([]Event, []Signal) { + signals := d.detectSignals(spectrum, centerHz) + finished := d.matchSignals(now, signals) + return finished, signals +} + +func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal { + n := len(spectrum) + if n == 0 { + return nil + } + threshold := d.ThresholdDb + noise := median(spectrum) + var signals []Signal + in := false + start := 0 + peak := -1e9 + peakBin := 0 + for i := 0; i < n; i++ { + v := spectrum[i] + if v >= threshold { + if !in { + in = true + start = i + peak = v + peakBin = i + } else if v > peak { + peak = v + peakBin = i + } + } else if in { + signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz)) + in = false + } + } + if in { + signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz)) + } + return signals +} + +func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64) Signal { + centerBin := float64(first+last) / 2.0 + centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth + bw := float64(last-first+1) * d.binWidth + snr := peak - noise + return Signal{ + FirstBin: first, + LastBin: last, + CenterHz: centerFreq, + BWHz: bw, + PeakDb: peak, + SNRDb: snr, + } +} + +func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { + used := make(map[int64]bool, len(d.active)) + for _, s := range signals { + var best *activeEvent + for _, ev := range d.active { + if overlapHz(s.CenterHz, s.BWHz, ev.centerHz, ev.bwHz) && math.Abs(s.CenterHz-ev.centerHz) < (s.BWHz+ev.bwHz)/2.0 { + best = ev + break + } + } + if best == nil { + id := d.nextID + d.nextID++ + d.active[id] = &activeEvent{ + id: id, + start: now, + lastSeen: now, + centerHz: s.CenterHz, + bwHz: s.BWHz, + peakDb: s.PeakDb, + snrDb: s.SNRDb, + firstBin: s.FirstBin, + lastBin: s.LastBin, + } + continue + } + used[best.id] = true + best.lastSeen = now + best.centerHz = (best.centerHz + s.CenterHz) / 2.0 + if s.BWHz > best.bwHz { + best.bwHz = s.BWHz + } + if s.PeakDb > best.peakDb { + best.peakDb = s.PeakDb + } + if s.SNRDb > best.snrDb { + best.snrDb = s.SNRDb + } + if s.FirstBin < best.firstBin { + best.firstBin = s.FirstBin + } + if s.LastBin > best.lastBin { + best.lastBin = s.LastBin + } + } + + var finished []Event + for id, ev := range d.active { + if used[id] { + continue + } + if now.Sub(ev.lastSeen) < d.Hold { + continue + } + duration := ev.lastSeen.Sub(ev.start) + if duration < d.MinDuration { + delete(d.active, id) + continue + } + finished = append(finished, Event{ + ID: ev.id, + Start: ev.start, + End: ev.lastSeen, + CenterHz: ev.centerHz, + Bandwidth: ev.bwHz, + PeakDb: ev.peakDb, + SNRDb: ev.snrDb, + FirstBin: ev.firstBin, + LastBin: ev.lastBin, + }) + delete(d.active, id) + } + return finished +} + +func overlapHz(c1, b1, c2, b2 float64) bool { + l1 := c1 - b1/2.0 + r1 := c1 + b1/2.0 + l2 := c2 - b2/2.0 + r2 := c2 + b2/2.0 + return l1 <= r2 && l2 <= r1 +} + +func median(vals []float64) float64 { + if len(vals) == 0 { + return 0 + } + cpy := append([]float64(nil), vals...) + sort.Float64s(cpy) + mid := len(cpy) / 2 + if len(cpy)%2 == 0 { + return (cpy[mid-1] + cpy[mid]) / 2.0 + } + return cpy[mid] +} diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go new file mode 100644 index 0000000..3e137d1 --- /dev/null +++ b/internal/detector/detector_test.go @@ -0,0 +1,40 @@ +package detector + +import ( + "testing" + "time" +) + +func TestDetectorCreatesEvent(t *testing.T) { + d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond) + center := 0.0 + spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} + now := time.Now() + finished, signals := d.Process(now, spectrum, center) + if len(finished) != 0 { + t.Fatalf("expected no finished events yet") + } + if len(signals) != 1 { + t.Fatalf("expected 1 signal, got %d", len(signals)) + } + if signals[0].BWHz <= 0 { + t.Fatalf("expected bandwidth > 0") + } + + // Extend signal duration. + _, _ = d.Process(now.Add(5*time.Millisecond), spectrum, center) + + // Advance beyond hold with no signal to finalize. + now2 := now.Add(30 * time.Millisecond) + noSignal := make([]float64, len(spectrum)) + for i := range noSignal { + noSignal[i] = -100 + } + finished, _ = d.Process(now2, noSignal, center) + if len(finished) != 1 { + t.Fatalf("expected 1 finished event, got %d", len(finished)) + } + if finished[0].Bandwidth <= 0 { + t.Fatalf("event bandwidth not set") + } +} diff --git a/internal/fft/fft.go b/internal/fft/fft.go new file mode 100644 index 0000000..50f2850 --- /dev/null +++ b/internal/fft/fft.go @@ -0,0 +1,55 @@ +package fftutil + +import ( + "math" + + "gonum.org/v1/gonum/dsp/fourier" +) + +func Hann(n int) []float64 { + w := make([]float64, n) + if n <= 1 { + if n == 1 { + w[0] = 1 + } + return w + } + for i := 0; i < n; i++ { + w[i] = 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1))) + } + return w +} + +func Spectrum(iq []complex64, window []float64) []float64 { + n := len(iq) + if n == 0 { + return nil + } + in := make([]complex128, n) + for i := 0; i < n; i++ { + v := iq[i] + w := 1.0 + if len(window) == n { + w = window[i] + } + in[i] = complex(float64(real(v))*w, float64(imag(v))*w) + } + fft := fourier.NewCmplxFFT(n) + out := make([]complex128, n) + fft.Coefficients(out, in) + + power := make([]float64, n) + eps := 1e-12 + invN := 1.0 / float64(n) + for i := 0; i < n; i++ { + idx := (i + n/2) % n + mag := cmplxAbs(out[idx]) * invN + p := 20 * math.Log10(mag+eps) + power[i] = p + } + return power +} + +func cmplxAbs(v complex128) float64 { + return math.Hypot(real(v), imag(v)) +} diff --git a/internal/mock/source.go b/internal/mock/source.go new file mode 100644 index 0000000..0ed0047 --- /dev/null +++ b/internal/mock/source.go @@ -0,0 +1,48 @@ +package mock + +import ( + "math" + "math/rand" + "sync" + "time" +) + +type Source struct { + mu sync.Mutex + phase float64 + phase2 float64 + phase3 float64 + sampleRate float64 + noise float64 +} + +func New(sampleRate int) *Source { + rand.Seed(time.Now().UnixNano()) + return &Source{ + sampleRate: float64(sampleRate), + noise: 0.02, + } +} + +func (s *Source) Start() error { return nil } +func (s *Source) Stop() error { return nil } + +func (s *Source) ReadIQ(n int) ([]complex64, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]complex64, n) + f1 := 50e3 + f2 := -120e3 + f3 := 300e3 + for i := 0; i < n; i++ { + s.phase += 2 * math.Pi * f1 / s.sampleRate + s.phase2 += 2 * math.Pi * f2 / s.sampleRate + s.phase3 += 2 * math.Pi * f3 / s.sampleRate + re := math.Cos(s.phase) + 0.7*math.Cos(s.phase2) + 0.4*math.Cos(s.phase3) + im := math.Sin(s.phase) + 0.7*math.Sin(s.phase2) + 0.4*math.Sin(s.phase3) + re += s.noise * rand.NormFloat64() + im += s.noise * rand.NormFloat64() + out[i] = complex(float32(re), float32(im)) + } + return out, nil +} diff --git a/internal/sdr/source.go b/internal/sdr/source.go new file mode 100644 index 0000000..26c25a1 --- /dev/null +++ b/internal/sdr/source.go @@ -0,0 +1,11 @@ +package sdr + +import "errors" + +type Source interface { + Start() error + Stop() error + ReadIQ(n int) ([]complex64, 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 new file mode 100644 index 0000000..a8dad5b --- /dev/null +++ b/internal/sdrplay/sdrplay.go @@ -0,0 +1,174 @@ +//go:build sdrplay + +package sdrplay + +/* +#cgo windows LDFLAGS: -lsdrplay_api +#cgo linux LDFLAGS: -lsdrplay_api +#include "sdrplay_api.h" +#include + +extern void goStreamCallback(short *xi, short *xq, unsigned int numSamples, void *cbContext); + +static void StreamACallback(short *xi, short *xq, sdrplay_api_StreamCbParamsT *params, unsigned int numSamples, unsigned int reset, void *cbContext) { + (void)params; + (void)reset; + goStreamCallback(xi, xq, numSamples, cbContext); +} + +static void EventCallback(sdrplay_api_EventT eventId, sdrplay_api_TunerSelectT tuner, sdrplay_api_EventParamsT *params, void *cbContext) { + (void)eventId; (void)tuner; (void)params; (void)cbContext; +} + +static void sdrplay_set_fs(sdrplay_api_DeviceParamsT *p, double fsHz) { + if (p && p->devParams) p->devParams->fsFreq.fsHz = fsHz; +} + +static void sdrplay_set_rf(sdrplay_api_DeviceParamsT *p, double rfHz) { + if (p && p->rxChannelA) p->rxChannelA->tunerParams.rfFreq.rfHz = rfHz; +} + +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_if_zero(sdrplay_api_DeviceParamsT *p) { + if (p && p->rxChannelA) p->rxChannelA->tunerParams.ifType = sdrplay_api_IF_Zero; +} + +static void sdrplay_disable_agc(sdrplay_api_DeviceParamsT *p) { + if (p && p->rxChannelA) p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_DISABLE; +} +*/ +import "C" + +import ( + "errors" + "fmt" + "runtime/cgo" + "sync" + "unsafe" + + "sdr-visual-suite/internal/sdr" +) + +type Source struct { + mu sync.Mutex + dev C.sdrplay_api_DeviceT + params *C.sdrplay_api_DeviceParamsT + ch chan []complex64 + handle cgo.Handle + open bool +} + +func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { + s := &Source{ + ch: make(chan []complex64, 16), + } + s.handle = cgo.NewHandle(s) + return s, s.configure(sampleRate, centerHz, gainDb) +} + +func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) error { + if err := cErr(C.sdrplay_api_Open()); err != nil { + return fmt.Errorf("sdrplay_api_Open: %w", err) + } + s.open = true + + var numDevs C.uint + var devices [8]C.sdrplay_api_DeviceT + if err := cErr(C.sdrplay_api_GetDevices(&devices[0], &numDevs, C.uint(len(devices)))); err != nil { + return fmt.Errorf("sdrplay_api_GetDevices: %w", err) + } + if numDevs == 0 { + return errors.New("no SDRplay devices found") + } + s.dev = devices[0] + if err := cErr(C.sdrplay_api_SelectDevice(&s.dev)); err != nil { + return fmt.Errorf("sdrplay_api_SelectDevice: %w", err) + } + + var params *C.sdrplay_api_DeviceParamsT + if err := cErr(C.sdrplay_api_GetDeviceParams(s.dev.dev, ¶ms)); err != nil { + return fmt.Errorf("sdrplay_api_GetDeviceParams: %w", err) + } + s.params = params + 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)) + 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)) + + 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) + } + return nil +} + +func (s *Source) Start() error { return nil } + +func (s *Source) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.params != nil { + _ = cErr(C.sdrplay_api_Uninit(s.dev.dev)) + s.params = nil + } + if s.open { + _ = cErr(C.sdrplay_api_ReleaseDevice(&s.dev)) + _ = cErr(C.sdrplay_api_Close()) + s.open = false + } + if s.handle != 0 { + s.handle.Delete() + s.handle = 0 + } + return nil +} + +func (s *Source) ReadIQ(n int) ([]complex64, error) { + buf := <-s.ch + if len(buf) >= n { + return buf[:n], nil + } + return buf, nil +} + +//export goStreamCallback +func goStreamCallback(xi *C.short, xq *C.short, numSamples C.uint, ctx unsafe.Pointer) { + h := cgo.Handle(uintptr(ctx)) + src, ok := h.Value().(*Source) + if !ok || src == nil { + return + } + n := int(numSamples) + if n <= 0 { + return + } + iq := make([]complex64, n) + xiSlice := unsafe.Slice((*int16)(unsafe.Pointer(xi)), n) + xqSlice := unsafe.Slice((*int16)(unsafe.Pointer(xq)), n) + const scale = 1.0 / 32768.0 + for i := 0; i < n; i++ { + re := float32(float64(xiSlice[i]) * scale) + im := float32(float64(xqSlice[i]) * scale) + iq[i] = complex(re, im) + } + select { + case src.ch <- iq: + default: + // Drop if consumer is slow. + } +} + +func cErr(err C.sdrplay_api_ErrT) error { + if err == C.sdrplay_api_Success { + return nil + } + return errors.New(C.GoString(C.sdrplay_api_GetErrorString(err))) +} diff --git a/internal/sdrplay/sdrplay_stub.go b/internal/sdrplay/sdrplay_stub.go new file mode 100644 index 0000000..7fbc83a --- /dev/null +++ b/internal/sdrplay/sdrplay_stub.go @@ -0,0 +1,9 @@ +//go:build !sdrplay + +package sdrplay + +import "sdr-visual-suite/internal/sdr" + +func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { + return nil, sdr.ErrNotImplemented +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..07d4865 --- /dev/null +++ b/web/app.js @@ -0,0 +1,177 @@ +const spectrumCanvas = document.getElementById('spectrum'); +const waterfallCanvas = document.getElementById('waterfall'); +const statusEl = document.getElementById('status'); +const metaEl = document.getElementById('meta'); + +let latest = null; +let zoom = 1.0; +let pan = 0.0; +let isDragging = false; +let dragStartX = 0; +let dragStartPan = 0; + +function resize() { + const dpr = window.devicePixelRatio || 1; + const rect1 = spectrumCanvas.getBoundingClientRect(); + spectrumCanvas.width = rect1.width * dpr; + spectrumCanvas.height = rect1.height * dpr; + const rect2 = waterfallCanvas.getBoundingClientRect(); + waterfallCanvas.width = rect2.width * dpr; + waterfallCanvas.height = rect2.height * dpr; +} + +window.addEventListener('resize', resize); +resize(); + +function colorMap(v) { + // v in [0..1] + const r = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 0.6)))); + const g = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 1.1)))); + const b = Math.min(255, Math.max(0, Math.floor(180 * Math.pow(1 - v, 1.2)))); + return [r, g, b]; +} + +function renderSpectrum() { + if (!latest) return; + const ctx = spectrumCanvas.getContext('2d'); + const w = spectrumCanvas.width; + const h = spectrumCanvas.height; + ctx.clearRect(0, 0, w, h); + + // Grid + ctx.strokeStyle = '#13263b'; + ctx.lineWidth = 1; + for (let i = 1; i < 10; i++) { + const y = (h / 10) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + const { spectrum_db, sample_rate, center_hz } = latest; + const n = spectrum_db.length; + const span = sample_rate / zoom; + const startHz = center_hz - span / 2 + pan * span; + const endHz = center_hz + span / 2 + pan * span; + + const minDb = -120; + const maxDb = 0; + + ctx.strokeStyle = '#48d1b8'; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const freq = center_hz + (i - n / 2) * (sample_rate / n); + if (freq < startHz || freq > endHz) continue; + const x = ((freq - startHz) / (endHz - startHz)) * w; + const v = spectrum_db[i]; + const y = h - ((v - minDb) / (maxDb - minDb)) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Signals overlay + ctx.strokeStyle = '#ffb454'; + ctx.lineWidth = 2; + if (latest.signals) { + for (const s of latest.signals) { + const left = s.center_hz - s.bw_hz / 2; + const right = s.center_hz + s.bw_hz / 2; + if (right < startHz || left > endHz) continue; + const x1 = ((left - startHz) / (endHz - startHz)) * w; + const x2 = ((right - startHz) / (endHz - startHz)) * w; + ctx.beginPath(); + ctx.moveTo(x1, h - 4); + ctx.lineTo(x2, h - 4); + ctx.stroke(); + } + } + + metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz`; +} + +function renderWaterfall() { + if (!latest) return; + const ctx = waterfallCanvas.getContext('2d'); + const w = waterfallCanvas.width; + const h = waterfallCanvas.height; + + // Scroll down + const image = ctx.getImageData(0, 0, w, h); + ctx.putImageData(image, 0, 1); + + const { spectrum_db, sample_rate, center_hz } = latest; + const n = spectrum_db.length; + const span = sample_rate / zoom; + const startHz = center_hz - span / 2 + pan * span; + const endHz = center_hz + span / 2 + pan * span; + const minDb = -120; + const maxDb = 0; + + const row = ctx.createImageData(w, 1); + for (let x = 0; x < w; x++) { + const freq = startHz + (x / (w - 1)) * (endHz - startHz); + const bin = Math.floor((freq - (center_hz - sample_rate / 2)) / (sample_rate / n)); + if (bin >= 0 && bin < n) { + const v = spectrum_db[bin]; + const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); + const [r, g, b] = colorMap(norm); + row.data[x * 4 + 0] = r; + row.data[x * 4 + 1] = g; + row.data[x * 4 + 2] = b; + row.data[x * 4 + 3] = 255; + } else { + row.data[x * 4 + 3] = 255; + } + } + ctx.putImageData(row, 0, 0); +} + +function tick() { + renderSpectrum(); + renderWaterfall(); + requestAnimationFrame(tick); +} + +function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${location.host}/ws`); + ws.onopen = () => { + statusEl.textContent = 'Connected'; + }; + ws.onmessage = (ev) => { + latest = JSON.parse(ev.data); + }; + ws.onclose = () => { + statusEl.textContent = 'Disconnected - retrying...'; + setTimeout(connect, 1000); + }; + ws.onerror = () => { + ws.close(); + }; +} + +spectrumCanvas.addEventListener('wheel', (ev) => { + ev.preventDefault(); + const delta = Math.sign(ev.deltaY); + zoom = Math.max(0.5, Math.min(10, zoom * (delta > 0 ? 1.1 : 0.9))); +}); + +spectrumCanvas.addEventListener('mousedown', (ev) => { + isDragging = true; + dragStartX = ev.clientX; + dragStartPan = pan; +}); + +window.addEventListener('mouseup', () => { isDragging = false; }); +window.addEventListener('mousemove', (ev) => { + if (!isDragging) return; + const dx = ev.clientX - dragStartX; + pan = dragStartPan - dx / spectrumCanvas.clientWidth; + pan = Math.max(-0.5, Math.min(0.5, pan)); +}); + +connect(); +requestAnimationFrame(tick); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..73049a3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,27 @@ + + + + + + SDR Spectrum + Waterfall + + + +
+
SDRplay RSP1b Visualizer
+
+
+
+
+ +
+
+ +
+
+
+
Connecting...
+
+ + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..88b4c49 --- /dev/null +++ b/web/style.css @@ -0,0 +1,77 @@ +:root { + --bg: #0b0f14; + --panel: #111821; + --grid: #1c2a39; + --text: #d6e3f0; + --accent: #48d1b8; + --muted: #7f93aa; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + background: radial-gradient(circle at 20% 20%, #14202e, #0b0f14 60%); + color: var(--text); + height: 100vh; + display: flex; + flex-direction: column; +} + +header, footer { + padding: 12px 20px; + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(90deg, #0f1824, #0b0f14); + border-bottom: 1px solid #0f2233; +} + +footer { + border-top: 1px solid #0f2233; + border-bottom: none; +} + +.title { + font-weight: 600; + letter-spacing: 0.02em; +} + +.meta { + font-size: 0.9rem; + color: var(--muted); +} + +main { + flex: 1; + display: grid; + grid-template-rows: 1fr 1.2fr; + gap: 12px; + padding: 12px; +} + +.panel { + background: var(--panel); + border: 1px solid #13263b; + border-radius: 10px; + padding: 8px; + position: relative; +} + +canvas { + width: 100%; + height: 100%; + border-radius: 6px; + background: #06090d; +} + +#status { + color: var(--muted); +} + +@media (max-width: 820px) { + main { + grid-template-rows: 1fr 1fr; + } +}