Pārlūkot izejas kodu

feat: add signal popovers and persist debug overlay

master
Jan Svabenik pirms 2 dienas
vecāks
revīzija
ac7fccaa5f
7 mainītis faili ar 331 papildinājumiem un 240 dzēšanām
  1. +85
    -0
      cmd/sdrd/hub.go
  2. +1
    -231
      cmd/sdrd/main.go
  3. +104
    -0
      cmd/sdrd/source_manager.go
  4. +69
    -0
      cmd/sdrd/types.go
  5. +58
    -9
      web/app.js
  6. +1
    -0
      web/index.html
  7. +13
    -0
      web/style.css

+ 85
- 0
cmd/sdrd/hub.go Parādīt failu

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

+ 1
- 231
cmd/sdrd/main.go Parādīt failu

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


+ 104
- 0
cmd/sdrd/source_manager.go Parādīt failu

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

+ 69
- 0
cmd/sdrd/types.go Parādīt failu

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

+ 58
- 9
web/app.js Parādīt failu

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


+ 1
- 0
web/index.html Parādīt failu

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


+ 13
- 0
web/style.css Parādīt failu

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



Notiek ielāde…
Atcelt
Saglabāt