**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
The minimum visible time window was a flat 2% of the file, but view.start
and view.end are normalized to the whole signal — so the achievable time
resolution scaled with file length. A 30-minute recording could never show
less than a 36-second span, while a 30-second one reached 0.6 s. Long
captures were effectively unreadable at the sample level no matter how far
you scrolled.
Derive the floor from the STFT hop instead (MinTimeViewWidth): segments sit
fftSize/HOP_RATIO samples apart, so the real limit is the point where only
a handful of segments span the viewport and further zoom would interpolate
rather than reveal. The floor is now a constant ~43 ms at 48 kHz/1024
regardless of duration — an 844x improvement on a 30-minute file, and it
tightens further with a smaller FFT. Guards cover the unloaded (sampleRate
0) and shorter-than-the-floor cases.
Zooming is also no longer forced to move both axes together. The bare wheel
keeps the existing coupled behaviour; Shift+wheel is time-only and
Ctrl+wheel frequency-only, so a long capture can be stretched along time
without collapsing the frequency range to match.
Time-axis labels now pick their precision from the span between adjacent
ticks (1 to 4 decimals, and m:ss.sss past a minute). At the spans this
change makes reachable the old fixed "%.1fs" printed the same value in
every slot, which read as a frozen axis.
Adds a `wheel X Y N [mod]` action to shot_input.sh for exercising zoom
headlessly, and known_bugs.md for behaviour that is unspecified rather
than broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN