diff --git a/README.md b/README.md index ee3931b..3a03af4 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,17 @@ prints the install hint for your platform if anything is missing. ```bash 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 +cd bin/web && python3 ../../serve_web.py ``` Then open . +**Use `serve_web.py`, not `python3 -m http.server`.** Browsers cache `.wasm` +and `.js` hard, and a plain reload will happily run a stale module while the +page looks freshly loaded — so a rebuild appears to change nothing and you end +up debugging a binary you already fixed. `serve_web.py` sends `no-store` on +everything, which makes any reload authoritative. + 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 diff --git a/build_web.sh b/build_web.sh index e4ae80b..50df819 100755 --- a/build_web.sh +++ b/build_web.sh @@ -90,11 +90,22 @@ 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. +# ASYNCIFY_STACK_SIZE: the whole main loop runs under ASYNCIFY, so every yield +# copies the live C stack into this buffer. The 4 KB default is far too small +# for a stack that runs through the render/STFT call chain — overflowing it +# corrupts the heap and surfaces as "memory access out of bounds" at doRewind. # 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" +# +# Do NOT add EXPORTED_FUNCTIONS here. It *replaces* the default export list +# rather than extending it, so everything unnamed gets dead-code-eliminated — +# including the ASYNCIFY rewind machinery this build depends on for its blocking +# main loop. The symptom is a blank canvas after load with "memory access out of +# bounds" at doRewind. The upload callback stays reachable via +# EMSCRIPTEN_KEEPALIVE on its definition instead. +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 ASYNCIFY_STACK_SIZE=1048576" if [ "$BUILD_TYPE" = "debug" ]; then LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1" diff --git a/known_bugs.md b/known_bugs.md index df95ea0..46269b8 100644 --- a/known_bugs.md +++ b/known_bugs.md @@ -6,6 +6,46 @@ need to decide. --- +## Large WAVs in the browser: text corruption and a long hang + +**Status:** open. Reproduces on the web build only; desktop is unaffected. + +Loading a large capture through the web UI (drag-drop or the Open file button) +produces garbled glyphs in menu titles, tooltips and axis labels, and the page +stops responding for a long stretch. Small files load and render correctly, and +the corruption appears *after* the load rather than at startup — so it is +triggered by the size of the work, not by the build being broken. + +The hang is understood and partly by design: the web build computes its entire +STFT in one synchronous pass (see the `__EMSCRIPTEN__` branch in the main loop), +because the desktop's incremental fill depends on idle main-loop frames the +browser doesn't hand back the same way. A DOM overlay now warns before it +starts, but the page genuinely is frozen until it finishes. A real fix means +chunking that work across frames or moving it to a Web Worker. + +The **corruption** is the unexplained part. The leading theory is heap growth: +the build links with `ALLOW_MEMORY_GROWTH=1`, and a large file forces the wasm +heap to grow mid-load. Growth reallocates the backing `ArrayBuffer`, which +invalidates every cached view and raw pointer held across that moment — so +anything retaining a `char*` or a texture-side pointer from before the growth +would read garbage afterwards. Font glyph data reached through raylib's atlas is +a plausible casualty, which fits the symptom being *text* specifically. + +Worth checking first: +- Whether `INITIAL_MEMORY` large enough to avoid growth entirely makes it go + away. That would confirm the theory cheaply, at the cost of a bigger initial + allocation. +- Whether the font atlas survives a deliberate `sbrk`-forced growth. +- SAFE_HEAP + ASSERTIONS=2 (`build_web.sh` debug path) to catch the first bad + access rather than the downstream symptom. + +**Testing note:** always serve the web build with `serve_web.py`, never +`python3 -m http.server`. Browsers cache `.wasm` hard enough that a plain reload +runs a stale module, which makes rebuilds look like no-ops and has already +burned significant time chasing bugs that were fixed. + +--- + ## Playhead vs. a selection edited mid-playback **Status:** partially addressed; underlying semantics still undefined. diff --git a/serve_web.py b/serve_web.py new file mode 100755 index 0000000..3c76c58 --- /dev/null +++ b/serve_web.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Dev server for bin/web with caching disabled. + +The browser aggressively caches .wasm/.js, and a plain reload (Ctrl+R) will +happily serve a stale module while the page *looks* freshly loaded — which makes +"did my rebuild take effect?" impossible to answer and sends you chasing bugs +that were already fixed. Everything here is served no-store so a rebuild is +always what you get, regardless of how the page is reloaded. +""" +import http.server, socketserver, sys + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 + +class NoCache(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + super().end_headers() + + def log_message(self, fmt, *args): + pass # quiet; the build script is the interesting output + +socketserver.TCPServer.allow_reuse_address = True +with socketserver.TCPServer(("", PORT), NoCache) as httpd: + print(f"serving bin/web on http://localhost:{PORT}/rspektrum.html (no-store)") + httpd.serve_forever() diff --git a/src/platform.h b/src/platform.h index 76c094f..5658ead 100644 --- a/src/platform.h +++ b/src/platform.h @@ -137,3 +137,21 @@ void Platform_RequestFileUpload(void); * @return true exactly once per uploaded file; false when nothing is pending. */ bool Platform_TakeUploadedFile(char *outPath, int cap); + +/** + * Show a blocking-work notice outside the app's own render surface. + * + * The web build computes its STFT in a single pass with no frame presented in + * between, so anything drawn on the canvas during that window never reaches the + * screen and the page simply appears to hang. This puts a message in the host + * document instead, which the browser paints independently, and yields long + * enough for that paint to happen before the caller starts working. + * + * No-op on desktop, which presents a progress panel normally. + * + * @param message Text to display, or NULL to dismiss. + */ +void Platform_ShowBlockingNotice(const char *message); + +/** Dismiss whatever Platform_ShowBlockingNotice put up. */ +void Platform_HideBlockingNotice(void); diff --git a/src/platform_linux.c b/src/platform_linux.c index d743f61..a1b468a 100644 --- a/src/platform_linux.c +++ b/src/platform_linux.c @@ -116,3 +116,10 @@ bool Platform_TakeUploadedFile(char *outPath, int cap) { (void)outPath; (void)cap; return false; } + +void Platform_ShowBlockingNotice(const char *message) { + /* Desktop draws its own progress panel; nothing to do here. */ + (void)message; +} + +void Platform_HideBlockingNotice(void) { } diff --git a/src/platform_web.c b/src/platform_web.c index 96cc362..cf3f551 100644 --- a/src/platform_web.c +++ b/src/platform_web.c @@ -169,3 +169,63 @@ bool Platform_TakeUploadedFile(char *outPath, int cap) { outPath[cap - 1] = '\0'; return true; } + +/* + * Blocking-work notice. + * + * Drawn into the host document rather than onto the raylib canvas: the STFT + * runs to completion inside one main-loop iteration, so nothing presented on + * the canvas during that window is ever painted, and the page looks frozen. + * The DOM is painted by the browser on its own schedule, so an overlay put up + * here survives the wasm thread being busy. + * + * EM_ASM only *queues* the mutation — the browser cannot paint until control + * returns to its event loop, so we yield briefly afterwards (ASYNCIFY is + * already enabled for the blocking main loop). Without that yield the overlay + * would appear only after the work it was meant to announce had finished. + */ +void Platform_ShowBlockingNotice(const char *message) { + if (!message) { Platform_HideBlockingNotice(); return; } + EM_ASM({ + var msg = UTF8ToString($0); + var el = document.getElementById('rspektrum-blocking'); + if (!el) { + el = document.createElement('div'); + el.id = 'rspektrum-blocking'; + el.style.cssText = + 'position:fixed;inset:0;z-index:9999;display:flex;' + + 'align-items:center;justify-content:center;' + + 'background:rgba(12,12,16,0.88);color:#dfe4ee;' + + 'font:14px system-ui,sans-serif;text-align:center;' + + 'pointer-events:all;'; + var box = document.createElement('div'); + box.id = 'rspektrum-blocking-box'; + box.style.cssText = + 'padding:22px 28px;border:1px solid #6a6a80;border-radius:6px;' + + 'background:#1b1b22;max-width:32em;line-height:1.5;'; + el.appendChild(box); + document.body.appendChild(el); + } + document.getElementById('rspektrum-blocking-box').innerHTML = msg; + el.style.display = 'flex'; + }, message); + + /* + * Yield so the browser can actually paint the overlay before the caller + * blocks. EM_ASM only queues the DOM mutation; nothing is drawn until + * control returns to the event loop. + * + * Under ASYNCIFY this unwinds the C stack and resumes later, so the main + * loop CAN re-enter while this is suspended. The load block guards against + * that with stftBusy — without it, the second pass re-runs ComputeSTFTInit + * and frees the STFT the suspended call is still building. + */ + emscripten_sleep(32); +} + +void Platform_HideBlockingNotice(void) { + EM_ASM({ + var el = document.getElementById('rspektrum-blocking'); + if (el) el.style.display = 'none'; + }); +} diff --git a/src/platform_win32.c b/src/platform_win32.c index a9ea2dd..2487e5b 100644 --- a/src/platform_win32.c +++ b/src/platform_win32.c @@ -143,3 +143,10 @@ bool Platform_TakeUploadedFile(char *outPath, int cap) { (void)outPath; (void)cap; return false; } + +void Platform_ShowBlockingNotice(const char *message) { + /* Desktop draws its own progress panel; nothing to do here. */ + (void)message; +} + +void Platform_HideBlockingNotice(void) { } diff --git a/src/render.c b/src/render.c index 0716490..a32b095 100644 --- a/src/render.c +++ b/src/render.c @@ -624,7 +624,7 @@ static void DrawStatPanel(Rectangle bounds, Rectangle sel) int fontSize = 10; int maxTextW = 0; for (int i = 0; i < lineCount; i++) { - int w = MeasureText(lines[i], fontSize); + int w = MeasureTextScaled(lines[i], fontSize); if (w > maxTextW) maxTextW = w; } int boxW = maxTextW + 20; @@ -657,7 +657,7 @@ static void DrawStatPanel(Rectangle bounds, Rectangle sel) DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 }); DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f)); for (int i = 0; i < lineCount; i++) { - DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY); + DrawTextScaled(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY); } } @@ -986,7 +986,7 @@ void DrawCursorReadout(Rectangle bounds) char text[80]; sprintf(text, "%.3fs %.0f Hz %s", timeSec, freqHz, level); int fontSize = 10; - int tw = MeasureText(text, fontSize); + int tw = MeasureTextScaled(text, fontSize); int boxW = tw + 12, boxH = fontSize + 8; // Offset up-right of the cursor; flip to keep it inside the viewport. @@ -996,7 +996,7 @@ void DrawCursorReadout(Rectangle bounds) DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 200 }); DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(SKYBLUE, 0.6f)); - DrawText(text, (int)bx + 6, (int)by + 4, fontSize, (Color){ 180, 220, 255, 255 }); + DrawTextScaled(text, (int)bx + 6, (int)by + 4, fontSize, (Color){ 180, 220, 255, 255 }); } // ============================================================================ @@ -1022,7 +1022,7 @@ static void DrawMarkerCross(Vector2 p, Color c, const char* tag) DrawLine((int)p.x - 7, (int)p.y, (int)p.x + 7, (int)p.y, c); DrawLine((int)p.x, (int)p.y - 7, (int)p.x, (int)p.y + 7, c); DrawCircleLines((int)p.x, (int)p.y, 4, c); - DrawText(tag, (int)p.x + 7, (int)p.y - 14, 10, c); + DrawTextScaled(tag, (int)p.x + 7, (int)p.y - 14, 10, c); } // Two-point ruler overlay: crosshairs at A and B, a connecting line, and a @@ -1064,7 +1064,7 @@ void DrawMarkers(Rectangle bounds) int fontSize = 10; int maxW = 0; - for (int i = 0; i < n; i++) { int w = MeasureText(lines[i], fontSize); if (w > maxW) maxW = w; } + for (int i = 0; i < n; i++) { int w = MeasureTextScaled(lines[i], fontSize); if (w > maxW) maxW = w; } int boxW = maxW + 16, boxH = n * 14 + 10; // Anchor near B (or A if B is off-view), clamped inside bounds. @@ -1078,7 +1078,7 @@ void DrawMarkers(Rectangle bounds) DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 210 }); DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(ORANGE, 0.7f)); for (int i = 0; i < n; i++) - DrawText(lines[i], (int)bx + 8, (int)by + 6 + i * 14, fontSize, (Color){ 255, 210, 160, 255 }); + DrawTextScaled(lines[i], (int)bx + 8, (int)by + 6 + i * 14, fontSize, (Color){ 255, 210, 160, 255 }); } // ============================================================================ @@ -1146,7 +1146,7 @@ void DrawSpectrumPanel(Rectangle bounds) Rectangle plot = { panel.x + padL, panel.y + padT, panel.width - padL - padR, panel.height - padT - padB }; - DrawText(hasSel ? "Spectrum (selection)" : "Spectrum (view)", + DrawTextScaled(hasSel ? "Spectrum (selection)" : "Spectrum (view)", (int)(panel.x + 6 * scale), (int)(panel.y + 4 * scale), 10, (Color){ 180, 220, 255, 255 }); float freqSpan = freqHigh - freqLow; @@ -1179,12 +1179,12 @@ void DrawSpectrumPanel(Rectangle bounds) // Axis labels: frequency at the ends, peak dB at top-left of the plot. char lbl[32]; sprintf(lbl, "%.0f Hz", freqLow); - DrawText(lbl, (int)plot.x, (int)(panel.y + panel.height - 12 * scale), 9, GRAY); + DrawTextScaled(lbl, (int)plot.x, (int)(panel.y + panel.height - 12 * scale), 9, GRAY); sprintf(lbl, "%.0f Hz", freqHigh); - DrawText(lbl, (int)(plot.x + plot.width - MeasureText(lbl, 9)), + DrawTextScaled(lbl, (int)(plot.x + plot.width - MeasureTextScaled(lbl, 9)), (int)(panel.y + panel.height - 12 * scale), 9, GRAY); sprintf(lbl, "pk %.0fHz %.0fdB", peakFr, maxDb); - DrawText(lbl, (int)(plot.x + 2), (int)(plot.y + 1), 9, Fade(YELLOW, 0.85f)); + DrawTextScaled(lbl, (int)(plot.x + 2), (int)(plot.y + 1), 9, Fade(YELLOW, 0.85f)); free(power); } diff --git a/src/spectrogram.c b/src/spectrogram.c index 03382c6..4e57752 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -1537,7 +1537,17 @@ int main(int argc, char* argv[]) } // Processing (incremental across frames) - if (app.loaded && !app.stftComputed) { + // + // stftBusy guards re-entry. On web the notice below yields to the + // browser (ASYNCIFY unwinds the stack and resumes later), so the main + // loop can run again while this block is suspended — and the guard + // condition is still true at that point, because stftComputed isn't set + // until the compute finishes. Without this, the second pass calls + // ComputeSTFTInit again and frees the STFT the suspended call is in the + // middle of building. + static bool stftBusy = false; + if (app.loaded && !app.stftComputed && !stftBusy) { + stftBusy = true; #ifdef __EMSCRIPTEN__ // Web build: there are no worker threads, and the desktop path's // overview-then-deferred-high-res fill depends on many main-loop @@ -1546,6 +1556,23 @@ int main(int argc, char* argv[]) // the spectrogram is completely ready as soon as the file loads. ComputeSTFTInit(&app.signal, &app.stft, app.fftSize); app.skipFactor = 1; // full resolution, no overview stride + + // Warn before blocking. The compute below runs to completion inside + // this one loop iteration, so the canvas cannot update and the page + // would otherwise look hung — on a multi-hour capture, for minutes. + // The notice lives in the DOM (see Platform_ShowBlockingNotice), + // which the browser paints even while wasm is busy. + { + char notice[320]; + float mins = app.signal.duration / 60.0f; + snprintf(notice, sizeof(notice), + "Analysing %.1f minutes of audio
" + "%d FFT frames to compute.

" + "The page will not respond until this finishes.", + mins, app.stft.numSegments); + Platform_ShowBlockingNotice(notice); + } + ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0); // computes every segment AutoScaleAmplitude(&app.stft); GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); @@ -1559,6 +1586,7 @@ int main(int argc, char* argv[]) app.loadingPhase = 0; SaveToCache(); if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; } + Platform_HideBlockingNotice(); #else if (app.loadingPhase == 0) { // Initialize STFT once @@ -1628,6 +1656,7 @@ int main(int argc, char* argv[]) if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; } } #endif // __EMSCRIPTEN__ + stftBusy = false; } #ifndef __EMSCRIPTEN__ @@ -2371,14 +2400,14 @@ int main(int argc, char* argv[]) // Export message notification if (app.exportMessage[0] != '\0') { - int msgW = MeasureText(app.exportMessage, 20); + int msgW = MeasureTextScaled(app.exportMessage, 20); int boxW = msgW + 40; int boxH = 36; int boxX = GetScreenWidth() / 2 - boxW / 2; int boxY = 15; DrawRectangle(boxX, boxY, boxW, boxH, (Color){ 30, 30, 30, 220 }); DrawRectangleLines(boxX, boxY, boxW, boxH, CYAN); - DrawText(app.exportMessage, boxX + (boxW - msgW) / 2, boxY + 10, 20, WHITE); + DrawTextScaled(app.exportMessage, boxX + (boxW - msgW) / 2, boxY + 10, 20, WHITE); } // Hold the export message for a few seconds, then clear it. diff --git a/src/ui.c b/src/ui.c index a532299..8e395fe 100644 --- a/src/ui.c +++ b/src/ui.c @@ -754,7 +754,14 @@ static float g_railPopoutY = 0.0f; // top of the button that opened it // Deferred rail tooltip: set by RailButton during the early sidebar pass and // drawn in the late pass so it floats above the axis labels and spectrogram. -static const char* g_railTipText = NULL; +// +// COPIED, not borrowed. Callers pass TextFormat() results, which point into +// raylib's small ring of static buffers and are explicitly documented to expire +// once TextFormat has been called a few more times. Holding that pointer across +// passes meant the tooltip rendered whatever text had since overwritten the +// slot — visible as garbled glyphs. +static char g_railTipText[192]; +static bool g_railTipSet = false; static float g_railTipX = 0.0f, g_railTipY = 0.0f; typedef void (*IconFn)(Rectangle, Color); @@ -782,7 +789,8 @@ static bool RailButton(Rectangle r, IconFn icon, bool active, bool enabled, // needs the rail width), so anything drawn here is painted over by the // frequency labels and spectrogram. DrawSidebarPopouts emits it later. if (over && tip) { - g_railTipText = tip; + snprintf(g_railTipText, sizeof(g_railTipText), "%s", tip); + g_railTipSet = true; g_railTipY = r.y + r.height * 0.5f; g_railTipX = r.x + r.width; } @@ -845,7 +853,7 @@ bool SidebarCapturesMouse(void) void DrawSidebar(void) { - g_railTipText = NULL; + g_railTipSet = false; HandleSidebarSplitter(); if (app.sidebarCollapsed) { g_railPopout = RAIL_POP_NONE; return; } @@ -1231,7 +1239,7 @@ void DrawSidebarPopouts(void) if (app.sidebarCollapsed) return; // Hovered icon's tooltip, deferred from the early pass (see RailButton). - if (g_railTipText) { + if (g_railTipSet) { float sc = GetUIScale(); float tw = MeasureTextScaled(g_railTipText, 11) + 12 * sc; float th = 18 * sc;