| @@ -11,4 +11,4 @@ | |||
| data/events.jsonl | |||
| # local prompts | |||
| prompt.txt | |||
| prompt*.txt | |||
| @@ -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 | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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); | |||
| @@ -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> | |||
| @@ -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; | |||
| } | |||
| } | |||