Skip to content

Bound WASM memory on long outputs + ship a scalar (non-SIMD) wasm fallback - #1

Merged
olilarkin merged 3 commits into
mainfrom
chunked-offline-render
Jul 2, 2026
Merged

Bound WASM memory on long outputs + ship a scalar (non-SIMD) wasm fallback#1
olilarkin merged 3 commits into
mainfrom
chunked-offline-render

Conversation

@olilarkin

@olilarkin olilarkin commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Problem

Stretching a short sound by a large factor (e.g. ~7 s × 482× → an hour-plus of audio) aborts the WebAssembly module with the generic "build with -sASSERTIONS for more info" message. This is an out-of-memory abort(), not a logic bug — reported downstream in paulstretch-for-live#2. It happens on the OfflineRenderer path (clicking Apply).

Root cause. renderMono/renderStereo build the entire output as one contiguous std::vector<float> in WASM linear memory, then to_js_float32_array allocates a second full-size copy — so peak memory is ≈ 2× the output, doubled again for stereo. The module links with -sALLOW_MEMORY_GROWTH=1 but no -sMAXIMUM_MEMORY, so the cap is Emscripten's 2 GiB default. A 7 s × 482× stereo render peaks well past that.

Fix

  • Chunked offline render (the real fix). New OfflineRenderer::render_mono_chunked / render_stereo_chunked (C++) and renderMonoChunked / renderStereoChunked (WASM). Same DSP — the existing render loops are refactored into shared stream_channel / stream_stereo cores, so render_mono/render_stereo output is unchanged — but the result is delivered one bufsize() chunk at a time. Each chunk is copied out to a fresh JS-heap Float32Array, so peak WASM memory stays ≈ input + one chunk regardless of output length. The consumer accumulates chunks on the JS heap (or streams them to disk / an encoder).
  • Memory cap bump. -sMAXIMUM_MEMORY=4294967296 (4 GiB wasm32 max) for immediate headroom. The chunked API is what removes the dependency on output length.
  • TypeScript declarations + npm README section.

SIMD fallback for older WebViews (fixes paulstretch-for-live#6)

Separately, the production paulstretch.wasm is a -msimd128 build (PFFFT WASM_SIMD128, v128 locals). Some WebViews can't parse a SIMD module at all and fail to compile it — notably macOS WKWebView before Safari 16.4 / macOS 13, which aborts with CompileError … can't get function local's type. On those systems the extension is completely dead (preview and render), which is paulstretch-for-live#6.

  • Ship a scalar binary too. scripts/build-wasm.sh builds both variants and assembles npm/dist: paulstretch.wasm (SIMD) plus paulstretch.nosimd.wasm (scalar, zero SIMD opcodes). Both are built with the same Emscripten, so the generated glue is byte-identical — one paulstretch.js drives either binary, and consumers feature-detect at runtime (WebAssembly.validate probe) and point locateFile at the matching .wasm.
  • The script deletes the linked output before each variant build so an up-to-date tree still refreshes it (CMake links straight into npm/dist and won't relink an up-to-date target — otherwise the second copy is a stale wasm from the other variant), and asserts the two binaries actually differ.
  • npm/package.json0.3.0; exports + packs dist/paulstretch.nosimd.wasm. README documents the fallback and the detection probe.

Tests

  • tests/chunked_test.cpp (new):
    • Parity — chunked vs whole-buffer render match structurally (length, finiteness, peak, RMS; not bit-exact because the algorithm's per-instance PRNG seed differs per render, matching the streaming_test convention), every chunk is exactly bufsize, empty input is a no-op.
    • Memory limits — allocation-free check (via estimate_output_frames against named wasm32 caps) that a representative extreme render's buffered footprint overflows the 2 GiB default cap, and a bigger one overflows even the 4 GiB max, while the chunked working set stays ~16 KB.
  • CI now builds both wasm via scripts/build-wasm.sh and runs the headless-Chrome browser smoke test once per binary (SIMD and scalar) through the shared glue; the release workflow publishes both.

Verified locally

  • ctest passes on PFFFT and ACCELERATE backends.
  • WASM target builds; both new functions exported; built .wasm reports max 65536 pages (4 GiB).
  • Scalar wasm has 0 v128/SIMD opcodes; SIMD wasm reports fftSimdArch()="WASM_SIMD128", scalar reports "4xScalar", both produce identical output through the same glue (Node smoke test).
  • Browser smoke test passes under headless Chrome (data-status="ok") for both binaries.

Opening for CI before merge.

olilarkin added 2 commits May 21, 2026 23:45
The npm/README.md already covers the full JS/WASM API. Replace the
parallel section in the root README with a pointer to it.
renderMono/renderStereo materialise the entire output as one contiguous
vector in WASM linear memory and then copy it again into the returned
Float32Array, so peak memory is ~2x the output (and both channels at once
for stereo). A large stretch (e.g. a few seconds stretched several hundred
times into an hour-plus of audio) exceeds the wasm32 heap and aborts. The
module was also linked with ALLOW_MEMORY_GROWTH but no MAXIMUM_MEMORY, so
the cap was Emscripten's 2 GiB default.

- Add OfflineRenderer::render_mono_chunked / render_stereo_chunked, which
  run the same DSP loop (refactored into shared stream_channel /
  stream_stereo cores) but deliver the result one bufsize() chunk at a
  time. render_mono/render_stereo output is unchanged.
- Expose renderMonoChunked / renderStereoChunked in the WASM bindings; each
  chunk is copied out to a fresh JS-heap Float32Array, so peak WASM memory
  stays ~ input + one chunk regardless of output length.
- Raise MAXIMUM_MEMORY to the 4 GiB wasm32 maximum for immediate headroom.
- Add TypeScript declarations and an npm README section.
- Add tests/chunked_test.cpp: structural parity between the chunked and
  whole-buffer paths, plus an allocation-free check that a representative
  extreme render overflows the known wasm32 caps while the chunked working
  set stays tiny. Extend the browser smoke test to exercise renderMonoChunked.
@olilarkin
olilarkin force-pushed the chunked-offline-render branch from e0c520d to 06f61c2 Compare June 7, 2026 16:11
Ship a second WebAssembly artifact, paulstretch.nosimd.wasm, built without
-msimd128 alongside the SIMD paulstretch.wasm. The SIMD build fails to compile
in WebViews without WASM SIMD support (notably macOS WKWebView before Safari
16.4 / macOS 13), where the module can't be parsed at all. Consumers
feature-detect SIMD at runtime and load the matching binary; both are built
with the same Emscripten version so a single glue drives either one.

- scripts/build-wasm.sh: build both variants and assemble npm/dist. Deletes the
  linked output before each variant build so an up-to-date tree still refreshes
  it (CMake links straight into npm/dist and won't relink an up-to-date target).
- npm/package.json: 0.3.0; export + pack dist/paulstretch.nosimd.wasm.
- CI/release: build both via the script; smoke-test each wasm in the browser.
- README: document the scalar fallback and the feature-detect probe.
@olilarkin olilarkin changed the title Add chunked offline render to bound WASM memory on long outputs Bound WASM memory on long outputs + ship a scalar (non-SIMD) wasm fallback Jul 2, 2026
@olilarkin
olilarkin merged commit d2de02f into main Jul 2, 2026
8 checks passed
@olilarkin
olilarkin deleted the chunked-offline-render branch July 2, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WASM assertion

1 participant