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