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
+5 -1
View File
@@ -90,7 +90,11 @@ done
# the resources dir at runtime, so the resources@resources preload covers it.
# INITIAL_MEMORY + ALLOW_MEMORY_GROWTH: the web build now computes the full STFT
# up front, so the heap must be able to grow for longer recordings.
LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s INITIAL_MEMORY=67108864 -s ALLOW_MEMORY_GROWTH=1 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1"
# EXPORTED_RUNTIME_METHODS: the file-upload path calls back into C from a
# browser event (ccall), and writes the chosen file into MEMFS (FS). Neither is
# exported by default in recent emscripten, and omitting them fails only at
# runtime, when the user clicks "Open file".
LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s INITIAL_MEMORY=67108864 -s ALLOW_MEMORY_GROWTH=1 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1 -s EXPORTED_RUNTIME_METHODS=ccall,cwrap,FS -s EXPORTED_FUNCTIONS=_main,_rspektrum_upload_done"
if [ "$BUILD_TYPE" = "debug" ]; then
LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1"
+29
View File
@@ -1,6 +1,7 @@
#pragma once
#include <stddef.h>
#include <stdbool.h>
/* ── Public API ─────────────────────────────────────────────────────────── */
@@ -108,3 +109,31 @@ const char *Platform_GetTempDir(void);
* @param path Path the file was just written to.
*/
void Platform_OfferFileToUser(const char *path);
/**
* Whether this platform needs an explicit "upload" affordance to get a file in.
*
* True only on the web, where the app's filesystem is an in-memory sandbox the
* user cannot see: a file browser there lists nothing useful, so the UI offers
* a native file picker instead.
*/
bool Platform_NeedsFileUpload(void);
/**
* Open the host's file picker and copy the chosen file into a place this
* process can read.
*
* Asynchronous by nature on the web (the picker resolves in a browser event),
* so this returns immediately; poll Platform_TakeUploadedFile() for the result.
* No-op on desktop, where the built-in file browser already works.
*/
void Platform_RequestFileUpload(void);
/**
* Collect a file delivered by Platform_RequestFileUpload, if one is ready.
*
* @param outPath Buffer receiving the path within the app's filesystem.
* @param cap Size of `outPath`.
* @return true exactly once per uploaded file; false when nothing is pending.
*/
bool Platform_TakeUploadedFile(char *outPath, int cap);
+11
View File
@@ -105,3 +105,14 @@ void Platform_OfferFileToUser(const char *path) {
/* Desktop: the file is already on disk where the user wanted it. */
(void)path;
}
bool Platform_NeedsFileUpload(void) { return false; }
void Platform_RequestFileUpload(void) {
/* Desktop has a real filesystem and a working file browser; nothing to do. */
}
bool Platform_TakeUploadedFile(char *outPath, int cap) {
(void)outPath; (void)cap;
return false;
}
+76
View File
@@ -93,3 +93,79 @@ void Platform_OfferFileToUser(const char *path) {
}
}, path);
}
/*
* File upload.
*
* The web build's filesystem is an in-memory sandbox, so the app's own file
* browser can only ever list files this process already wrote — useless for
* opening a capture off the user's disk. Instead we drive a real
* <input type="file"> and copy the chosen file into MEMFS, where the ordinary
* LoadWavFile path can read it like any other.
*
* The picker resolves in a browser event long after the C call returns, so the
* result is parked in these globals and collected by polling from the main
* loop. g_uploadReady is written from JS (see EM_ASM below) and read from C.
*/
static char g_uploadPath[512];
static volatile int g_uploadReady = 0;
/* Called from JS once the file's bytes are in MEMFS. */
EMSCRIPTEN_KEEPALIVE
void rspektrum_upload_done(const char *path) {
if (!path) return;
strncpy(g_uploadPath, path, sizeof(g_uploadPath) - 1);
g_uploadPath[sizeof(g_uploadPath) - 1] = '\0';
g_uploadReady = 1;
}
bool Platform_NeedsFileUpload(void) { return true; }
void Platform_RequestFileUpload(void) {
EM_ASM({
// Reuse one hidden input across calls: creating a fresh element per
// click leaks nodes, and some browsers ignore a picker opened from an
// element that isn't in the document.
var input = document.getElementById('rspektrum-upload');
if (!input) {
input = document.createElement('input');
input.type = 'file';
input.id = 'rspektrum-upload';
input.accept = '.wav,.wave,audio/*';
input.style.display = 'none';
document.body.appendChild(input);
}
input.onchange = function(ev) {
var file = ev.target.files && ev.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function() {
try {
var bytes = new Uint8Array(reader.result);
// Keep the original name so the UI can show something
// meaningful; sanitise it into a flat MEMFS path.
var safe = file.name.replace(/[^A-Za-z0-9._-]/g, '_');
var path = '/uploads/' + safe;
try { FS.mkdir('/uploads'); } catch (e) {}
try { FS.unlink(path); } catch (e) {}
FS.writeFile(path, bytes);
ccall('rspektrum_upload_done', null, ['string'], [path]);
} catch (e) {
console.error('rspektrum: upload failed: ' + e);
}
};
reader.readAsArrayBuffer(file);
// Allow re-picking the same file next time.
ev.target.value = '';
};
input.click();
});
}
bool Platform_TakeUploadedFile(char *outPath, int cap) {
if (!g_uploadReady || !outPath || cap <= 0) return false;
g_uploadReady = 0;
strncpy(outPath, g_uploadPath, (size_t)cap - 1);
outPath[cap - 1] = '\0';
return true;
}
+11
View File
@@ -132,3 +132,14 @@ void Platform_OfferFileToUser(const char *path) {
/* Desktop: the file is already on disk where the user wanted it. */
(void)path;
}
bool Platform_NeedsFileUpload(void) { return false; }
void Platform_RequestFileUpload(void) {
/* Desktop has a real filesystem and a working file browser; nothing to do. */
}
bool Platform_TakeUploadedFile(char *outPath, int cap) {
(void)outPath; (void)cap;
return false;
}
+165 -45
View File
@@ -390,16 +390,102 @@ void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* textu
// Compute auto-adjusted amplitude floor/ceiling from STFT data
// ===== Grid, labels, selection, playhead =====
// Axis tick spacing. Shared by the grid and the labels so a gridline always
// lands exactly on a labelled value: the grid used to draw a fixed number of
// even divisions of the viewport rectangle, which meant its lines sat at
// arbitrary times/frequencies and slid around under pan and zoom instead of
// marking anything.
// Frequency tick step (Hz) for a visible span.
static int FreqTickSpacing(float freqRange)
{
if (freqRange < 20) return 5;
if (freqRange < 50) return 10;
if (freqRange < 200) return 50;
if (freqRange < 1000) return 100;
if (freqRange < 5000) return 200;
if (freqRange < 20000) return 1000;
if (freqRange < 50000) return 5000;
return 10000;
}
// Coarser step for labels, so text stays readable where ticks are dense.
static int FreqLabelSpacing(int tickSpacing)
{
if (tickSpacing <= 10) return 10;
if (tickSpacing <= 50) return 50;
if (tickSpacing <= 200) return 200;
if (tickSpacing <= 1000) return 1000;
if (tickSpacing <= 5000) return 5000;
return 10000;
}
// Time tick step (seconds) for a visible span: a 1-2-5 ladder, which keeps the
// values human-readable at every zoom (0.1, 0.2, 0.5, 1, 2, 5, ...).
static double TimeTickSpacing(double spanSec, int targetDivisions)
{
if (spanSec <= 0.0 || targetDivisions <= 0) return 1.0;
double raw = spanSec / targetDivisions;
double mag = pow(10.0, floor(log10(raw)));
double n = raw / mag;
double step;
if (n <= 1.0) step = 1.0;
else if (n <= 2.0) step = 2.0;
else if (n <= 5.0) step = 5.0;
else step = 10.0;
return step * mag;
}
// Grid drawn at real axis values rather than even divisions of the rectangle,
// so every line marks a round time/frequency and stays locked to the labels
// while panning and zooming. numCellsX/Y are a *target* density; the actual
// spacing snaps to the same ladders the labels use.
void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color)
{
float cellWidth = bounds.width / numCellsX, cellHeight = bounds.height / numCellsY;
for (int i = 0; i <= numCellsX; i++) {
float x = bounds.x + i * cellWidth;
DrawLineV((Vector2){ x, bounds.y }, (Vector2){ x, bounds.y + bounds.height }, color);
if (bounds.width <= 0 || bounds.height <= 0) return;
// --- Vertical lines: time ---
double duration = app.signal.duration;
double viewSpan = (double)(app.view.end - app.view.start);
if (duration > 0.0 && viewSpan > 0.0) {
double spanSec = viewSpan * duration;
double startSec = (double)app.view.start * duration;
double endSec = startSec + spanSec;
double step = TimeTickSpacing(spanSec, numCellsX);
double first = ceil(startSec / step) * step;
for (double t = first; t <= endSec; t += step) {
float frac = (float)((t - startSec) / spanSec);
float x = bounds.x + frac * bounds.width;
if (x < bounds.x || x > bounds.x + bounds.width) continue;
DrawLineV((Vector2){ x, bounds.y },
(Vector2){ x, bounds.y + bounds.height }, color);
}
}
for (int i = 0; i <= numCellsY; i++) {
float y = bounds.y + bounds.height - i * cellHeight;
DrawLineV((Vector2){ bounds.x, y }, (Vector2){ bounds.x + bounds.width, y }, color);
// --- Horizontal lines: frequency ---
float maxFreq = EffectiveMaxFreqHz();
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
float freqRange = freqMax - freqMin;
if (freqRange > 0.0f) {
// Match the labels' own step so lines and text coincide, but fall back
// to the finer tick step when that would leave too few lines to read as
// a grid.
int tick = FreqTickSpacing(freqRange);
int step = FreqLabelSpacing(tick);
if (freqRange / (float)step < (float)numCellsY * 0.5f) step = tick;
if (step <= 0) step = 1;
int first = ((int)(freqMin / step)) * step;
if (first < freqMin) first += step;
for (int hz = first; hz <= freqMax; hz += step) {
float t = (hz - freqMin) / freqRange;
float y = bounds.y + bounds.height - t * bounds.height;
if (y < bounds.y || y > bounds.y + bounds.height) continue;
DrawLineV((Vector2){ bounds.x, y },
(Vector2){ bounds.x + bounds.width, y }, color);
}
}
}
@@ -412,30 +498,40 @@ void DrawLabels(Rectangle bounds)
// labels decides how many decimals are meaningful. Without this a deep
// zoom prints the same "%.1fs" value in every slot, which reads as a
// frozen axis even though the view is moving.
float viewSpanSec = (app.view.end - app.view.start) * app.signal.duration;
float labelStepSec = viewSpanSec / 10.0f;
int decimals;
if (labelStepSec >= 1.0f) decimals = 1;
else if (labelStepSec >= 0.1f) decimals = 2;
else if (labelStepSec >= 0.01f) decimals = 3;
else decimals = 4;
// Placed on round values from the same 1-2-5 ladder the grid uses, not at
// fixed fractions of the viewport: labels at i/10 of the view read as
// arbitrary numbers ("3.47s") and never line up with a gridline.
double viewSpanSec = (double)(app.view.end - app.view.start) * app.signal.duration;
double startSec = (double)app.view.start * app.signal.duration;
double endSec = startSec + viewSpanSec;
double labelStepSec = TimeTickSpacing(viewSpanSec, 10);
for (int i = 0; i <= 10; i++) {
float t = (float)i / 10;
float timeSec = (app.view.start + t * (app.view.end - app.view.start)) * app.signal.duration;
float x = bounds.x + t * bounds.width;
int decimals;
if (labelStepSec >= 1.0) decimals = 1;
else if (labelStepSec >= 0.1) decimals = 2;
else if (labelStepSec >= 0.01) decimals = 3;
else decimals = 4;
double firstLabel = ceil(startSec / labelStepSec) * labelStepSec;
for (double ts = firstLabel; ts <= endSec && viewSpanSec > 0.0; ts += labelStepSec) {
float x = bounds.x + (float)((ts - startSec) / viewSpanSec) * bounds.width;
char label[32];
// Past a minute the m:ss form stays readable only while the step is
// coarse; zoomed in we need the fractional seconds inside the minute.
if (timeSec >= 60) {
int mins = (int)(timeSec / 60);
float secs = timeSec - mins * 60.0f;
if (labelStepSec >= 1.0f) sprintf(label, "%d:%02d", mins, (int)secs);
if (ts >= 60.0) {
int mins = (int)(ts / 60.0);
double secs = ts - mins * 60.0;
if (labelStepSec >= 1.0) sprintf(label, "%d:%02d", mins, (int)secs);
else sprintf(label, "%d:%0*.*f", mins, decimals + 3, decimals, secs);
} else {
sprintf(label, "%.*fs", decimals, timeSec);
sprintf(label, "%.*fs", decimals, ts);
}
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
// Centre on the tick, then keep the first/last inside the viewport.
float lw = MeasureTextScaled(label, baseFontSize);
float lx = x - lw * 0.5f;
if (lx < bounds.x) lx = bounds.x;
if (lx + lw > bounds.x + bounds.width) lx = bounds.x + bounds.width - lw;
DrawTextScaled(label, lx, bounds.y + bounds.height + 5, baseFontSize, textColor);
}
// Frequency labels adapted to current zoom level. Honors the display crop:
@@ -444,26 +540,11 @@ void DrawLabels(Rectangle bounds)
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
// Choose tick spacing based on zoom range
// Same ladders the grid uses (see FreqTickSpacing), so a gridline and its
// label are always the same value.
float freqRange = freqMax - freqMin;
int tickSpacing;
if (freqRange < 20) tickSpacing = 5;
else if (freqRange < 50) tickSpacing = 10;
else if (freqRange < 200) tickSpacing = 50;
else if (freqRange < 1000) tickSpacing = 100;
else if (freqRange < 5000) tickSpacing = 200;
else if (freqRange < 20000) tickSpacing = 1000;
else if (freqRange < 50000) tickSpacing = 5000;
else tickSpacing = 10000;
// Labels use next coarser spacing so they stay readable
int labelSpacing = tickSpacing;
if (labelSpacing <= 10) labelSpacing = 10;
else if (labelSpacing <= 50) labelSpacing = 50;
else if (labelSpacing <= 200) labelSpacing = 200;
else if (labelSpacing <= 1000) labelSpacing = 1000;
else if (labelSpacing <= 5000) labelSpacing = 5000;
else labelSpacing = 10000;
int tickSpacing = FreqTickSpacing(freqRange);
int labelSpacing = FreqLabelSpacing(tickSpacing);
// Round freqMin up to nearest tick spacing (smallest multiple >= freqMin)
int firstTick = ((int)(freqMin / tickSpacing)) * tickSpacing;
@@ -847,7 +928,27 @@ void DrawCursorReadout(Rectangle bounds)
float dataNyquist = app.signal.sampleRate * 0.5f;
float freqHz = fFrac * displayMax;
// Sample the STFT level at this (time, freq).
// Sample the STFT level around this (time, freq).
//
// Averaged over a neighbourhood in DATA space (+/-CURSOR_AVG_SEGS segments,
// +/-CURSOR_AVG_BINS bins) rather than reading 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. This is a quick reference number, so it is
// deliberately weighted toward a stable reading over a local one.
//
// Wider in frequency than in time: subcarriers are the noisy axis, while
// widening time risks averaging across symbol/frame boundaries. At 12 kHz
// with a 2048-point FFT the window is ~76 Hz x ~300 ms — under a third of
// the narrowest mLink channel, so it stays inside one signal.
//
// Deliberately not a window of *screen* pixels: one pixel covers hundreds
// of segments zoomed out and a fraction of a bin zoomed in, so that would
// mean something different at every zoom level.
//
// The mean is taken over POWER (amplitude^2) and converted afterwards.
// Averaging dB values would be a geometric mean of power, which biases low
// and understates a burst sitting next to quiet bins.
char level[32] = "--";
if (app.stft.numSegments > 0) {
int seg = (int)(tFrac * app.stft.numSegments);
@@ -859,7 +960,26 @@ void DrawCursorReadout(Rectangle bounds)
int bin = (int)(freqHz / binHz + 0.5f);
if (bin < 0) bin = 0;
if (bin >= s->numBins) bin = s->numBins - 1;
sprintf(level, "%.1f dB", AmplitudeToDecibels(s->spectrum[bin].amplitude));
double sumPow = 0.0;
int n = 0;
for (int ds = -CURSOR_AVG_SEGS; ds <= CURSOR_AVG_SEGS; ds++) {
int si = seg + ds;
if (si < 0 || si >= app.stft.numSegments) continue;
const StftSegment* ss = &app.stft.segments[si];
if (!ss->spectrum) continue; // not yet filled in
for (int db = -CURSOR_AVG_BINS; db <= CURSOR_AVG_BINS; db++) {
int bi = bin + db;
if (bi < 0 || bi >= ss->numBins) continue;
float a = ss->spectrum[bi].amplitude;
sumPow += (double)a * (double)a;
n++;
}
}
if (n > 0) {
float rms = (float)sqrt(sumPow / n);
sprintf(level, "%.1f dB", AmplitudeToDecibels(rms));
}
}
}
+58 -6
View File
@@ -561,7 +561,15 @@ void ApplyAutoCrop(void)
// to run at a specific point in the frame, leave action NULL and wire it inline).
// ============================================================================
static void ActionOpenBrowser(void) { app.showFileBrowser = true; ScanDirectory(GetWorkingDirectory()); }
static void ActionOpenBrowser(void)
{
// On the web the file browser would only list this process's in-memory
// filesystem, so "open" has to mean the host's picker instead. One entry
// point keeps O, the File menu and the empty-state button consistent.
if (Platform_NeedsFileUpload()) { Platform_RequestFileUpload(); return; }
app.showFileBrowser = true;
ScanDirectory(GetWorkingDirectory());
}
static void ActionToggleScope(void) { app.showScope = !app.showScope; }
static void ActionToggleAbout(void) { app.showAbout = !app.showAbout; }
static void ActionToggleFullscreen(void){ ToggleFullscreen(); }
@@ -1172,6 +1180,19 @@ int main(int argc, char* argv[])
#endif
// Drag & Drop
// A file chosen through the host's picker (web only — see
// Platform_RequestFileUpload). The bytes are already in the app's
// filesystem by the time this reports one, so it loads like any path.
{
char uploaded[512];
if (Platform_TakeUploadedFile(uploaded, (int)sizeof(uploaded)) &&
LoadWavFile(uploaded, &app.signal)) {
ResetForNewSignal();
LoadMlnlFromWav(uploaded, &app.annotations);
ComputeCollisions();
}
}
if (IsFileDropped()) {
FilePathList dropped = LoadDroppedFiles();
if (dropped.count > 0) {
@@ -2293,11 +2314,42 @@ int main(int argc, char* argv[])
}
}
} else if (!app.showFileBrowser) {
const char* msg1 = "Press 'O' or click 'Open File Browser' to load a WAV";
const char* msg2 = "Or drag & drop a file, or use: ./rspektrum <file.wav>";
float centerX = 350 + (GetScreenWidth() - 380 - 350) / 2;
DrawTextScaled(msg1, centerX, GetScreenHeight() / 2 - 25, 24, LIGHTGRAY);
DrawTextScaled(msg2, centerX, GetScreenHeight() / 2 + 10, 18, GRAY);
// Empty state. Centred by measuring the text against the area right
// of the rail — the old form hardcoded the 320px sidebar's width and
// left-aligned at that offset, so it sat well off-centre once the
// rail shrank to 46px.
Layout eL = ComputeLayout();
float areaX = eL.sidebarWidth;
float areaW = GetScreenWidth() - areaX;
float cy = GetScreenHeight() * 0.5f;
// On the web the app's filesystem is an in-memory sandbox, so the
// file browser can only list what this process wrote. Point the user
// at the upload button instead of a browser that shows nothing.
bool web = Platform_NeedsFileUpload();
const char* msg1 = web ? "Drop a WAV here, or click Open file"
: "Press 'O' or click 'Open File Browser' to load a WAV";
const char* msg2 = web ? "(annotated mLnL captures show their overlay automatically)"
: "Or drag & drop a file, or use: ./rspektrum <file.wav>";
float w1 = MeasureTextScaled(msg1, 24);
float w2 = MeasureTextScaled(msg2, 18);
DrawTextScaled(msg1, areaX + (areaW - w1) * 0.5f, cy - 46, 24, LIGHTGRAY);
DrawTextScaled(msg2, areaX + (areaW - w2) * 0.5f, cy - 12, 18, GRAY);
if (web) {
float bw = 150.0f * eL.scale, bh = 34.0f * eL.scale;
Rectangle btn = { areaX + (areaW - bw) * 0.5f, cy + 22, bw, bh };
bool over = CheckCollisionPointRec(GetMousePosition(), btn);
DrawRectangleRec(btn, over ? (Color){ 60, 90, 120, 255 }
: (Color){ 45, 65, 90, 255 });
DrawRectangleLinesEx(btn, 1, (Color){ 130, 180, 230, 255 });
float tw = MeasureTextScaled("Open file", 16);
DrawTextScaled("Open file", btn.x + (bw - tw) * 0.5f,
btn.y + 8 * eL.scale, 16, WHITE);
if (over && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
Platform_RequestFileUpload();
}
}
// Draw file browser on top (if active)
+7
View File
@@ -88,6 +88,13 @@ typedef struct {
#define MINIMAP_MARGIN 10.0f
#define MINIMAP_HANDLE 12.0f // corner grab square, bottom-left
// Neighbourhood the cursor's dB readout averages over, in STFT cells (not
// screen pixels — see DrawCursorReadout). Wider in frequency than in time:
// OFDM subcarriers are the noisy axis, whereas widening time would average
// across symbol boundaries. Sized to stay well inside one mLink channel.
#define CURSOR_AVG_SEGS 3 // +/- segments (~300 ms at 12 kHz / 2048)
#define CURSOR_AVG_BINS 6 // +/- bins (~76 Hz)
// How many overlapping annotation boxes the cursor-hit stack retains. Deeper
// piles than this are counted but not listed individually (the tooltip says
// "+N more"), which keeps a dense pile-up from covering the spectrogram.