fix: check STFT allocations; reject files too large for the web build

Every malloc/calloc in stft.c was written through unchecked. On desktop
that never bites — Linux overcommits and swaps — but WebAssembly has a
hard 32-bit address-space ceiling, so on a long capture the allocations
genuinely fail and the code wrote through NULL into low memory. That is
why it surfaced as corrupted font glyphs rather than a crash, and only
after loading a large file.

The apparent hang had the same root: ComputeSegment returned void, so a
failed allocation was invisible and the loop ground through all remaining
segments failing identically. The cursor readout showing "-" for the level
was the tell — the segments were there but their spectra were NULL.

ComputeSegment now returns a bool, ComputeSTFTIncremental stops at the
first failure instead of churning, and both halves of a segment's spectra
are freed together (reassignment reads them in lockstep, so half a segment
is worse than none). ComputeSTFTInit, CopySTFT and the scratch buffers are
checked too.

Segments left NULL are already skipped by every consumer — that is how the
progressive fill renders partial results — so a file that nearly fits
degrades to a truncated spectrogram rather than corrupting.

The web load path treats exhaustion as fatal and says so: a multi-hour
capture needs ~11 GB of spectra (478k segments x 1025 bins x two spectra x
12 bytes for the 5.7-hour case) against a 4 GB ceiling browsers cap below
in practice, so there is nothing useful to fall back to. Better to explain
that than present a spectrogram full of holes.

Corrects the known_bugs.md entry, which blamed ALLOW_MEMORY_GROWTH
invalidating cached pointers. That theory fit the symptom but was wrong;
the allocations were simply failing. Also notes what making large files
actually work would take — 16-bit magnitudes, dropping the derivative
spectrum when synchrosqueezing is off, or streaming segments.

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 20:32:17 -07:00
parent bc8bcf78b7
commit 438150080c
3 changed files with 102 additions and 33 deletions
+23 -27
View File
@@ -6,38 +6,34 @@ need to decide.
---
## Large WAVs in the browser: text corruption and a long hang
## Large WAVs in the browser: too large to analyse
**Status:** open. Reproduces on the web build only; desktop is unaffected.
**Status:** diagnosed and handled. The file is rejected with a message instead
of corrupting or hanging; it still cannot be opened in the browser.
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.
A multi-hour capture needs far more memory than a 32-bit WebAssembly page can
address. v23 (5.7 h at 12 kHz) works out to ~478k STFT segments x 1025 bins x
two spectra x 12 bytes — roughly **11 GB of spectra alone**, against a hard
wasm32 ceiling of 4 GB that browsers cap below in practice.
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 old symptom was not a memory-growth bug, as first assumed. The per-segment
`malloc`s in `ComputeSegment` were simply failing and their results written
through unchecked. On desktop that never bites (Linux overcommits and swaps),
but in wasm the failure is real, so the code wrote through NULL into low memory
— which is why it surfaced as *corrupted font glyphs* rather than a crash. The
apparent "hang" was the loop grinding through every remaining segment, each
failing the same way, since `ComputeSegment` returned `void` and nothing noticed.
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.
Every allocation in `stft.c` is now checked. `ComputeSegment` reports failure,
`ComputeSTFTIncremental` stops at the first one rather than churning, and the
web load path frees the partial result and explains that the file is too large.
A file that *nearly* fits should degrade to a truncated spectrogram — NULL
segments are already skipped everywhere, which is how the progressive fill draws
partial results — though that path is reasoned rather than tested.
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.
Making large files actually work in the browser needs a different data layout:
storing magnitudes as 16-bit, dropping the derivative spectrum unless
synchrosqueezing is on, or streaming segments rather than holding them all.
**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
+32 -1
View File
@@ -1557,6 +1557,21 @@ int main(int argc, char* argv[])
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1; // full resolution, no overview stride
// Out of address space before we even start. wasm32 caps the heap
// well below what a multi-hour capture needs (v23 alone wants ~11 GB
// of spectra), so say so instead of rendering an empty spectrogram.
if (app.stft.numSegments == 0) {
Platform_ShowBlockingNotice(
"<b>File too large for the browser build</b><br>"
"This capture needs more memory than a 32-bit WebAssembly "
"page can address.<br><br>"
"Use the desktop build, or a shorter excerpt.");
app.loaded = false;
app.stftComputed = false;
stftBusy = false;
continue;
}
// 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.
@@ -1573,7 +1588,23 @@ int main(int argc, char* argv[])
Platform_ShowBlockingNotice(notice);
}
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0); // computes every segment
bool stftOk = ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
if (!stftOk) {
// Ran out of address space partway through. wasm32 tops out
// around 4 GB and a multi-hour capture needs far more than that
// in spectra alone, so there is nothing to fall back to — say
// so rather than presenting a spectrogram full of holes.
FreeSTFT(&app.stft);
Platform_ShowBlockingNotice(
"<b>File too large for the browser build</b><br>"
"This capture needs more memory than a 32-bit WebAssembly "
"page can address.<br><br>"
"Use the desktop build, or load a shorter excerpt.");
app.loaded = false;
app.stftComputed = false;
stftBusy = false;
continue;
}
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.currentSTFTSegment = app.stft.numSegments;
+47 -5
View File
@@ -29,6 +29,7 @@ static void CopySTFT(StftResult* dst, const StftResult* src)
dst->totalSamples = src->totalSamples;
dst->useHannWindow = src->useHannWindow;
dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment));
if (dst->segments == NULL) { dst->numSegments = 0; return; }
for (int i = 0; i < src->numSegments; i++) {
const StftSegment* s = &src->segments[i];
StftSegment* d = &dst->segments[i];
@@ -156,6 +157,14 @@ static SegScratch AllocSegScratch(int fftSize)
return sc;
}
// True when every scratch buffer was allocated. These are only a few KB, so
// this realistically only fails when the heap is already exhausted — but the
// caller must not run a pass with a NULL buffer either way.
static bool SegScratchOk(const SegScratch* sc)
{
return sc->windowed && sc->derivWindowed && sc->fftIn && sc->fftOut;
}
static void FreeSegScratch(SegScratch* sc)
{
free(sc->windowed);
@@ -166,7 +175,7 @@ static void FreeSegScratch(SegScratch* sc)
// Compute one STFT segment (normal V_f + derivative-window V_fd spectra) into
// result->segments[seg]. Caller ensures the segment isn't already computed.
static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize, int seg, SegScratch* sc)
static bool ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize, int seg, SegScratch* sc)
{
int hopSize = fftSize / HOP_RATIO;
int numBins = fftSize / 2 + 1;
@@ -195,7 +204,12 @@ static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize,
// Normal STFT (V_f)
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->windowed[i] + 0.0f * I;
FFT(sc->fftIn, sc->fftOut, fftSize, false);
// Out of memory: leave the segment NULL. Every consumer already skips
// NULL segments (that is how the progressive fill renders partial results),
// so a truncated spectrogram degrades gracefully — whereas writing through
// the NULL scribbles over low memory and shows up later as corrupted glyphs.
result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
if (result->segments[seg].spectrum == NULL) return false;
for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(sc->fftOut[bin]) / fftSize : 2.0f * cabsf(sc->fftOut[bin]) / fftSize;
@@ -206,11 +220,19 @@ static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize,
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->derivWindowed[i] + 0.0f * I;
FFT(sc->fftIn, sc->fftOut, fftSize, false);
result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
if (result->segments[seg].derivativeSpectrum == NULL) {
// Reassignment reads both buffers in lockstep, so a segment with only
// half of them is worse than none.
free(result->segments[seg].spectrum);
result->segments[seg].spectrum = NULL;
return false;
}
for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(sc->fftOut[bin]) / fftSize;
result->segments[seg].derivativeSpectrum[bin].phase = cargf(sc->fftOut[bin]);
}
return true;
}
// ===== Background high-res computation =====
@@ -220,9 +242,10 @@ int ComputeNextHighResChunk(AudioSignal* signal, StftResult* result,
int fftSize, int startSeg, int endSeg)
{
SegScratch sc = AllocSegScratch(fftSize);
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return endSeg; }
for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) {
if (result->segments[seg].spectrum != NULL) continue; // already computed
ComputeSegment(signal, result, fftSize, seg, &sc);
if (!ComputeSegment(signal, result, fftSize, seg, &sc)) break; // out of memory
}
FreeSegScratch(&sc);
@@ -238,8 +261,17 @@ void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
if (numSegments <= 0) numSegments = 1;
result->numSegments = numSegments;
result->segments = (StftSegment*)calloc(numSegments, sizeof(StftSegment));
if (result->segments == NULL) {
// wasm has a hard address-space ceiling, so this genuinely fails on a
// long capture where desktop would just swap. Writing through the NULL
// corrupts low memory and surfaces later as garbled glyphs rather than
// a crash, so report it and leave the result empty.
TraceLog(LOG_ERROR, "STFT: out of memory for %d segments", numSegments);
result->numSegments = 0;
return;
}
result->numSegments = numSegments;
result->sampleRate = signal->sampleRate;
result->totalSamples = signal->numSamples;
result->useHannWindow = true;
@@ -248,13 +280,23 @@ void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment)
{
SegScratch sc = AllocSegScratch(fftSize);
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return false; }
bool ok = true;
for (int seg = startSegment; seg < result->numSegments; seg++) {
if (seg % app.skipFactor != 0) continue; // overview stride
if (result->segments[seg].spectrum != NULL) continue; // already computed
ComputeSegment(signal, result, fftSize, seg, &sc);
if (!ComputeSegment(signal, result, fftSize, seg, &sc)) {
// Heap exhausted. Stop rather than grinding through every remaining
// segment failing the same way, and tell the caller so it can say
// something useful instead of showing an empty spectrogram.
TraceLog(LOG_ERROR, "STFT: out of memory at segment %d of %d",
seg, result->numSegments);
ok = false;
break;
}
}
FreeSegScratch(&sc);
return true;
return ok;
}
void FreeSTFT(StftResult* result)