Browse Source

Initial SDR visual suite

master
Jan Svabenik 4 days ago
commit
f94510c048
18 changed files with 1279 additions and 0 deletions
  1. +14
    -0
      .gitignore
  2. +62
    -0
      README.md
  3. +186
    -0
      cmd/sdrd/main.go
  4. +17
    -0
      config.yaml
  5. +11
    -0
      go.mod
  6. +12
    -0
      go.sum
  7. +102
    -0
      internal/config/config.go
  8. +35
    -0
      internal/config/config_test.go
  9. +222
    -0
      internal/detector/detector.go
  10. +40
    -0
      internal/detector/detector_test.go
  11. +55
    -0
      internal/fft/fft.go
  12. +48
    -0
      internal/mock/source.go
  13. +11
    -0
      internal/sdr/source.go
  14. +174
    -0
      internal/sdrplay/sdrplay.go
  15. +9
    -0
      internal/sdrplay/sdrplay_stub.go
  16. +177
    -0
      web/app.js
  17. +27
    -0
      web/index.html
  18. +77
    -0
      web/style.css

+ 14
- 0
.gitignore View File

@@ -0,0 +1,14 @@
# binaries
*.exe
*.dll
*.so
*.dylib

# build artifacts
/bin/

# runtime data
data/events.jsonl

# local prompts
prompt.txt

+ 62
- 0
README.md View File

@@ -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.

+ 186
- 0
cmd/sdrd/main.go View File

@@ -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,
})
}
}
}

+ 17
- 0
config.yaml View File

@@ -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"

+ 11
- 0
go.mod View File

@@ -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

+ 12
- 0
go.sum View File

@@ -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=

+ 102
- 0
internal/config/config.go View File

@@ -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)
}

+ 35
- 0
internal/config/config_test.go View File

@@ -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")
}
}

+ 222
- 0
internal/detector/detector.go View File

@@ -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]
}

+ 40
- 0
internal/detector/detector_test.go View File

@@ -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")
}
}

+ 55
- 0
internal/fft/fft.go View File

@@ -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))
}

+ 48
- 0
internal/mock/source.go View File

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

+ 11
- 0
internal/sdr/source.go View File

@@ -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")

+ 174
- 0
internal/sdrplay/sdrplay.go View File

@@ -0,0 +1,174 @@
//go:build sdrplay

package sdrplay

/*
#cgo windows LDFLAGS: -lsdrplay_api
#cgo linux LDFLAGS: -lsdrplay_api
#include "sdrplay_api.h"
#include <stdlib.h>

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, &params)); 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)))
}

+ 9
- 0
internal/sdrplay/sdrplay_stub.go View File

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

+ 177
- 0
web/app.js View File

@@ -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);

+ 27
- 0
web/index.html View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SDR Spectrum + Waterfall</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="title">SDRplay RSP1b Visualizer</div>
<div class="meta" id="meta"></div>
</header>
<main>
<section class="panel">
<canvas id="spectrum"></canvas>
</section>
<section class="panel">
<canvas id="waterfall"></canvas>
</section>
</main>
<footer>
<div id="status">Connecting...</div>
</footer>
<script src="app.js"></script>
</body>
</html>

+ 77
- 0
web/style.css View File

@@ -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;
}
}

Loading…
Cancel
Save