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:
+23
-27
@@ -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)
|
A multi-hour capture needs far more memory than a 32-bit WebAssembly page can
|
||||||
produces garbled glyphs in menu titles, tooltips and axis labels, and the page
|
address. v23 (5.7 h at 12 kHz) works out to ~478k STFT segments x 1025 bins x
|
||||||
stops responding for a long stretch. Small files load and render correctly, and
|
two spectra x 12 bytes — roughly **11 GB of spectra alone**, against a hard
|
||||||
the corruption appears *after* the load rather than at startup — so it is
|
wasm32 ceiling of 4 GB that browsers cap below in practice.
|
||||||
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
|
The old symptom was not a memory-growth bug, as first assumed. The per-segment
|
||||||
STFT in one synchronous pass (see the `__EMSCRIPTEN__` branch in the main loop),
|
`malloc`s in `ComputeSegment` were simply failing and their results written
|
||||||
because the desktop's incremental fill depends on idle main-loop frames the
|
through unchecked. On desktop that never bites (Linux overcommits and swaps),
|
||||||
browser doesn't hand back the same way. A DOM overlay now warns before it
|
but in wasm the failure is real, so the code wrote through NULL into low memory
|
||||||
starts, but the page genuinely is frozen until it finishes. A real fix means
|
— which is why it surfaced as *corrupted font glyphs* rather than a crash. The
|
||||||
chunking that work across frames or moving it to a Web Worker.
|
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:
|
Every allocation in `stft.c` is now checked. `ComputeSegment` reports failure,
|
||||||
the build links with `ALLOW_MEMORY_GROWTH=1`, and a large file forces the wasm
|
`ComputeSTFTIncremental` stops at the first one rather than churning, and the
|
||||||
heap to grow mid-load. Growth reallocates the backing `ArrayBuffer`, which
|
web load path frees the partial result and explains that the file is too large.
|
||||||
invalidates every cached view and raw pointer held across that moment — so
|
A file that *nearly* fits should degrade to a truncated spectrogram — NULL
|
||||||
anything retaining a `char*` or a texture-side pointer from before the growth
|
segments are already skipped everywhere, which is how the progressive fill draws
|
||||||
would read garbage afterwards. Font glyph data reached through raylib's atlas is
|
partial results — though that path is reasoned rather than tested.
|
||||||
a plausible casualty, which fits the symptom being *text* specifically.
|
|
||||||
|
|
||||||
Worth checking first:
|
Making large files actually work in the browser needs a different data layout:
|
||||||
- Whether `INITIAL_MEMORY` large enough to avoid growth entirely makes it go
|
storing magnitudes as 16-bit, dropping the derivative spectrum unless
|
||||||
away. That would confirm the theory cheaply, at the cost of a bigger initial
|
synchrosqueezing is on, or streaming segments rather than holding them all.
|
||||||
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
|
**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
|
`python3 -m http.server`. Browsers cache `.wasm` hard enough that a plain reload
|
||||||
|
|||||||
+32
-1
@@ -1557,6 +1557,21 @@ int main(int argc, char* argv[])
|
|||||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||||
app.skipFactor = 1; // full resolution, no overview stride
|
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
|
// Warn before blocking. The compute below runs to completion inside
|
||||||
// this one loop iteration, so the canvas cannot update and the page
|
// this one loop iteration, so the canvas cannot update and the page
|
||||||
// would otherwise look hung — on a multi-hour capture, for minutes.
|
// would otherwise look hung — on a multi-hour capture, for minutes.
|
||||||
@@ -1573,7 +1588,23 @@ int main(int argc, char* argv[])
|
|||||||
Platform_ShowBlockingNotice(notice);
|
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);
|
AutoScaleAmplitude(&app.stft);
|
||||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||||
app.currentSTFTSegment = app.stft.numSegments;
|
app.currentSTFTSegment = app.stft.numSegments;
|
||||||
|
|||||||
+47
-5
@@ -29,6 +29,7 @@ static void CopySTFT(StftResult* dst, const StftResult* src)
|
|||||||
dst->totalSamples = src->totalSamples;
|
dst->totalSamples = src->totalSamples;
|
||||||
dst->useHannWindow = src->useHannWindow;
|
dst->useHannWindow = src->useHannWindow;
|
||||||
dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment));
|
dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment));
|
||||||
|
if (dst->segments == NULL) { dst->numSegments = 0; return; }
|
||||||
for (int i = 0; i < src->numSegments; i++) {
|
for (int i = 0; i < src->numSegments; i++) {
|
||||||
const StftSegment* s = &src->segments[i];
|
const StftSegment* s = &src->segments[i];
|
||||||
StftSegment* d = &dst->segments[i];
|
StftSegment* d = &dst->segments[i];
|
||||||
@@ -156,6 +157,14 @@ static SegScratch AllocSegScratch(int fftSize)
|
|||||||
return sc;
|
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)
|
static void FreeSegScratch(SegScratch* sc)
|
||||||
{
|
{
|
||||||
free(sc->windowed);
|
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
|
// Compute one STFT segment (normal V_f + derivative-window V_fd spectra) into
|
||||||
// result->segments[seg]. Caller ensures the segment isn't already computed.
|
// 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 hopSize = fftSize / HOP_RATIO;
|
||||||
int numBins = fftSize / 2 + 1;
|
int numBins = fftSize / 2 + 1;
|
||||||
@@ -195,7 +204,12 @@ static void ComputeSegment(AudioSignal* signal, StftResult* result, int fftSize,
|
|||||||
// Normal STFT (V_f)
|
// Normal STFT (V_f)
|
||||||
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->windowed[i] + 0.0f * I;
|
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->windowed[i] + 0.0f * I;
|
||||||
FFT(sc->fftIn, sc->fftOut, fftSize, false);
|
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));
|
result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
|
||||||
|
if (result->segments[seg].spectrum == NULL) return false;
|
||||||
for (int bin = 0; bin < numBins; bin++) {
|
for (int bin = 0; bin < numBins; bin++) {
|
||||||
result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
|
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;
|
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;
|
for (int i = 0; i < fftSize; i++) sc->fftIn[i] = sc->derivWindowed[i] + 0.0f * I;
|
||||||
FFT(sc->fftIn, sc->fftOut, fftSize, false);
|
FFT(sc->fftIn, sc->fftOut, fftSize, false);
|
||||||
result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
|
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++) {
|
for (int bin = 0; bin < numBins; bin++) {
|
||||||
result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
|
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].amplitude = cabsf(sc->fftOut[bin]) / fftSize;
|
||||||
result->segments[seg].derivativeSpectrum[bin].phase = cargf(sc->fftOut[bin]);
|
result->segments[seg].derivativeSpectrum[bin].phase = cargf(sc->fftOut[bin]);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Background high-res computation =====
|
// ===== Background high-res computation =====
|
||||||
@@ -220,9 +242,10 @@ int ComputeNextHighResChunk(AudioSignal* signal, StftResult* result,
|
|||||||
int fftSize, int startSeg, int endSeg)
|
int fftSize, int startSeg, int endSeg)
|
||||||
{
|
{
|
||||||
SegScratch sc = AllocSegScratch(fftSize);
|
SegScratch sc = AllocSegScratch(fftSize);
|
||||||
|
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return endSeg; }
|
||||||
for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) {
|
for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) {
|
||||||
if (result->segments[seg].spectrum != NULL) continue; // already computed
|
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);
|
FreeSegScratch(&sc);
|
||||||
|
|
||||||
@@ -238,8 +261,17 @@ void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
|
|||||||
int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
|
int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
|
||||||
if (numSegments <= 0) numSegments = 1;
|
if (numSegments <= 0) numSegments = 1;
|
||||||
|
|
||||||
result->numSegments = numSegments;
|
|
||||||
result->segments = (StftSegment*)calloc(numSegments, sizeof(StftSegment));
|
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->sampleRate = signal->sampleRate;
|
||||||
result->totalSamples = signal->numSamples;
|
result->totalSamples = signal->numSamples;
|
||||||
result->useHannWindow = true;
|
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)
|
bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment)
|
||||||
{
|
{
|
||||||
SegScratch sc = AllocSegScratch(fftSize);
|
SegScratch sc = AllocSegScratch(fftSize);
|
||||||
|
if (!SegScratchOk(&sc)) { FreeSegScratch(&sc); return false; }
|
||||||
|
bool ok = true;
|
||||||
for (int seg = startSegment; seg < result->numSegments; seg++) {
|
for (int seg = startSegment; seg < result->numSegments; seg++) {
|
||||||
if (seg % app.skipFactor != 0) continue; // overview stride
|
if (seg % app.skipFactor != 0) continue; // overview stride
|
||||||
if (result->segments[seg].spectrum != NULL) continue; // already computed
|
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);
|
FreeSegScratch(&sc);
|
||||||
return true;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FreeSTFT(StftResult* result)
|
void FreeSTFT(StftResult* result)
|
||||||
|
|||||||
Reference in New Issue
Block a user