diff --git a/cmd/sdrd/hub.go b/cmd/sdrd/hub.go new file mode 100644 index 0000000..382b398 --- /dev/null +++ b/cmd/sdrd/hub.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "log" + "time" + + "sdr-visual-suite/internal/detector" +) + +func (s *signalSnapshot) set(sig []detector.Signal) { + s.mu.Lock() + defer s.mu.Unlock() + s.signals = append([]detector.Signal(nil), sig...) +} + +func (s *signalSnapshot) get() []detector.Signal { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]detector.Signal(nil), s.signals...) +} + +func (g *gpuStatus) set(active bool, err error) { + g.mu.Lock() + defer g.mu.Unlock() + g.Active = active + if err != nil { + g.Error = err.Error() + } else { + g.Error = "" + } +} + +func (g *gpuStatus) snapshot() gpuStatus { + g.mu.RLock() + defer g.mu.RUnlock() + return gpuStatus{Available: g.Available, Active: g.Active, Error: g.Error} +} + +func newHub() *hub { + return &hub{clients: map[*client]struct{}{}, lastLogTs: time.Now()} +} + +func (h *hub) add(c *client) { + h.mu.Lock() + defer h.mu.Unlock() + h.clients[c] = struct{}{} + log.Printf("ws connected (%d clients)", len(h.clients)) +} + +func (h *hub) remove(c *client) { + c.closeOnce.Do(func() { close(c.done) }) + h.mu.Lock() + defer h.mu.Unlock() + delete(h.clients, c) + log.Printf("ws disconnected (%d clients)", len(h.clients)) +} + +func (h *hub) broadcast(frame SpectrumFrame) { + b, err := json.Marshal(frame) + if err != nil { + log.Printf("marshal frame: %v", err) + return + } + + h.mu.Lock() + clients := make([]*client, 0, len(h.clients)) + for c := range h.clients { + clients = append(clients, c) + } + h.mu.Unlock() + + for _, c := range clients { + select { + case c.send <- b: + default: + h.remove(c) + } + } + h.frameCnt++ + if time.Since(h.lastLogTs) > 2*time.Second { + h.lastLogTs = time.Now() + log.Printf("broadcast frames=%d clients=%d", h.frameCnt, len(clients)) + } +} diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index fd72ef1..f9cf8fa 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -34,237 +34,6 @@ import ( "sdr-visual-suite/internal/sdrplay" ) -type SpectrumDebug struct { - Thresholds []float64 `json:"thresholds,omitempty"` - NoiseFloor float64 `json:"noise_floor,omitempty"` - Scores []map[string]any `json:"scores,omitempty"` -} - -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"` - Debug *SpectrumDebug `json:"debug,omitempty"` -} - -type client struct { - conn *websocket.Conn - send chan []byte - done chan struct{} - closeOnce sync.Once -} - -type hub struct { - mu sync.Mutex - clients map[*client]struct{} - frameCnt int64 - lastLogTs time.Time -} - -type gpuStatus struct { - mu sync.RWMutex - Available bool `json:"available"` - Active bool `json:"active"` - Error string `json:"error"` -} - -type signalSnapshot struct { - mu sync.RWMutex - signals []detector.Signal -} - -func (s *signalSnapshot) set(sig []detector.Signal) { - s.mu.Lock() - defer s.mu.Unlock() - s.signals = append([]detector.Signal(nil), sig...) -} - -func (s *signalSnapshot) get() []detector.Signal { - s.mu.RLock() - defer s.mu.RUnlock() - return append([]detector.Signal(nil), s.signals...) -} - -func (g *gpuStatus) set(active bool, err error) { - g.mu.Lock() - defer g.mu.Unlock() - g.Active = active - if err != nil { - g.Error = err.Error() - } else { - g.Error = "" - } -} - -func (g *gpuStatus) snapshot() gpuStatus { - g.mu.RLock() - defer g.mu.RUnlock() - return gpuStatus{Available: g.Available, Active: g.Active, Error: g.Error} -} - -func newHub() *hub { - return &hub{clients: map[*client]struct{}{}, lastLogTs: time.Now()} -} - -func (h *hub) add(c *client) { - h.mu.Lock() - defer h.mu.Unlock() - h.clients[c] = struct{}{} - log.Printf("ws connected (%d clients)", len(h.clients)) -} - -func (h *hub) remove(c *client) { - c.closeOnce.Do(func() { close(c.done) }) - h.mu.Lock() - defer h.mu.Unlock() - delete(h.clients, c) - log.Printf("ws disconnected (%d clients)", len(h.clients)) -} - -func (h *hub) broadcast(frame SpectrumFrame) { - b, err := json.Marshal(frame) - if err != nil { - log.Printf("marshal frame: %v", err) - return - } - - h.mu.Lock() - clients := make([]*client, 0, len(h.clients)) - for c := range h.clients { - clients = append(clients, c) - } - h.mu.Unlock() - - for _, c := range clients { - select { - case c.send <- b: - default: - h.remove(c) - } - } - h.frameCnt++ - if time.Since(h.lastLogTs) > 2*time.Second { - h.lastLogTs = time.Now() - log.Printf("broadcast frames=%d clients=%d", h.frameCnt, len(clients)) - } -} - -type sourceManager struct { - mu sync.RWMutex - src sdr.Source - newSource func(cfg config.Config) (sdr.Source, error) -} - -func (m *sourceManager) Restart(cfg config.Config) error { - m.mu.Lock() - defer m.mu.Unlock() - old := m.src - _ = old.Stop() - next, err := m.newSource(cfg) - if err != nil { - _ = old.Start() - m.src = old - return err - } - if err := next.Start(); err != nil { - _ = next.Stop() - _ = old.Start() - m.src = old - return err - } - m.src = next - return nil -} - -func (m *sourceManager) Stats() sdr.SourceStats { - m.mu.RLock() - defer m.mu.RUnlock() - if sp, ok := m.src.(sdr.StatsProvider); ok { - return sp.Stats() - } - return sdr.SourceStats{} -} - -func (m *sourceManager) Flush() { - m.mu.RLock() - defer m.mu.RUnlock() - if fl, ok := m.src.(sdr.Flushable); ok { - fl.Flush() - } -} - -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, cfg.TunerBwKHz); 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 - useGPUFFT 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 @@ -966,3 +735,4 @@ func parseSince(raw string) (time.Time, error) { } return time.Parse(time.RFC3339, raw) } + diff --git a/cmd/sdrd/source_manager.go b/cmd/sdrd/source_manager.go new file mode 100644 index 0000000..c9a664c --- /dev/null +++ b/cmd/sdrd/source_manager.go @@ -0,0 +1,104 @@ +package main + +import ( + "sdr-visual-suite/internal/config" + "sdr-visual-suite/internal/sdr" +) + +func (m *sourceManager) Restart(cfg config.Config) error { + m.mu.Lock() + defer m.mu.Unlock() + old := m.src + _ = old.Stop() + next, err := m.newSource(cfg) + if err != nil { + _ = old.Start() + m.src = old + return err + } + if err := next.Start(); err != nil { + _ = next.Stop() + _ = old.Start() + m.src = old + return err + } + m.src = next + return nil +} + +func (m *sourceManager) Stats() sdr.SourceStats { + m.mu.RLock() + defer m.mu.RUnlock() + if sp, ok := m.src.(sdr.StatsProvider); ok { + return sp.Stats() + } + return sdr.SourceStats{} +} + +func (m *sourceManager) Flush() { + m.mu.RLock() + defer m.mu.RUnlock() + if fl, ok := m.src.(sdr.Flushable); ok { + fl.Flush() + } +} + +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, cfg.TunerBwKHz); 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 +} + +func pushDSPUpdate(ch chan dspUpdate, update dspUpdate) { + select { + case ch <- update: + default: + select { + case <-ch: + default: + } + ch <- update + } +} diff --git a/cmd/sdrd/types.go b/cmd/sdrd/types.go new file mode 100644 index 0000000..1c21d88 --- /dev/null +++ b/cmd/sdrd/types.go @@ -0,0 +1,69 @@ +package main + +import ( + "sync" + "time" + + "github.com/gorilla/websocket" + + "sdr-visual-suite/internal/config" + "sdr-visual-suite/internal/detector" + "sdr-visual-suite/internal/sdr" +) + +type SpectrumDebug struct { + Thresholds []float64 `json:"thresholds,omitempty"` + NoiseFloor float64 `json:"noise_floor,omitempty"` + Scores []map[string]any `json:"scores,omitempty"` +} + +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"` + Debug *SpectrumDebug `json:"debug,omitempty"` +} + +type client struct { + conn *websocket.Conn + send chan []byte + done chan struct{} + closeOnce sync.Once +} + +type hub struct { + mu sync.Mutex + clients map[*client]struct{} + frameCnt int64 + lastLogTs time.Time +} + +type gpuStatus struct { + mu sync.RWMutex + Available bool `json:"available"` + Active bool `json:"active"` + Error string `json:"error"` +} + +type signalSnapshot struct { + mu sync.RWMutex + signals []detector.Signal +} + +type sourceManager struct { + mu sync.RWMutex + src sdr.Source + newSource func(cfg config.Config) (sdr.Source, error) +} + +type dspUpdate struct { + cfg config.Config + det *detector.Detector + window []float64 + dcBlock bool + iqBalance bool + useGPUFFT bool +} diff --git a/web/app.js b/web/app.js index 9acd865..a676bad 100644 --- a/web/app.js +++ b/web/app.js @@ -151,7 +151,8 @@ let timelineRects = []; let liveSignalRects = []; let recordings = []; let recordingsFetchInFlight = false; -let showDebugOverlay = true; +let showDebugOverlay = localStorage.getItem('spectre.debugOverlay') !== '0'; +let hoveredSignal = null; let showDebugOverlay = true; const GAIN_MAX = 60; @@ -184,16 +185,17 @@ function fmtMs(ms) { return `${(ms / 1000).toFixed(2)} s`; } -function renderScoreBars(scores) { - if (!classifierScoreBarsEl) return; - if (!scores || typeof scores !== 'object') { - classifierScoreBarsEl.innerHTML = ''; - return; - } - const entries = Object.entries(scores) +function scoreEntries(scores, limit = 6) { + if (!scores || typeof scores !== 'object') return []; + return Object.entries(scores) .filter(([, v]) => Number.isFinite(Number(v))) .sort((a, b) => Number(b[1]) - Number(a[1])) - .slice(0, 6); + .slice(0, limit); +} + +function renderScoreBars(scores) { + if (!classifierScoreBarsEl) return; + const entries = scoreEntries(scores); if (!entries.length) { classifierScoreBarsEl.innerHTML = ''; return; @@ -205,6 +207,28 @@ function renderScoreBars(scores) { }).join(''); } +function hideSignalPopover() { + hoveredSignal = null; + if (!signalPopover) return; + signalPopover.classList.remove('open'); + signalPopover.setAttribute('aria-hidden', 'true'); +} + +function renderSignalPopover(rect, signal) { + if (!signalPopover || !signal) return; + const entries = scoreEntries(signal.class?.scores || signal.debug_scores || {}, 4); + const maxVal = Math.max(...entries.map(([, v]) => Number(v)), 1e-6); + const rows = entries.map(([label, value]) => { + const width = Math.max(4, (Number(value) / maxVal) * 100); + return `
${label}${Number(value).toFixed(2)}
`; + }).join(''); + signalPopover.innerHTML = `
${signal.class?.mod_type || 'Signal'}
${fmtMHz(signal.center_hz, 5)} · ${fmtKHz(signal.bw_hz || 0)} · ${(signal.snr_db || 0).toFixed(1)} dB SNR
${rows || '
No classifier scores
'}
`; + signalPopover.style.left = `${Math.max(8, rect.x + rect.w + 8)}px`; + signalPopover.style.top = `${Math.max(8, rect.y + 8)}px`; + signalPopover.classList.add('open'); + signalPopover.setAttribute('aria-hidden', 'false'); +} + function colorMap(v) { const x = Math.max(0, Math.min(1, v)); const r = Math.floor(255 * Math.pow(x, 0.55)); @@ -731,6 +755,10 @@ function renderSpectrum() { const label = `${(s.center_hz / 1e6).toFixed(4)} MHz`; ctx.fillText(label, Math.max(4, x1 + 4), 24 + (index % 3) * 16); + const debugMatch = (latest?.debug?.scores || []).find((d) => Math.abs((d.center_hz || 0) - (s.center_hz || 0)) < Math.max(500, s.bw_hz || 0)); + if (debugMatch?.scores && (!s.class || !s.class.scores)) { + s.debug_scores = debugMatch.scores; + } liveSignalRects.push({ x: x1, y: 10, @@ -1242,7 +1270,25 @@ window.addEventListener('mouseup', () => { isDraggingSpectrum = false; navDrag = false; }); +spectrumCanvas.addEventListener('mouseleave', hideSignalPopover); window.addEventListener('mousemove', (ev) => { + const rect = spectrumCanvas.getBoundingClientRect(); + const x = (ev.clientX - rect.left) * (spectrumCanvas.width / rect.width); + const y = (ev.clientY - rect.top) * (spectrumCanvas.height / rect.height); + let hoverHit = null; + for (let i = liveSignalRects.length - 1; i >= 0; i--) { + const r = liveSignalRects[i]; + if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) { + hoverHit = r; + break; + } + } + if (hoverHit) { + hoveredSignal = hoverHit.signal; + renderSignalPopover(hoverHit, hoverHit.signal); + } else { + hideSignalPopover(); + } if (isDraggingSpectrum) { const dx = ev.clientX - dragStartX; pan = Math.max(-0.5, Math.min(0.5, dragStartPan - dx / spectrumCanvas.clientWidth)); @@ -1394,6 +1440,7 @@ maxHoldToggle.addEventListener('change', () => { }); if (debugOverlayToggle) debugOverlayToggle.addEventListener('change', () => { showDebugOverlay = debugOverlayToggle.checked; + localStorage.setItem('spectre.debugOverlay', showDebugOverlay ? '1' : '0'); markSpectrumDirty(); updateHeroMetrics(); }); @@ -1601,3 +1648,5 @@ setInterval(fetchRecordings, 5000); setInterval(loadSignals, 1500); setInterval(loadDecoders, 10000); +coders, 10000); + diff --git a/web/index.html b/web/index.html index add056e..1f459d2 100644 --- a/web/index.html +++ b/web/index.html @@ -90,6 +90,7 @@ +
WaterfallNewest row at top
diff --git a/web/style.css b/web/style.css index 6fba36f..bfc2d66 100644 --- a/web/style.css +++ b/web/style.css @@ -213,6 +213,19 @@ button, input, select { font: inherit; } .viz-hint { font-family: var(--mono); font-size: 0.58rem; color: var(--text-mute); } canvas { display: block; width: 100%; height: 100%; border-radius: var(--r-sm); background: #030508; } +.signal-popover { + position: absolute; z-index: 8; pointer-events: none; min-width: 180px; + padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-hi); + background: rgba(6, 10, 18, 0.94); box-shadow: 0 18px 50px rgba(0,0,0,0.38); + font-size: 0.72rem; color: var(--text); display: none; +} +.signal-popover.open { display: block; } +.signal-popover__title { font-family: var(--mono); font-weight: 700; margin-bottom: 4px; color: var(--accent); } +.signal-popover__meta { color: var(--text-mute); margin-bottom: 6px; } +.signal-popover__scores { display: grid; gap: 4px; } +.signal-popover__row { display: grid; grid-template-columns: 42px 1fr 36px; gap: 6px; align-items: center; font-family: var(--mono); font-size: 0.68rem; } +.signal-popover__bar { height: 6px; border-radius: 999px; background: rgba(148,163,184,0.14); overflow: hidden; } +.signal-popover__fill { height: 100%; background: linear-gradient(90deg, rgba(0,255,200,0.72), rgba(0,144,255,0.8)); } #spectrum, #waterfall { cursor: crosshair; } #spectrum:active { cursor: grabbing; }