| @@ -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)) | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 `<div class="signal-popover__row"><span>${label}</span><span class="signal-popover__bar"><span class="signal-popover__fill" style="width:${width}%"></span></span><span>${Number(value).toFixed(2)}</span></div>`; | |||
| }).join(''); | |||
| signalPopover.innerHTML = `<div class="signal-popover__title">${signal.class?.mod_type || 'Signal'}</div><div class="signal-popover__meta">${fmtMHz(signal.center_hz, 5)} · ${fmtKHz(signal.bw_hz || 0)} · ${(signal.snr_db || 0).toFixed(1)} dB SNR</div><div class="signal-popover__scores">${rows || '<div class="signal-popover__meta">No classifier scores</div>'}</div>`; | |||
| 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); | |||
| @@ -90,6 +90,7 @@ | |||
| </span> | |||
| </div> | |||
| <canvas id="spectrum"></canvas> | |||
| <div class="signal-popover" id="signalPopover" aria-hidden="true"></div> | |||
| </div> | |||
| <div class="viz-card"> | |||
| <div class="viz-head"><span class="viz-label">Waterfall</span><span class="viz-hint" id="waterfallMeta">Newest row at top</span></div> | |||
| @@ -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; } | |||