| @@ -0,0 +1,14 @@ | |||||
| # binaries | |||||
| *.exe | |||||
| *.dll | |||||
| *.so | |||||
| *.dylib | |||||
| # build artifacts | |||||
| /bin/ | |||||
| # runtime data | |||||
| data/events.jsonl | |||||
| # local prompts | |||||
| prompt.txt | |||||
| @@ -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. | |||||
| @@ -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, | |||||
| }) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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" | |||||
| @@ -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 | |||||
| @@ -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= | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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] | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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)) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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") | |||||
| @@ -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, ¶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))) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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); | |||||
| @@ -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> | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||