fix: web text corruption, blocking-load notice, no-cache dev server

**Most UI text drew with raylib's built-in font, not the loaded one.**
Thirteen call sites used DrawText/MeasureText directly instead of the
project's DrawTextScaled/MeasureTextScaled wrappers, so the cursor and
marker readouts, spectrum panel labels and export toast rendered from
raylib's default bitmap atlas. On desktop that merely looked slightly off;
on web it renders as garbage. The only raw calls left are the two
fallbacks inside the wrappers themselves.

**The rail tooltip held a dangling pointer.** RailButton stored a
TextFormat() result for the deferred draw pass, and raylib documents that
string as expiring once TextFormat has been called a few more times — which
it has by then. It copies into owned storage now.

**Loading a large file in the browser looked like a crash.** The web build
computes its whole STFT in one synchronous pass, with no frame presented in
between, so nothing drawn on the canvas during that window ever reaches the
screen. A notice now goes into the host DOM instead, which the browser
paints independently, and yields once so that paint actually happens before
the work starts. Because that yield unwinds the stack under ASYNCIFY, the
load block is guarded by stftBusy — without it the re-entered main loop
calls ComputeSTFTInit again and frees the STFT the suspended call is still
building.

**Removed EXPORTED_FUNCTIONS from the web link flags.** It replaces
emscripten's default export list rather than extending it, so everything
unnamed is dead-code-eliminated. The upload callback stays reachable via
the EMSCRIPTEN_KEEPALIVE already on its definition.

**Adds serve_web.py**, a dev server that sends no-store. Browsers cache
.wasm hard enough that a plain reload runs a stale module while the page
looks freshly loaded — which makes a rebuild appear to change nothing.
That cost most of a debugging session today: three separate fixes were
tested against a binary that never changed, each returning an identical
fault at an identical address. README now points at it and says why.

Documents the remaining web issue in known_bugs.md: large captures still
corrupt text after loading. Leading theory is ALLOW_MEMORY_GROWTH
reallocating the heap mid-load and invalidating pointers cached across
that moment, which fits the symptom being text specifically.

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 18:38:59 -07:00
parent 40623feadc
commit bc8bcf78b7
11 changed files with 233 additions and 20 deletions
+7 -1
View File
@@ -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 <http://localhost:8080/rspektrum.html>.
**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
+12 -1
View File
@@ -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"
+40
View File
@@ -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.
Executable
+27
View File
@@ -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()
+18
View File
@@ -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);
+7
View File
@@ -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) { }
+60
View File
@@ -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';
});
}
+7
View File
@@ -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) { }
+11 -11
View File
@@ -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);
}
+32 -3
View File
@@ -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),
"<b>Analysing %.1f minutes of audio</b><br>"
"%d FFT frames to compute.<br><br>"
"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.
+12 -4
View File
@@ -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;