feat: web file upload, axis-locked grid, averaged cursor readout

**File loading on the web.** The browser build's filesystem is an in-memory
sandbox, so the file browser could only ever list what the page itself had
written — useless for opening a capture off the user's disk. Adds
Platform_NeedsFileUpload / RequestFileUpload / TakeUploadedFile: no-ops on
desktop, and on web they drive a real <input type="file">, copy the chosen
bytes into MEMFS, and hand the path back through a callback the main loop
polls. The ordinary load path takes it from there, so mLnL parsing and
collision detection work identically.

All three entry points (the O key, File -> Open, and the empty-state
button) route through the same place, so they can't disagree about what
"open" means. ccall/cwrap/FS and the callback symbol needed explicit
export — recent emscripten omits them by default and the failure is
silent until someone clicks the button.

The empty-state banner was centred with the old 320px sidebar's width
hardcoded and never measured its text, so it sat well off-centre once the
rail shrank to 46px. It now measures and centres against the area right of
the rail, and says something useful on web.

**Grid was not locked to the axes.** It drew a fixed 10x8 even divisions of
the viewport rectangle, with no idea what time or frequency those lines
fell on — so they marked arbitrary values and slid continuously while
panning, never coinciding with the labels. Both axes now draw at real
values from a 1-2-5 ladder, and the time labels moved onto that same
ladder (they were at 11 fixed fractions of the view, printing values like
"3.47s" that matched no gridline). Grid and labels share one set of
spacing helpers so they cannot drift apart again.

**Cursor dB readout was a single bin.** On an OFDM burst that swings ~30 dB
between adjacent subcarriers and symbols, so the number reported where the
cursor landed rather than the level of the signal under it. Now averaged
over +/-3 segments x +/-6 bins (~76 Hz x 300 ms at 12 kHz / 2048, under a
third of the narrowest mLink channel, so it stays inside one signal).
Simulated against Rayleigh-distributed subcarriers this cuts the standard
deviation from 5.6 dB to 0.44 dB.

The mean is taken over power and converted afterwards: averaging dB values
is a geometric mean of power and read ~2 dB low. The window is sized in
STFT cells rather than screen pixels, which would otherwise mean something
different at every zoom — one pixel spans hundreds of segments zoomed out
and a fraction of a bin zoomed in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
This commit is contained in:
2026-08-12 16:23:26 -07:00
parent 56b1666850
commit 40623feadc
9 changed files with 386 additions and 54 deletions
+24 -2
View File
@@ -111,9 +111,22 @@ prints the install hint for your platform if anything is missing.
### Web (WebAssembly) build
```bash
./build_web.sh # emscripten; emits the WebAssembly bundle to bin/web/
source ~/emsdk/emsdk_env.sh # emscripten on PATH
./build_web.sh # emits the bundle to bin/web/
cd bin/web && python3 -m http.server 8080
```
Then open <http://localhost:8080/rspektrum.html>.
The browser build's filesystem is an in-memory sandbox, so files come in by
**drag-and-drop** or the **Open file** button (which drives the host's native
file picker and copies the result in). The desktop file browser is bypassed
there — it could only ever list what the page itself had written.
The STFT is computed in one synchronous pass on load rather than progressively,
because the incremental fill depends on idle main-loop frames that the browser
doesn't give back the same way. Long captures therefore block until they finish.
---
## Usage (desktop GUI)
@@ -149,7 +162,7 @@ Middle-drag always pans.
| **Drag the playhead** | (while stopped) set where the next play starts |
| **Hover an annotation** | Tooltip with that frame's mLnL detail; lists **every** overlapping frame under the cursor |
| **N** / **Shift+N** | Jump to the next / previous collision |
| **O** | Open file browser |
| **O** | Open file browser (the host's file picker on web) |
| **P** | Show / hide the waveform scope |
| **M** | Marker / ruler tool |
| **S** | Spectrum slice (PSD) |
@@ -303,6 +316,15 @@ reference implementation.
which on a 5.7-hour file is the difference between ~60 ms and ~0.07 ms per
frame. Keeping both extremes per bucket means a single-sample transient still
shows up when fully zoomed out.
- **Cursor dB readout** — averaged over a neighbourhood of STFT cells
(±3 segments × ±6 bins, roughly 76 Hz × 300 ms at 12 kHz / 2048) rather than
read from one bin. A single bin of an OFDM burst swings ~30 dB between
adjacent subcarriers and symbols, so a one-bin readout reports where the
cursor happened to land rather than the level of the signal under it. The mean
is taken over power and converted to dB afterwards — averaging dB values is a
geometric mean of power and reads a couple of dB low. Sized in STFT cells, not
screen pixels: one pixel spans hundreds of segments zoomed out and a fraction
of a bin zoomed in.
- **Time zoom limit** — the tightest visible window is derived from the STFT hop
(`fftSize / HOP_RATIO` samples), not from a fixed fraction of the file, so time
resolution does not degrade as files get longer: a 30-minute recording zooms in