diff --git a/known_bugs.md b/known_bugs.md index 46269b8..b091188 100644 --- a/known_bugs.md +++ b/known_bugs.md @@ -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 diff --git a/src/spectrogram.c b/src/spectrogram.c index 4e57752..cf74c26 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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( + "File too large for the browser build
" + "This capture needs more memory than a 32-bit WebAssembly " + "page can address.

" + "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( + "File too large for the browser build
" + "This capture needs more memory than a 32-bit WebAssembly " + "page can address.

" + "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; diff --git a/src/stft.c b/src/stft.c index af99910..14db8ce 100644 --- a/src/stft.c +++ b/src/stft.c @@ -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)