Skip to content

Enable wasm-bindgen feature with target wasm32-unknown-emscripten - #1349

Merged
roderickvd merged 12 commits into
RustAudio:masterfrom
DouglasDwyer:webaudio-reach-any-wasm-target
Sep 11, 2026
Merged

Enable wasm-bindgen feature with target wasm32-unknown-emscripten#1349
roderickvd merged 12 commits into
RustAudio:masterfrom
DouglasDwyer:webaudio-reach-any-wasm-target

Conversation

@DouglasDwyer

@DouglasDwyer DouglasDwyer commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

The wasm-bindgen tool is finally getting support for integration with Emscripten. The feature is still quite new, but this means that most of Rust's web ecosystem can now work with the Emscripten target. I'd like to use cpal in a WASM/Emscripten project, but right now the wasm32-unknown-emscripten target is hard-coded to use the null backend. This PR eliminates the feature gate to make the wasm-bindgen feature work with wasm32-unknown-emscripten too.

This PR exposes the webaudio backend but not the audioworklet backend. That backend relies on re-instantiating the WASM module, but the way Emscripten modules get instantiated is different, so it wouldn't work without more changes.

Changes

  • Replace #[cfg(all(target_arch = "wasm32", target_os = "unknown", feature = "wasm-bindgen"))] with #[cfg(all(target_arch = "wasm32", feature = "wasm-bindgen"))] for the webaudio backend
  • Use Closure::wrap_aborting instead of Closure::wrap so that the code properly compiles on WASM targets with panic=unwind
  • Bump wasm-bindgen dependency to 0.2.110 in order to use Closure::wrap_aborting
  • Add an additional CI job for the Emscripten target

Testing

In my own project, I have gotten cpal audio working with a Rust/Emscripten WASM module in Chrome. This PR also adds CI checks to ensure that compilation is successful.

Related issues

#92 #413 #810

@roderickvd roderickvd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's great! Not so long ago we removed the old Emscripten host that had become defunct. This seems like a light-weight manner to get Emscripten support back.

Beyond the changes requested in the review points, please also consider updating README.md with Emscripten support.

Comment thread src/host/webaudio/mod.rs Outdated
Comment thread Cargo.toml Outdated
Comment thread CHANGELOG.md Outdated
@DouglasDwyer
DouglasDwyer force-pushed the webaudio-reach-any-wasm-target branch 2 times, most recently from ccac245 to 1620436 Compare September 2, 2026 03:24
@DouglasDwyer

Copy link
Copy Markdown
Contributor Author

Thank you for your swift response! I have responded to the comments, and additionally updated the tables in the README to reflect support for the Emscripten target.

@roderickvd roderickvd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround. Here's a few points, hopefully the last.

Comment thread README.md
Comment thread CHANGELOG.md Outdated
Comment thread src/platform/mod.rs
Comment thread src/host/webaudio/mod.rs Outdated
@DouglasDwyer
DouglasDwyer force-pushed the webaudio-reach-any-wasm-target branch from 1620436 to 40c1b2b Compare September 3, 2026 01:52
Enable the WebAudio host (and its wasm-bindgen/js-sys/web-sys deps) for
`wasm32-unknown-emscripten`, gated on
`any(target_os = "emscripten", target_os = "unknown")` so nothing
wasm-bindgen-related is pulled in for `wasm32-wasip1`/`wasip2`. The
AudioWorklet host stays `wasm32-unknown-unknown`-only.

The three WebAudio JS callbacks keep using `Closure::wrap`. Dropping the
`as Box<dyn FnMut(_)>` cast keeps the closures concrete, so their captures
(all `UnwindSafe`) satisfy the `panic=unwind` bound on Emscripten without
`wrap_aborting` -- a callback panic still surfaces as a JS exception
rather than aborting the instance. Minimum `wasm-bindgen` stays at 0.2.

README: document the `wasm32-unknown-emscripten` target (Emscripten 6.0.3,
wasm-bindgen 0.2.127) and list it under the `wasm-bindgen` feature. Adds
an Emscripten CI job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@DouglasDwyer
DouglasDwyer force-pushed the webaudio-reach-any-wasm-target branch from 40c1b2b to 3846df3 Compare September 3, 2026 01:58
@DouglasDwyer
DouglasDwyer force-pushed the webaudio-reach-any-wasm-target branch from 572e408 to 31691df Compare September 3, 2026 13:50

@roderickvd roderickvd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the iteration. Some more points inside.
Additionally:

  • would you add wasm-emscripten to the publish-cpal gate?
  • add a CI path for threading and the proxying?

Comment thread src/host/webaudio/main_thread.rs Outdated
if is_main_thread() {
Some(run(func))
} else {
None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None maps to ErrorKind::UnsupportedConfig but not being able to proxy seems more like an UnsupportedOperation. Maybe return a Resultrather thanOption` here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the old code path, when attempting to create an input/output stream from a worker thread, it would fail on this line:

https://github.com/RustAudio/cpal/blob/master/src/host/webaudio/mod.rs#L324-L330

So I chose that error variant because it would preserve the existing behavior. But I like the idea of returning a Result here - much more explicit. I'll go ahead and make that change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to Result in 3492db2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The switch to the Result looks good, but the Rustdoc is stale and still talks about returning Some or None with both try_run implementations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have caught that. Thanks. Fixed in 411f5cc

Comment thread src/host/webaudio/main_thread.rs Outdated
(&raw mut slot).cast(),
);

assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this not panic with try_run?
Both this assert!() and the .expect() below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intended semantics for try_run are:

  • If it's possible to run the closure on the main browser thread, do it
  • If it wasn't possible, return null

My intention was that try_run would always return Some on Emscripten targets, since Emscripten always has a main thread. The only case where emscripten_proxy_sync returns false is when the target thread is null or cancelled, but I don't think that can reasonably happen for the UI thread? So I added these asserts to document invariants that I would never expect to fail. The main point of having try_run return None was for wasm32-unknown-unknown, where proxying isn't possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since (according to my best understanding) these expects should never fail - it should always be possible to proxy something to Emscripten's runtime thread - I would push to leave them as is.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand. Keeping the expect indeed is better than simply unwrapping, but then why do we still need the assertion?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - I have left the expects, but the assert is unnecessary, because if the function didn't run then the expect on the very next line will fail. Removed the extra check in 411f5cc

Comment thread src/host/webaudio/main_thread.rs Outdated
Comment thread src/host/webaudio/main_thread.rs
Comment thread src/host/webaudio/main_thread.rs
@DouglasDwyer
DouglasDwyer force-pushed the webaudio-reach-any-wasm-target branch from 583ef9e to 3492db2 Compare September 7, 2026 02:33
@DouglasDwyer

Copy link
Copy Markdown
Contributor Author

I added the wasm-emscripten target to the publish-cpal gate in 3492db2.

To cover threading, I modified the wasm-emscripten CI job so it runs everything twice: once in single-threaded mode (no +atomics or -Zbuild-std) and once in multi-threaded mode. That's done in bda2227, and should at least confirm that it builds.

Let me know if you had something more in mind - to really exercise this code path, we should add an example. But wasm-bindgen's Emscripten integration is still in beta, and requires extra setup that's still subject to change. I'd suggest waiting until that workflow becomes more mature to do so. (In my own project, it took quite a bit of fiddling with AI to figure out the command line flags to use - nothing related to cpal of course, just generally getting the two runtimes to work together. But I'm excited about the possibilities, and so I'm hoping to make downstream crates - like cpal - ready for it early!)

@roderickvd

Copy link
Copy Markdown
Member

Your CI rationale sounds reasonable: let's keep it like that for now.

Thanks for linking to those headers. Reading through proxying.h, something that stands out:

Get the queue used for proxying low-level runtime work. Work on this queue may be processed at any time inside system functions, so it must be nonblocking and safe to run at any time, similar to a native signal handler.

But what we're proxying now can block and isn't entirely safe: reating an AudioContext, getUserMedia, buffer allocation, mutex acquisition. That is neither nonblocking nor signal-handler-safe. Is this something we should be using em_proxying_queue_create() for instead?

@DouglasDwyer

DouglasDwyer commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Your CI rationale sounds reasonable: let's keep it like that for now.

Thanks for linking to those headers. Reading through proxying.h, something that stands out:

Get the queue used for proxying low-level runtime work. Work on this queue may be processed at any time inside system functions, so it must be nonblocking and safe to run at any time, similar to a native signal handler.

But what we're proxying now can block and isn't entirely safe: reating an AudioContext, getUserMedia, buffer allocation, mutex acquisition. That is neither nonblocking nor signal-handler-safe. Is this something we should be using em_proxying_queue_create() for instead?

The Emscripten runtime does some magic to provide a synchronous API for stuff like network and file IO. Javascript only provides async versions of such functions, so when a normal thread attempts to read a file or write a socket, it will block that thread and proxy to the main one (much as we are doing here). I believe that this is what the documentation means when it says nonblocking and safe to run. Creating an AudioContext is synchronous and "atomic" from the JS perspective, and the getUserMedia/mutex locking happens inside separate wasm_bindgen_futures::spawn_local callbacks - these operations do not touch Emscripten resources and should not have problems.

The one instance where I could imagine an issue is allocation: in the past, there have been deadlock bugs related to using malloc on the UI thread. These bugs have been patched, though, and using the system queue seems to be the standard practice:

  • Here an Emscripten maintainer recommends emscripten_proxy_get_system_queue as a general replacement for emscripten_sync_run_in_main_runtime_thread, a deprecated function that didn't have these scary warnings.
  • Here the Emscripten standard library proxies to the system queue and then performs a free on the main thread.

Considering this, I think the warning is just meant to say "hey, don't expect Emscripten functions that are already proxied to work, such as file IO." I haven't been able to come up with a concrete failure path for us, so my instinct is to leave it. But I'm happy to allocate a separate queue in a thread local if you'd feel better about it.

I also want to note that with a proxying approach like this, it's always possible for incorrect user code create a deadlock, assuming that user code is using mutexes of its own. That's an unavoidable consequence of this architecture.

Comment thread src/host/webaudio/main_thread.rs Outdated
ok,
"emscripten_proxy_sync to the browser main thread failed"
);
Ok(slot.ret.take().expect("proxied task did not run"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: you can use .ok_or() to transform an Option into a Result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the way I understand your suggestion, this would look like:

slot.ret.ok_or_else(|| panic!("proxied task did not run"))

I'm not totally convinced that the control flow is more obvious here. With the current code, you can look and see the expect followed by the Ok, and see that it's an unwrap (or panic) followed by putting the value into a Result. With ok_or_else, the reader needs to parse the panic! and then the fact that it only happens in this conditionally-executing closure.

Of course, we could properly return an Err here for the exceptional case:

slot.ret.ok_or_else(|| Error::with_message(
    ErrorKind::BackendError,
    "proxied task did not run",
))

But from our previous conversations here I thought that we were keeping the panic-on-error.


I did notice that the .take() is useless here though, so I'll remove that in any case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I meant that we might as well defensively return Err if we've got that function signature anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3f585a7

@roderickvd

Copy link
Copy Markdown
Member

Thanks, that makes sense. Just dropped a comment which I think should be the last, if you're still up to iterating over that. Should be good to go then!

@roderickvd
roderickvd merged commit 2ee614a into RustAudio:master Sep 11, 2026
17 checks passed
@roderickvd

Copy link
Copy Markdown
Member

Thanks, merged!

@DouglasDwyer

Copy link
Copy Markdown
Contributor Author

Just want to say thank you for iterating with me! Your dedication and thoroughness as a maintainer are very apparent :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants