Bladeren bron

Add events API and timeline UI

master
Jan Svabenik 4 dagen geleden
bovenliggende
commit
8c762a0691
8 gewijzigde bestanden met toevoegingen van 646 en 2 verwijderingen
  1. +1
    -1
      .gitignore
  2. +18
    -0
      README.md
  3. +43
    -0
      cmd/sdrd/main.go
  4. +111
    -0
      internal/events/reader.go
  5. +60
    -0
      internal/events/reader_test.go
  6. +257
    -0
      web/app.js
  7. +33
    -0
      web/index.html
  8. +123
    -1
      web/style.css

+ 1
- 1
.gitignore Bestand weergeven

@@ -11,4 +11,4 @@
data/events.jsonl

# local prompts
prompt.txt
prompt*.txt

+ 18
- 0
README.md Bestand weergeven

@@ -4,7 +4,11 @@ Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event

## Features
- Live spectrum + waterfall web UI (WebSocket streaming)
- Event timeline view (time vs frequency) with detail drawer
- In-browser spectrogram slice for selected events
- Basic detector with event JSONL output (`data/events.jsonl`)
- Events API (`/api/events?limit=...&since=...`)
- Recorded clips list placeholder (metadata only for now)
- Windows + Linux support
- Mock mode for testing without hardware

@@ -50,6 +54,20 @@ Edit `config.yaml`:
## Web UI
The UI is served from `web/` and connects to `/ws` for spectrum frames.

### Event Timeline
- The timeline panel displays recent events (time vs frequency).
- Click any event block to open the detail drawer with event stats and a mini spectrogram slice from the latest frame.

### Events API
`/api/events` reads from the JSONL event log and returns the most recent events:
- `limit` (optional): max number of events (default 200, max 2000)
- `since` (optional): Unix milliseconds or RFC3339 timestamp

Example:
```bash
curl "http://localhost:8080/api/events?limit=100&since=1700000000000"
```

## Tests
```bash



+ 43
- 0
cmd/sdrd/main.go Bestand weergeven

@@ -9,6 +9,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"sync"
"syscall"
"time"
@@ -17,6 +18,7 @@ import (

"sdr-visual-suite/internal/config"
"sdr-visual-suite/internal/detector"
"sdr-visual-suite/internal/events"
fftutil "sdr-visual-suite/internal/fft"
"sdr-visual-suite/internal/mock"
"sdr-visual-suite/internal/sdr"
@@ -134,6 +136,31 @@ func main() {
_ = json.NewEncoder(w).Encode(cfg)
})

http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
limit := 200
if v := r.URL.Query().Get("limit"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
limit = parsed
}
}
var since time.Time
if v := r.URL.Query().Get("since"); v != "" {
if parsed, err := parseSince(v); err == nil {
since = parsed
} else {
http.Error(w, "invalid since", http.StatusBadRequest)
return
}
}
evs, err := events.ReadRecent(cfg.EventPath, limit, since)
if err != nil {
http.Error(w, "failed to read events", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(evs)
})

http.Handle("/", http.FileServer(http.Dir(cfg.WebRoot)))

server := &http.Server{Addr: cfg.WebAddr}
@@ -184,3 +211,19 @@ func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detecto
}
}
}

func parseSince(raw string) (time.Time, error) {
if raw == "" {
return time.Time{}, nil
}
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
if ms > 1e12 {
return time.UnixMilli(ms), nil
}
return time.Unix(ms, 0), nil
}
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return t, nil
}
return time.Parse(time.RFC3339, raw)
}

+ 111
- 0
internal/events/reader.go Bestand weergeven

@@ -0,0 +1,111 @@
package events

import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"time"

"sdr-visual-suite/internal/detector"
)

const (
defaultLimit = 200
maxLimit = 2000
readChunk = 64 * 1024
)

// ReadRecent reads the newest events from a JSONL file.
// If since is non-zero, older events (by End time) are skipped.
func ReadRecent(path string, limit int, since time.Time) ([]detector.Event, error) {
if limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}

file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer file.Close()

info, err := file.Stat()
if err != nil {
return nil, err
}
if info.Size() == 0 {
return nil, nil
}

lines, err := readLinesReverse(file, info.Size(), limit)
if err != nil {
return nil, err
}

events := make([]detector.Event, 0, len(lines))
for _, line := range lines {
var ev detector.Event
if err := json.Unmarshal([]byte(line), &ev); err != nil {
continue
}
if !since.IsZero() && ev.End.Before(since) {
break
}
events = append(events, ev)
}

for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 {
events[i], events[j] = events[j], events[i]
}
return events, nil
}

func readLinesReverse(file *os.File, size int64, limit int) ([]string, error) {
pos := size
remainder := []byte{}
lines := make([]string, 0, limit)

for pos > 0 && len(lines) < limit {
chunkSize := int64(readChunk)
if chunkSize > pos {
chunkSize = pos
}
pos -= chunkSize
buf := make([]byte, chunkSize)
n, err := file.ReadAt(buf, pos)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
buf = buf[:n]
data := append(buf, remainder...)

i := len(data)
for i > 0 && len(lines) < limit {
j := bytes.LastIndexByte(data[:i], '\n')
if j == -1 {
break
}
line := bytes.TrimSpace(data[j+1 : i])
if len(line) > 0 {
lines = append(lines, string(line))
}
i = j
}
remainder = data[:i]
}

if len(lines) < limit {
line := bytes.TrimSpace(remainder)
if len(line) > 0 {
lines = append(lines, string(line))
}
}
return lines, nil
}

+ 60
- 0
internal/events/reader_test.go Bestand weergeven

@@ -0,0 +1,60 @@
package events

import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

"sdr-visual-suite/internal/detector"
)

func TestReadRecent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "events.jsonl")

start := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Millisecond)
events := []detector.Event{
{ID: 1, Start: start, End: start.Add(2 * time.Second), CenterHz: 100, Bandwidth: 5, PeakDb: -10, SNRDb: 12},
{ID: 2, Start: start.Add(10 * time.Second), End: start.Add(12 * time.Second), CenterHz: 200, Bandwidth: 10, PeakDb: -5, SNRDb: 15},
{ID: 3, Start: start.Add(20 * time.Second), End: start.Add(22 * time.Second), CenterHz: 300, Bandwidth: 20, PeakDb: -3, SNRDb: 18},
}

file, err := os.Create(path)
if err != nil {
t.Fatalf("create: %v", err)
}
enc := json.NewEncoder(file)
for _, ev := range events {
if err := enc.Encode(ev); err != nil {
t.Fatalf("encode: %v", err)
}
}
if err := file.Close(); err != nil {
t.Fatalf("close: %v", err)
}

got, err := ReadRecent(path, 2, time.Time{})
if err != nil {
t.Fatalf("read: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 events, got %d", len(got))
}
if got[0].ID != 2 || got[1].ID != 3 {
t.Fatalf("unexpected IDs: %v, %v", got[0].ID, got[1].ID)
}

since := start.Add(15 * time.Second)
got, err = ReadRecent(path, 10, since)
if err != nil {
t.Fatalf("read since: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 event, got %d", len(got))
}
if got[0].ID != 3 {
t.Fatalf("expected ID 3, got %d", got[0].ID)
}
}

+ 257
- 0
web/app.js Bestand weergeven

@@ -1,7 +1,18 @@
const spectrumCanvas = document.getElementById('spectrum');
const waterfallCanvas = document.getElementById('waterfall');
const timelineCanvas = document.getElementById('timeline');
const statusEl = document.getElementById('status');
const metaEl = document.getElementById('meta');
const timelineRangeEl = document.getElementById('timelineRange');
const drawerEl = document.getElementById('eventDrawer');
const drawerCloseBtn = document.getElementById('drawerClose');
const detailCenterEl = document.getElementById('detailCenter');
const detailBwEl = document.getElementById('detailBw');
const detailStartEl = document.getElementById('detailStart');
const detailEndEl = document.getElementById('detailEnd');
const detailSnrEl = document.getElementById('detailSnr');
const detailDurEl = document.getElementById('detailDur');
const detailSpectrogram = document.getElementById('detailSpectrogram');

let latest = null;
let zoom = 1.0;
@@ -9,6 +20,15 @@ let pan = 0.0;
let isDragging = false;
let dragStartX = 0;
let dragStartPan = 0;
let timelineDirty = true;
let detailDirty = false;

const events = [];
const eventsById = new Map();
let lastEventEndMs = 0;
let eventsFetchInFlight = false;
let timelineRects = [];
let selectedEventId = null;

function resize() {
const dpr = window.devicePixelRatio || 1;
@@ -18,6 +38,14 @@ function resize() {
const rect2 = waterfallCanvas.getBoundingClientRect();
waterfallCanvas.width = rect2.width * dpr;
waterfallCanvas.height = rect2.height * dpr;
const rect3 = timelineCanvas.getBoundingClientRect();
timelineCanvas.width = rect3.width * dpr;
timelineCanvas.height = rect3.height * dpr;
const rect4 = detailSpectrogram.getBoundingClientRect();
detailSpectrogram.width = rect4.width * dpr;
detailSpectrogram.height = rect4.height * dpr;
timelineDirty = true;
detailDirty = true;
}

window.addEventListener('resize', resize);
@@ -31,6 +59,12 @@ function colorMap(v) {
return [r, g, b];
}

function snrColor(snr) {
const norm = Math.max(0, Math.min(1, (snr + 5) / 30));
const [r, g, b] = colorMap(norm);
return `rgb(${r}, ${g}, ${b})`;
}

function renderSpectrum() {
if (!latest) return;
const ctx = spectrumCanvas.getContext('2d');
@@ -129,9 +163,130 @@ function renderWaterfall() {
ctx.putImageData(row, 0, 0);
}

function renderTimeline() {
const ctx = timelineCanvas.getContext('2d');
const w = timelineCanvas.width;
const h = timelineCanvas.height;
ctx.clearRect(0, 0, w, h);

if (events.length === 0) {
timelineRangeEl.textContent = 'No events yet';
return;
}

const now = Date.now();
const windowMs = 5 * 60 * 1000;
const endMs = now;
const startMs = endMs - windowMs;

let minHz = Infinity;
let maxHz = -Infinity;
if (latest) {
minHz = latest.center_hz - latest.sample_rate / 2;
maxHz = latest.center_hz + latest.sample_rate / 2;
} else {
for (const ev of events) {
minHz = Math.min(minHz, ev.center_hz - ev.bandwidth_hz / 2);
maxHz = Math.max(maxHz, ev.center_hz + ev.bandwidth_hz / 2);
}
}
if (!isFinite(minHz) || !isFinite(maxHz) || minHz === maxHz) {
minHz = 0;
maxHz = 1;
}

ctx.strokeStyle = '#13263b';
ctx.lineWidth = 1;
for (let i = 1; i < 6; i++) {
const y = (h / 6) * i;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}

timelineRects = [];
for (const ev of events) {
if (ev.end_ms < startMs || ev.start_ms > endMs) continue;
const x1 = ((Math.max(ev.start_ms, startMs) - startMs) / (endMs - startMs)) * w;
const x2 = ((Math.min(ev.end_ms, endMs) - startMs) / (endMs - startMs)) * w;
const bw = Math.max(ev.bandwidth_hz, 1);
const topHz = ev.center_hz + bw / 2;
const bottomHz = ev.center_hz - bw / 2;
const y1 = ((maxHz - topHz) / (maxHz - minHz)) * h;
const y2 = ((maxHz - bottomHz) / (maxHz - minHz)) * h;
const rectH = Math.max(2, y2 - y1);

ctx.fillStyle = snrColor(ev.snr_db || 0);
ctx.fillRect(x1, y1, Math.max(2, x2 - x1), rectH);

const rect = { x: x1, y: y1, w: Math.max(2, x2 - x1), h: rectH, id: ev.id };
timelineRects.push(rect);
}

if (selectedEventId) {
const hit = timelineRects.find((r) => r.id === selectedEventId);
if (hit) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.strokeRect(hit.x - 1, hit.y - 1, hit.w + 2, hit.h + 2);
}
}

const startLabel = new Date(startMs).toLocaleTimeString();
const endLabel = new Date(endMs).toLocaleTimeString();
timelineRangeEl.textContent = `${startLabel} - ${endLabel}`;
}

function renderDetailSpectrogram(ev) {
const ctx = detailSpectrogram.getContext('2d');
const w = detailSpectrogram.width;
const h = detailSpectrogram.height;
ctx.clearRect(0, 0, w, h);
if (!latest || !ev) return;

const span = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 3, latest.sample_rate / 8));
const startHz = ev.center_hz - span / 2;
const endHz = ev.center_hz + span / 2;

const { spectrum_db, sample_rate, center_hz } = latest;
const n = spectrum_db.length;
const minDb = -120;
const maxDb = 0;

const row = ctx.createImageData(w, 1);
for (let x = 0; x < w; x++) {
const freq = startHz + (x / (w - 1)) * (endHz - startHz);
const bin = Math.floor((freq - (center_hz - sample_rate / 2)) / (sample_rate / n));
if (bin >= 0 && bin < n) {
const v = spectrum_db[bin];
const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb)));
const [r, g, b] = colorMap(norm);
row.data[x * 4 + 0] = r;
row.data[x * 4 + 1] = g;
row.data[x * 4 + 2] = b;
row.data[x * 4 + 3] = 255;
} else {
row.data[x * 4 + 3] = 255;
}
}
for (let y = 0; y < h; y++) {
ctx.putImageData(row, 0, y);
}
}

function tick() {
renderSpectrum();
renderWaterfall();
if (timelineDirty) {
renderTimeline();
timelineDirty = false;
}
if (detailDirty && drawerEl.classList.contains('open')) {
const ev = eventsById.get(selectedEventId);
renderDetailSpectrogram(ev);
detailDirty = false;
}
requestAnimationFrame(tick);
}

@@ -143,6 +298,8 @@ function connect() {
};
ws.onmessage = (ev) => {
latest = JSON.parse(ev.data);
detailDirty = true;
timelineDirty = true;
};
ws.onclose = () => {
statusEl.textContent = 'Disconnected - retrying...';
@@ -173,5 +330,105 @@ window.addEventListener('mousemove', (ev) => {
pan = Math.max(-0.5, Math.min(0.5, pan));
});

function normalizeEvent(ev) {
const startMs = new Date(ev.start).getTime();
const endMs = new Date(ev.end).getTime();
return {
...ev,
start_ms: startMs,
end_ms: endMs,
duration_ms: Math.max(0, endMs - startMs),
};
}

function upsertEvents(list, replace) {
if (replace) {
events.length = 0;
eventsById.clear();
}
for (const raw of list) {
if (eventsById.has(raw.id)) continue;
const ev = normalizeEvent(raw);
eventsById.set(ev.id, ev);
events.push(ev);
}
events.sort((a, b) => a.end_ms - b.end_ms);
const maxEvents = 1500;
if (events.length > maxEvents) {
const drop = events.length - maxEvents;
for (let i = 0; i < drop; i++) {
eventsById.delete(events[i].id);
}
events.splice(0, drop);
}
if (events.length > 0) {
lastEventEndMs = events[events.length - 1].end_ms;
}
timelineDirty = true;
}

async function fetchEvents(initial) {
if (eventsFetchInFlight) return;
eventsFetchInFlight = true;
try {
let url = '/api/events?limit=1000';
if (!initial && lastEventEndMs > 0) {
url = `/api/events?since=${lastEventEndMs - 1}`;
}
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data)) {
upsertEvents(data, initial);
}
} finally {
eventsFetchInFlight = false;
}
}

function openDrawer(ev) {
if (!ev) return;
selectedEventId = ev.id;
detailCenterEl.textContent = `${(ev.center_hz / 1e6).toFixed(6)} MHz`;
detailBwEl.textContent = `${(ev.bandwidth_hz / 1e3).toFixed(2)} kHz`;
detailStartEl.textContent = new Date(ev.start_ms).toLocaleString();
detailEndEl.textContent = new Date(ev.end_ms).toLocaleString();
detailSnrEl.textContent = `${(ev.snr_db || 0).toFixed(1)} dB`;
detailDurEl.textContent = `${(ev.duration_ms / 1000).toFixed(2)} s`;
drawerEl.classList.add('open');
drawerEl.setAttribute('aria-hidden', 'false');
resize();
detailDirty = true;
timelineDirty = true;
}

function closeDrawer() {
drawerEl.classList.remove('open');
drawerEl.setAttribute('aria-hidden', 'true');
selectedEventId = null;
timelineDirty = true;
}

drawerCloseBtn.addEventListener('click', closeDrawer);

timelineCanvas.addEventListener('click', (ev) => {
const rect = timelineCanvas.getBoundingClientRect();
const scaleX = timelineCanvas.width / rect.width;
const scaleY = timelineCanvas.height / rect.height;
const x = (ev.clientX - rect.left) * scaleX;
const y = (ev.clientY - rect.top) * scaleY;

for (let i = timelineRects.length - 1; i >= 0; i--) {
const r = timelineRects[i];
if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
const hit = eventsById.get(r.id);
openDrawer(hit);
return;
}
}
});

connect();
requestAnimationFrame(tick);
fetchEvents(true);
setInterval(() => fetchEvents(false), 2000);

+ 33
- 0
web/index.html Bestand weergeven

@@ -18,7 +18,40 @@
<section class="panel">
<canvas id="waterfall"></canvas>
</section>
<section class="panel timeline-panel">
<div class="panel-header">
<div>Event Timeline</div>
<div class="panel-subtitle" id="timelineRange">Waiting for events...</div>
</div>
<canvas id="timeline"></canvas>
</section>
</main>
<aside class="drawer" id="eventDrawer" aria-hidden="true">
<div class="drawer-header">
<div>Event Detail</div>
<button class="drawer-close" id="drawerClose" type="button">Close</button>
</div>
<div class="drawer-body">
<div class="detail-grid">
<div class="detail-label">Center</div>
<div id="detailCenter">-</div>
<div class="detail-label">Bandwidth</div>
<div id="detailBw">-</div>
<div class="detail-label">Start</div>
<div id="detailStart">-</div>
<div class="detail-label">End</div>
<div id="detailEnd">-</div>
<div class="detail-label">SNR</div>
<div id="detailSnr">-</div>
<div class="detail-label">Duration</div>
<div id="detailDur">-</div>
</div>
<div class="detail-section-title">Latest Spectrogram Slice</div>
<canvas id="detailSpectrogram"></canvas>
<div class="detail-section-title">Recorded Clips (placeholder)</div>
<div class="clips-empty">No clips recorded yet.</div>
</div>
</aside>
<footer>
<div id="status">Connecting...</div>
</footer>


+ 123
- 1
web/style.css Bestand weergeven

@@ -46,6 +46,7 @@ footer {
main {
flex: 1;
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr 1.2fr;
gap: 12px;
padding: 12px;
@@ -66,12 +67,133 @@ canvas {
background: #06090d;
}

.timeline-panel {
display: flex;
flex-direction: column;
gap: 8px;
grid-row: 1 / span 2;
grid-column: 2;
}

.timeline-panel canvas {
flex: 1;
cursor: crosshair;
}

.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.95rem;
color: var(--text);
}

.panel-subtitle {
font-size: 0.8rem;
color: var(--muted);
}

#spectrum {
cursor: grab;
}

#spectrum:active {
cursor: grabbing;
}

.drawer {
position: fixed;
right: 12px;
top: 70px;
bottom: 70px;
width: 320px;
background: #0d141e;
border: 1px solid #13263b;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
display: none;
flex-direction: column;
z-index: 10;
}

.drawer.open {
display: flex;
}

.drawer-header {
padding: 12px 16px;
border-bottom: 1px solid #13263b;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}

.drawer-close {
background: #162030;
border: 1px solid #20344b;
color: var(--text);
padding: 4px 10px;
border-radius: 8px;
cursor: pointer;
}

.drawer-body {
padding: 12px 16px 16px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
}

.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 12px;
font-size: 0.9rem;
}

.detail-label {
color: var(--muted);
}

.detail-section-title {
font-size: 0.85rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}

#detailSpectrogram {
height: 90px;
}

.clips-empty {
color: var(--muted);
font-size: 0.9rem;
}

#status {
color: var(--muted);
}

@media (max-width: 820px) {
main {
grid-template-rows: 1fr 1fr;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr;
}

.timeline-panel {
grid-row: auto;
grid-column: auto;
}

.drawer {
left: 12px;
right: 12px;
width: auto;
top: auto;
bottom: 12px;
height: 45vh;
}
}

Laden…
Annuleren
Opslaan