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)