Skip to content

Add incremental rendering API to OfflineAudioContext - #630

Open
cmzy wants to merge 2 commits into
orottier:mainfrom
cmzy:feature/offline-incremental-rendering
Open

Add incremental rendering API to OfflineAudioContext#630
cmzy wants to merge 2 commits into
orottier:mainfrom
cmzy:feature/offline-incremental-rendering

Conversation

@cmzy

@cmzy cmzy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

OfflineAudioContext currently offers two rendering entry points, and
neither fits hosts that embed a JavaScript engine:

  • start_rendering_sync() renders the whole buffer in one call and
    consumes the renderer;
  • suspend()/suspend_sync() must be registered before rendering starts -
    suspending after the renderer has been taken panics with "cannot
    suspend when rendering has already started".

The Web Audio suspend/resume contract is inherently incremental: render
up to the suspend point, resolve the suspend promise, let script mutate
the graph inside the promise callback, resume, render the next segment.
An embedder cannot know the suspend points up front (script may schedule
new ones from inside a suspend callback), so a pull-style API is needed.

This adds three methods, mutually exclusive with suspend/suspend_sync:

  • render_upto_sync(frame): renders forward to the given frame (rounded
    up to whole render quanta), returns the total rendered frame count,
    and can be called repeatedly to continue;
  • rendered_frames_sync(): lock-free progress accessor (drives
    currentTime on the embedder side);
  • take_rendered_sync(): takes the finished AudioBuffer exactly once.

Implementation notes:

  • The rendered frame count is a lock-free AtomicUsize rather than being
    derived from the incremental state. The early-return paths of
    render_upto_sync report the count while holding the state mutex, and a
    std Mutex is not re-entrant: deriving the count through the same mutex
    self-deadlocks the calling thread on completely legal call sequences
    (render to the end, take the result, call render_upto_sync again). The
    atomic also keeps reporting the full length after the result has been
    taken instead of dropping back to 0.
  • While parked between segments the context state is set to Suspended
    (scripts observing state from a suspend callback must read
    "suspended" per spec), Running while a segment renders, and Closed
    after the final segment.
  • After finalizing, the event loop is spun once more so that the
    complete/statechange events queued by the finalization itself get
    delivered, matching the tail of start_rendering_sync.
  • RenderThread grows render_offline_quanta() (renders n quanta without
    consuming self) and finish_offline_render() (destructor run + event
    drain), splitting the existing one-shot render_audiobuffer_sync into
    resumable pieces.

Tests: chunked rendering produces bit-identical output to a one-shot
render of the same graph, state transitions are observable, the taken
result is single-shot, and post-completion / post-start_rendering calls
are safe no-ops.

OfflineAudioContext currently offers two rendering entry points, and
neither fits hosts that embed a JavaScript engine:

- start_rendering_sync() renders the whole buffer in one call and
  consumes the renderer;
- suspend()/suspend_sync() must be registered before rendering starts -
  suspending after the renderer has been taken panics with "cannot
  suspend when rendering has already started".

The Web Audio suspend/resume contract is inherently incremental: render
up to the suspend point, resolve the suspend promise, let script mutate
the graph inside the promise callback, resume, render the next segment.
An embedder cannot know the suspend points up front (script may schedule
new ones from inside a suspend callback), so a pull-style API is needed.

This adds three methods, mutually exclusive with suspend/suspend_sync:

- render_upto_sync(frame): renders forward to the given frame (rounded
  up to whole render quanta), returns the total rendered frame count,
  and can be called repeatedly to continue;
- rendered_frames_sync(): lock-free progress accessor (drives
  currentTime on the embedder side);
- take_rendered_sync(): takes the finished AudioBuffer exactly once.

Implementation notes:

- The rendered frame count is a lock-free AtomicUsize rather than being
  derived from the incremental state. The early-return paths of
  render_upto_sync report the count while holding the state mutex, and a
  std Mutex is not re-entrant: deriving the count through the same mutex
  self-deadlocks the calling thread on completely legal call sequences
  (render to the end, take the result, call render_upto_sync again). The
  atomic also keeps reporting the full length after the result has been
  taken instead of dropping back to 0.
- While parked between segments the context state is set to Suspended
  (scripts observing `state` from a suspend callback must read
  "suspended" per spec), Running while a segment renders, and Closed
  after the final segment.
- After finalizing, the event loop is spun once more so that the
  complete/statechange events queued by the finalization itself get
  delivered, matching the tail of start_rendering_sync.
- RenderThread grows render_offline_quanta() (renders n quanta without
  consuming self) and finish_offline_render() (destructor run + event
  drain), splitting the existing one-shot render_audiobuffer_sync into
  resumable pieces.

Tests: chunked rendering produces bit-identical output to a one-shot
render of the same graph, state transitions are observable, the taken
result is single-shot, and post-completion / post-start_rendering calls
are safe no-ops.
@orottier

Copy link
Copy Markdown
Owner

I'm fine with adding the ability for incremental rendering to this lib, but it will have to follow the spec that is currently developed over at WebAudio/web-audio-api#2675

Can you assess if that spec will help your use case? Otherwise me might need to chime in to that discussion.

OfflineAudioContext currently offers two rendering entry points, and neither fits hosts that embed a JavaScript engine:

I'm curious about the architecture that you envision. Are you not generating the audio on an audio thread? Did you see our sibling project btw https://github.com/ircam-ismm/node-web-audio-api ?

@cmzy

cmzy commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @orottier — happy to walk through the architecture and assess #2675.

"Are you not generating the audio on an audio thread?"

For realtime contexts, we do — we drive the crate's cpal audio thread exactly as
intended, and that path needs none of this PR.

This PR is only about offline contexts. We embed a JS engine (V8/JSC) and expose the
full Web Audio API to script. Offline rendering in the crate is already synchronous
(start_rendering_sync runs on the calling thread), and we run it on our engine/JS
thread so it can interleave with the JS event loop and microtask queue. There is
deliberately no separate audio thread for offline — the whole point is that script must
be able to observe and mutate the graph between render segments.

The concrete use case: the standard suspend() / resume() contract

We're implementing the existing, shipping OfflineAudioContext.suspend(suspendTime) /
resume() semantics, not a bespoke feature:

  • script calls suspend(t), which resolves a promise when rendering reaches t;
  • inside that callback script may mutate the graph and schedule further suspends;
  • resume() continues rendering to the next suspend point.

So the suspend points are not known up front — a suspend callback can register new
ones. The crate's current suspend() / suspend_sync() require every suspend to be
registered before start_rendering_sync(), and suspending after the renderer has been
taken panics. That's the gap this PR closes: a pull-style primitive that renders forward,
hands control back to the embedder (which runs the pending JS microtasks), and can be
called again — with the next suspend point decided dynamically on our side.

To be clear, render_upto_sync & co. are not a new user-facing API on our end. The API
we expose to script is the standard one-shot startRendering + suspend/resume; these
methods are just the internal primitive we drive it with.

Does #2675 help?

I read it, and yes — its startRendering(chunkSize) is a genuinely more general pull
primitive (it returns control to script between chunks), so our suspend/resume could
be layered on top of it, and I'd be glad to converge there. Two caveats:

  1. It's a different shape: #2675 hands back a fresh AudioBuffer per chunk (streaming,
    length: Infinity), whereas suspend/resume accumulates into one fixed-length
    renderedBuffer. Rebuilding suspend/resume on #2675 means the embedder concatenates
    chunks back into the final buffer — doable, but it reintroduces a second full copy of
    the PCM that our current single-buffer path avoids.
  2. It's still an open draft (the bikeshed preview doesn't build yet), so the IDL isn't
    settled.

Given that, my suggestion: land this as a *_sync offline primitive now — it maps
cleanly onto the shipping suspend/resume semantics, which is what we need for
conformance today — and once #2675 stabilizes I'm happy to migrate the crate's public
surface to match it and drop these methods.

Would that work for you? And does node-web-audio-api already expose an equivalent
incremental/offline primitive I should look at first? If it already covers the
dynamic-suspend-point case, I'd rather reuse that shape than introduce a new one.

@b-ma

b-ma commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Hey @cmzy,

does node-web-audio-api already expose an equivalent incremental/offline primitive I should look at first?

No, the node-web-audio-api lib does not (and will not) expose more functionalities than the ones implemented by the rust library, it is basically just a wrapper to expose the javascript API on node.

I personally still don't really understand your use case here, but if your suspend end-points are not known up front, couldn't you do something like:

let suspendTime = 0;
const duration = 5;
const sampleRate = 48000;

async function someInput() {
  await new Promise(resolve => setTimeout(resolve, 1000));
  suspendTime += 1;
  return Promise.resolve(suspendTime);
}

function suspendAndWaitForInput(context, suspendTime) {
  context.suspend(suspendTime).then(async () => {
    const suspendTime = await someInput();
    console.log(suspendTime);
    if (suspendTime < duration) {
      suspendAndWaitForInput(context, suspendTime);
    }
    context.resume();
  });
}

const offline = new OfflineAudioContext({ 
  sampleRate, 
  length: duration * sampleRate, 
});
suspendAndWaitForInput(offline, 0);
const buffer = await offline.startRendering();
console.log(buffer);

This is a toy example but it works in Chrome, and given someInput is asynchronous it could be an entry point for basically anything I think.

Ok, the drawback is that it crashes for some reason with node-web-audio-api for now :) but it would be better to make it work properly than just adding some non-standard method

@cmzy

cmzy commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — that example pokes at exactly the right thing, so I ran it locally. Here's the
architecture and two empirical results.

Architecture / "are you not generating the audio on an audio thread?"

Realtime contexts do go through the cpal audio thread — that path needs none of this PR.
This PR is only about offline. We embed a JS engine (V8/JSC) and expose the full Web
Audio API to script. Offline rendering in the crate is already synchronous
(start_rendering_sync runs on the calling thread), and we drive it on our engine/JS
thread so it can interleave with the JS event loop and microtask queue. Offline
deliberately has no separate audio thread, because script has to be able to observe and
mutate the graph between render segments.

Two empirical results

  1. Your exact dynamic-suspend snippet — scheduling the next suspend() from inside a
    suspend callback (i.e. after rendering has started), gated on a real awaited
    setTimeout before resume()runs green in our engine: all five dynamic
    suspend points fire in order, the async input gating works, it renders to the end, and
    every segment's output is correct. It works precisely because our
    OfflineAudioContext.suspend/resume is built on top of this PR's render_upto_sync.

  2. The same logic, run directly against stock upstream web-audio-api 1.6.0 (none of our
    patches), panics in async fn suspend() in offline.rs:
    InvalidStateError - cannot suspend when rendering has already started. The reason is
    that start_rendering() take()s the renderer, after which any suspend() hits the
    .as_mut().expect(...). So the "standard approach" you wrote throws on the very first
    dynamic suspend point on the stock crate. (In node-web-audio-api the napi layer turns
    that panic into a thrown JS exception, so it surfaces as an error rather than a process
    crash.)

So the real question isn't "add a non-standard method vs. use the standard approach"

The standard suspend/resume contract inherently requires suspend points to be created
dynamically — script can schedule new ones from inside a suspend callback. The crate's
current async suspend() can only register before start_rendering(), so it can't
express that contract — which is exactly the gap render_upto_sync fills. It's also not a
new user-facing API: what we expose to script is the standard one-shot startRendering +
suspend/resume. And it's consistent with an existing convention — the crate already
ships suspend_sync alongside async suspend, and start_rendering_sync alongside async
start_rendering, for consumers that don't drive a Rust executor. render_upto_sync is
the same idea, and it covers the one case suspend_sync can't: suspend_sync's callback
is synchronous and auto-resumes, so it can't await dynamic input.

@b-ma

b-ma commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Ok, I think I understand your point. Just few remarks

Realtime contexts do go through the cpal audio thread

This is not completely true, you can create an online context that does not do through Cpal by doing

const audioContext = new AudioContext({ sinkId: { type:'none' } });

It's also not a new user-facing API: what we expose to script is the standard one-shot startRendering +
suspend/resume

In JS maybe you can wrap the thing in such way that it doesn't introduce a new method, but in Rust context this looks like a new user facing API to me:

pub fn render_upto_sync(&mut self, upto_frame: usize)

So there is still a issue concerning "add a non-standard method vs. use the standard approach". In my opinion this should better be considered as a bug fix (within the existing rust API surface) rather than a new feature then

For information, another related spec issue WebAudio/web-audio-api#2662

The suspend/resume driving model is inherently dynamic: the standard
pattern schedules the next suspend point from inside the previous point's
promise callback, i.e. *after* `start_rendering()` has begun. Previously
`suspend()` panicked in that case ("cannot suspend when rendering has
already started"), because the suspend registry is moved into the render
future when rendering starts, so the context can no longer reach it.

- `suspend()` now registers the point synchronously on the call and
  returns a future that only waits for the point to be reached. This
  matches the eager-registration semantics of JS promises and guarantees a
  point scheduled right before `resume()` is in place before the render
  loop advances past it.
- Points scheduled after rendering has started are sent through an
  unbounded injection channel that the async render loop drains each
  quantum (and right after each resume), merging them into the pending
  suspend list.

The one-shot `start_rendering_sync` path and `suspend_sync` are unchanged;
`suspend_sync` after rendering still panics as before.

Adds a regression test mirroring the dynamic suspend/resume pattern
(suspend points scheduled from inside the previous point's callback).
@cmzy

cmzy commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — I took your suggestion and treated it as a bug fix within the existing suspend/resume API. @orottier’s toy example now runs to completion natively in the lib (no panic, and the dynamically scheduled suspends fire at the right frames); I added it as a regression test.

@b-ma

b-ma commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Hey, just to let you know that I have tested your patch with node-web-audio-api and it seems to work well too!

But then, there seem to be no reason to keep the pub fn render_upto_sync(&mut self, upto_frame: usize) -> usize (and all related mechanic)

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.

3 participants