diff --git a/.gitignore b/.gitignore index 2bcc412..a4c036b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ data/events.jsonl # local prompts -prompt.txt +prompt*.txt diff --git a/README.md b/README.md index c4fc19f..79eedd2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index c128e8c..742f3df 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -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) +} diff --git a/internal/events/reader.go b/internal/events/reader.go new file mode 100644 index 0000000..a16561b --- /dev/null +++ b/internal/events/reader.go @@ -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 +} diff --git a/internal/events/reader_test.go b/internal/events/reader_test.go new file mode 100644 index 0000000..8f3a135 --- /dev/null +++ b/internal/events/reader_test.go @@ -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) + } +} diff --git a/web/app.js b/web/app.js index 07d4865..4d73ddd 100644 --- a/web/app.js +++ b/web/app.js @@ -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); diff --git a/web/index.html b/web/index.html index 73049a3..37a4671 100644 --- a/web/index.html +++ b/web/index.html @@ -18,7 +18,40 @@
+
+
+
Event Timeline
+
Waiting for events...
+
+ +
+ diff --git a/web/style.css b/web/style.css index 88b4c49..a5100d3 100644 --- a/web/style.css +++ b/web/style.css @@ -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; } }