v158 · multimedia · web audio

OfflineAudioContext incremental rendering

Rendering two hours of 48 kHz multichannel audio with OfflineAudioContext used to mean allocating the entire result buffer up front — gigabytes of float32 that exist only to be written to a file and thrown away. Chrome 158 makes startRendering() incremental: pass a chunkSize, call it repeatedly, and get the graph's output one bounded AudioBuffer at a time — with a new close() method to stop, and indefinite-length contexts for renders whose duration is unknown up front.

concepts

  1. Chunked render console

    A real offline graph, rendered chunk by chunk: set a chunkSize, press "render next chunk", and compare frames requested with frames received. On a pre-158 build the first call honestly comes back with the whole buffer — the argument is ignored — and the console says so instead of pretending.

  2. The memory math

    Why this shipped, in bytes: dial in a project length, sample rate, and channel count and see the up-front allocation classic rendering demands versus the bounded peak a chunked loop holds — then run a real, small render in this browser and measure what actually got allocated.

  3. WAV export pipeline

    The product use case end to end: render a small synth composition offline, encode each chunk into a growing WAV file as it arrives, then play the result back and download it. On a supporting build the encoder genuinely never holds more than one chunk of float data; elsewhere the page runs the classic one-shot path and labels it.

  4. Lifecycle, close(), and the indefinite context

    The edges, each run for real: what a second startRendering() call throws, what constructing an indefinite-length context does on this build, whether close() exists and what it interrupts, and what oncomplete and statechange report along the way.

why it shipped

Web-based video editors compose projects from many clips and export them faster than real time. The audio half of that export runs through OfflineAudioContext — which allocated the full-length AudioBuffer before rendering a single sample. A two-hour stereo project at 48 kHz is ≈2.76 GB of float32; on a memory-constrained machine the tab dies before the export finishes, and even when it survives, the monolithic buffer arrives all at once, too late to interleave cleanly with video chunks streaming out of WebCodecs.

Incremental rendering turns the same API into a pull loop: each startRendering(chunkSize) call renders at most chunkSize frames and resolves with just that slice, so peak memory is bounded by the chunk, audio can be muxed with video as it is produced, and a context constructed without a fixed length can render indefinitely until close(). The change is additive — a context with a fixed length and no chunk argument behaves exactly as before, which is also the honest feature-detection story: the explainer's own advice is to detect the adjacent close() method rather than the changed startRendering().

the API

const ctx = new OfflineAudioContext({
  numberOfChannels: 2,
  sampleRate: 48000,
  length: null,                // Chrome 158: indefinite-length context
});
// …build the graph…

while (ctx.state !== "closed" && ctx.currentTime < 5) {
  const chunk = await ctx.startRendering(4096);   // ≤ 4096 frames each call
  feedEncoder(chunk);                             // bounded memory, streamable
}
await ctx.close();             // stop; also how you feature-detect:
                               // typeof OfflineAudioContext.prototype.close === "function"

With a finite length the context transitions to "closed" by itself once every frame has been delivered, and oncomplete still fires. Calling startRendering() with no argument on a finite context renders the whole thing in one call — the pre-158 behaviour, now a special case.

references