diff --git a/README.md b/README.md index 2f0dcfb..ee3931b 100644 --- a/README.md +++ b/README.md @@ -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 . + +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 diff --git a/build_web.sh b/build_web.sh index 5867c15..e4ae80b 100755 --- a/build_web.sh +++ b/build_web.sh @@ -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" diff --git a/src/platform.h b/src/platform.h index 6266638..76c094f 100644 --- a/src/platform.h +++ b/src/platform.h @@ -1,6 +1,7 @@ #pragma once #include +#include /* ── 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); diff --git a/src/platform_linux.c b/src/platform_linux.c index 39063e9..d743f61 100644 --- a/src/platform_linux.c +++ b/src/platform_linux.c @@ -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; +} diff --git a/src/platform_web.c b/src/platform_web.c index e936c5b..96cc362 100644 --- a/src/platform_web.c +++ b/src/platform_web.c @@ -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 + * 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; +} diff --git a/src/platform_win32.c b/src/platform_win32.c index 54a83f9..a9ea2dd 100644 --- a/src/platform_win32.c +++ b/src/platform_win32.c @@ -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; +} diff --git a/src/render.c b/src/render.c index e7cdec6..0716490 100644 --- a/src/render.c +++ b/src/render.c @@ -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)); + } } } diff --git a/src/spectrogram.c b/src/spectrogram.c index 139c277..03382c6 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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 "; - 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 "; + + 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) diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index 4f282ce..1c347ed 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -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.