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