| @@ -8,6 +8,7 @@ Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event | |||||
| - In-browser spectrogram slice for selected events | - In-browser spectrogram slice for selected events | ||||
| - Basic detector with event JSONL output (`data/events.jsonl`) | - Basic detector with event JSONL output (`data/events.jsonl`) | ||||
| - Events API (`/api/events?limit=...&since=...`) | - 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) | - Recorded clips list placeholder (metadata only for now) | ||||
| - Windows + Linux support | - Windows + Linux support | ||||
| - Mock mode for testing without hardware | - Mock mode for testing without hardware | ||||
| @@ -48,16 +49,27 @@ Edit `config.yaml`: | |||||
| - `sample_rate`: sample rate | - `sample_rate`: sample rate | ||||
| - `fft_size`: FFT size | - `fft_size`: FFT size | ||||
| - `gain_db`: device gain | - `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.threshold_db`: power threshold in dB | ||||
| - `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge | - `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge | ||||
| ## Web UI | ## Web UI | ||||
| The UI is served from `web/` and connects to `/ws` for spectrum frames. | 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 | ### Event Timeline | ||||
| - The timeline panel displays recent events (time vs frequency). | - 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. | - 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 | ### Events API | ||||
| `/api/events` reads from the JSONL event log and returns the most recent events: | `/api/events` reads from the JSONL event log and returns the most recent events: | ||||
| - `limit` (optional): max number of events (default 200, max 2000) | - `limit` (optional): max number of events (default 200, max 2000) | ||||
| @@ -18,9 +18,11 @@ import ( | |||||
| "sdr-visual-suite/internal/config" | "sdr-visual-suite/internal/config" | ||||
| "sdr-visual-suite/internal/detector" | "sdr-visual-suite/internal/detector" | ||||
| "sdr-visual-suite/internal/dsp" | |||||
| "sdr-visual-suite/internal/events" | "sdr-visual-suite/internal/events" | ||||
| fftutil "sdr-visual-suite/internal/fft" | fftutil "sdr-visual-suite/internal/fft" | ||||
| "sdr-visual-suite/internal/mock" | "sdr-visual-suite/internal/mock" | ||||
| "sdr-visual-suite/internal/runtime" | |||||
| "sdr-visual-suite/internal/sdr" | "sdr-visual-suite/internal/sdr" | ||||
| "sdr-visual-suite/internal/sdrplay" | "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() { | func main() { | ||||
| var cfgPath string | var cfgPath string | ||||
| var mockFlag bool | var mockFlag bool | ||||
| @@ -76,19 +152,35 @@ func main() { | |||||
| log.Fatalf("load config: %v", err) | 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 { | 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) | log.Fatalf("source start: %v", err) | ||||
| } | } | ||||
| defer src.Stop() | |||||
| defer srcMgr.Stop() | |||||
| if err := os.MkdirAll(filepath.Dir(cfg.EventPath), 0o755); err != nil { | if err := os.MkdirAll(filepath.Dir(cfg.EventPath), 0o755); err != nil { | ||||
| log.Fatalf("event path: %v", err) | log.Fatalf("event path: %v", err) | ||||
| @@ -106,11 +198,12 @@ func main() { | |||||
| window := fftutil.Hann(cfg.FFTSize) | window := fftutil.Hann(cfg.FFTSize) | ||||
| h := newHub() | h := newHub() | ||||
| dspUpdates := make(chan dspUpdate, 1) | |||||
| ctx, cancel := context.WithCancel(context.Background()) | ctx, cancel := context.WithCancel(context.Background()) | ||||
| defer cancel() | 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 }} | upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} | ||||
| http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { | 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) { | http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { | ||||
| w.Header().Set("Content-Type", "application/json") | 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) { | http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { | ||||
| @@ -179,21 +355,42 @@ func main() { | |||||
| _ = server.Shutdown(ctxTimeout) | _ = 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()) | ticker := time.NewTicker(cfg.FrameInterval()) | ||||
| defer ticker.Stop() | defer ticker.Stop() | ||||
| enc := json.NewEncoder(eventFile) | enc := json.NewEncoder(eventFile) | ||||
| dcBlocker := dsp.NewDCBlocker(0.995) | |||||
| dcEnabled := cfg.DCBlock | |||||
| iqEnabled := cfg.IQBalance | |||||
| for { | for { | ||||
| select { | select { | ||||
| case <-ctx.Done(): | case <-ctx.Done(): | ||||
| return | 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: | case <-ticker.C: | ||||
| iq, err := src.ReadIQ(cfg.FFTSize) | iq, err := src.ReadIQ(cfg.FFTSize) | ||||
| if err != nil { | if err != nil { | ||||
| log.Printf("read IQ: %v", err) | log.Printf("read IQ: %v", err) | ||||
| continue | continue | ||||
| } | } | ||||
| if dcEnabled { | |||||
| dcBlocker.Apply(iq) | |||||
| } | |||||
| if iqEnabled { | |||||
| dsp.IQBalance(iq) | |||||
| } | |||||
| spectrum := fftutil.Spectrum(iq, window) | spectrum := fftutil.Spectrum(iq, window) | ||||
| now := time.Now() | now := time.Now() | ||||
| finished, signals := det.Process(now, spectrum, cfg.CenterHz) | finished, signals := det.Process(now, spectrum, cfg.CenterHz) | ||||
| @@ -1,11 +1,14 @@ | |||||
| bands: | 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 | sample_rate: 2048000 | ||||
| fft_size: 2048 | fft_size: 2048 | ||||
| gain_db: 30 | gain_db: 30 | ||||
| agc: false | |||||
| dc_block: false | |||||
| iq_balance: false | |||||
| detector: | detector: | ||||
| threshold_db: -20 | threshold_db: -20 | ||||
| min_duration_ms: 250 | min_duration_ms: 250 | ||||
| @@ -8,29 +8,32 @@ import ( | |||||
| ) | ) | ||||
| type Band struct { | 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 { | 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 { | 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 { | func Default() Config { | ||||
| @@ -42,6 +45,9 @@ func Default() Config { | |||||
| SampleRate: 2_048_000, | SampleRate: 2_048_000, | ||||
| FFTSize: 2048, | FFTSize: 2048, | ||||
| GainDb: 30, | GainDb: 30, | ||||
| AGC: false, | |||||
| DCBlock: false, | |||||
| IQBalance: false, | |||||
| Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500}, | Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500}, | ||||
| WebAddr: ":8080", | WebAddr: ":8080", | ||||
| EventPath: "data/events.jsonl", | 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) Start() error { return nil } | ||||
| func (s *Source) Stop() 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) { | func (s *Source) ReadIQ(n int) ([]complex64, error) { | ||||
| s.mu.Lock() | s.mu.Lock() | ||||
| defer s.mu.Unlock() | 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) | 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") | 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 | #cgo linux LDFLAGS: -lsdrplay_api | ||||
| #include "sdrplay_api.h" | #include "sdrplay_api.h" | ||||
| #include <stdlib.h> | #include <stdlib.h> | ||||
| #include <string.h> | |||||
| extern void goStreamCallback(short *xi, short *xq, unsigned int numSamples, void *cbContext); | 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; | (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) { | static void sdrplay_set_fs(sdrplay_api_DeviceParamsT *p, double fsHz) { | ||||
| if (p && p->devParams) p->devParams->fsFreq.fsHz = 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) { | static void sdrplay_disable_agc(sdrplay_api_DeviceParamsT *p) { | ||||
| if (p && p->rxChannelA) p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_DISABLE; | 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" | import "C" | ||||
| @@ -53,17 +72,24 @@ import ( | |||||
| ) | ) | ||||
| type Source struct { | 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) { | func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { | ||||
| s := &Source{ | s := &Source{ | ||||
| ch: make(chan []complex64, 16), | |||||
| ch: make(chan []complex64, 16), | |||||
| sampleRate: sampleRate, | |||||
| centerHz: centerHz, | |||||
| gainDb: gainDb, | |||||
| } | } | ||||
| s.handle = cgo.NewHandle(s) | s.handle = cgo.NewHandle(s) | ||||
| return s, s.configure(sampleRate, centerHz, gainDb) | 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_set_if_zero(s.params) | ||||
| C.sdrplay_disable_agc(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 { | 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 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) 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 { | func (s *Source) Stop() error { | ||||
| s.mu.Lock() | s.mu.Lock() | ||||
| defer s.mu.Unlock() | defer s.mu.Unlock() | ||||
| @@ -13,6 +13,18 @@ const detailEndEl = document.getElementById('detailEnd'); | |||||
| const detailSnrEl = document.getElementById('detailSnr'); | const detailSnrEl = document.getElementById('detailSnr'); | ||||
| const detailDurEl = document.getElementById('detailDur'); | const detailDurEl = document.getElementById('detailDur'); | ||||
| const detailSpectrogram = document.getElementById('detailSpectrogram'); | 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 latest = null; | ||||
| let zoom = 1.0; | let zoom = 1.0; | ||||
| @@ -22,6 +34,12 @@ let dragStartX = 0; | |||||
| let dragStartPan = 0; | let dragStartPan = 0; | ||||
| let timelineDirty = true; | let timelineDirty = true; | ||||
| let detailDirty = false; | let detailDirty = false; | ||||
| let currentConfig = null; | |||||
| let isSyncingConfig = false; | |||||
| let pendingConfigUpdate = null; | |||||
| let pendingSettingsUpdate = null; | |||||
| let configTimer = null; | |||||
| let settingsTimer = null; | |||||
| const events = []; | const events = []; | ||||
| const eventsById = new Map(); | const eventsById = new Map(); | ||||
| @@ -51,6 +69,114 @@ function resize() { | |||||
| window.addEventListener('resize', resize); | window.addEventListener('resize', 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) { | function colorMap(v) { | ||||
| // v in [0..1] | // v in [0..1] | ||||
| const r = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 0.6)))); | 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)); | 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) { | function normalizeEvent(ev) { | ||||
| const startMs = new Date(ev.start).getTime(); | const startMs = new Date(ev.start).getTime(); | ||||
| const endMs = new Date(ev.end).getTime(); | const endMs = new Date(ev.end).getTime(); | ||||
| @@ -428,6 +623,7 @@ timelineCanvas.addEventListener('click', (ev) => { | |||||
| } | } | ||||
| }); | }); | ||||
| loadConfig(); | |||||
| connect(); | connect(); | ||||
| requestAnimationFrame(tick); | requestAnimationFrame(tick); | ||||
| fetchEvents(true); | fetchEvents(true); | ||||
| @@ -12,10 +12,71 @@ | |||||
| <div class="meta" id="meta"></div> | <div class="meta" id="meta"></div> | ||||
| </header> | </header> | ||||
| <main> | <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> | <canvas id="spectrum"></canvas> | ||||
| </section> | </section> | ||||
| <section class="panel"> | |||||
| <section class="panel waterfall-panel"> | |||||
| <canvas id="waterfall"></canvas> | <canvas id="waterfall"></canvas> | ||||
| </section> | </section> | ||||
| <section class="panel timeline-panel"> | <section class="panel timeline-panel"> | ||||
| @@ -47,7 +47,7 @@ main { | |||||
| flex: 1; | flex: 1; | ||||
| display: grid; | display: grid; | ||||
| grid-template-columns: 2fr 1fr; | grid-template-columns: 2fr 1fr; | ||||
| grid-template-rows: 1fr 1.2fr; | |||||
| grid-template-rows: auto 1fr; | |||||
| gap: 12px; | gap: 12px; | ||||
| padding: 12px; | padding: 12px; | ||||
| } | } | ||||
| @@ -67,12 +67,104 @@ canvas { | |||||
| background: #06090d; | 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 { | .timeline-panel { | ||||
| display: flex; | display: flex; | ||||
| flex-direction: column; | flex-direction: column; | ||||
| gap: 8px; | gap: 8px; | ||||
| grid-row: 1 / span 2; | |||||
| grid-column: 2; | |||||
| } | } | ||||
| .timeline-panel canvas { | .timeline-panel canvas { | ||||
| @@ -180,9 +272,12 @@ canvas { | |||||
| @media (max-width: 820px) { | @media (max-width: 820px) { | ||||
| main { | main { | ||||
| grid-template-columns: 1fr; | 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 { | .timeline-panel { | ||||
| grid-row: auto; | grid-row: auto; | ||||
| grid-column: auto; | grid-column: auto; | ||||