Borrowing a WASM synth
The tutorial waved at
the mechanics: one import() line fetches the Strudel engine at play time,
nothing gets bundled, go make beats. This post is for the readers who
stopped at that line and asked wait, how does that actually work? — and
then we'll push the trick somewhere more demanding than a sample player.
How imports work in a vibe
A vibe's App.jsx has two kinds of imports, and the platform treats them
differently:
Static imports at the top of the file — import { useFireproof } from "use-vibes" — are rewritten at deploy time. Each bare package name gets
pinned to a versioned CDN URL (esm.sh, with the dependency graph and
external=react worked out so you never ship a second React), and the page
carries an import map so the browser resolves everything without a bundler.
Your source stays readable; the resolution is the platform's problem.
Dynamic import() in the body is different: the rewriter only covers
the import region at the top of the file, so a body-position
import("@strudel/web") ships as-is and dies with "Failed to resolve
module specifier" — we hit exactly that on this tutorial's first deploy.
The rule is simple: lazy imports use a full pinned URL.
jslet ctx = null;
let enginePromise = null;
function engine() {
// called from inside the click handler:
ctx ||= new AudioContext(); // create + unlock synchronously in the gesture
ctx.resume();
enginePromise ||= import("https://esm.sh/[email protected]")
.then((m) => m.default.create(ctx)); // the engine adopts the unlocked context
return enginePromise;
}
Why lazy at all? Because heavyweight engines belong off the boot path. You
don't spend first-paint time on an audio engine the visitor may never press
play on — and you can't start audio before a user gesture anyway (browser
autoplay policy). A memoized module-level promise gives you exactly one
fetch, started at the moment the gesture hands you permission. One ordering
detail is load-bearing: create and resume() the AudioContext
synchronously inside the click handler, before the first await — iOS
Safari doesn't preserve user activation across an awaited import, so a
context created after the module arrives can stay muted on iPhones while
desktop testing looks fine.
The stress test: zaltz
A sample player is an easy guest. So we invited a harder one: zaltz — a synthesizer written as one file of C, compiled to 165 KB of WASM, running inside an AudioWorklet on the audio thread, where main-thread jank can't reach it. It speaks superdough's parameter language (superdough is Strudel's sound layer), so it slots into the same musical world as the rest of this series.
Get posts like this in your inbox
One email field. Real updates. No algorithm required.
Loading it from a vibe means three cross-origin fetches at runtime, each a place the sandbox could have said no:
- the ES module (
import("https://esm.sh/[email protected]")), - the worklet module (
ctx.audioWorklet.addModule(...)pullingdist/zaltz.worklet.jsfrom the CDN), - the WASM binary, fetched and compiled inside the worklet's scope.
We wrote a probe vibe that reports each step to the console and ran it against production. All three passed, zero platform changes:
[ZALTZ-PROBE] PASS import {"hasDefault":true}
[ZALTZ-PROBE] PASS create {"state":"running"}
[ZALTZ-PROBE] PASS schedule {"events":8}
Here's the playable version — each button schedules raw events on the engine and shows you the first event object it sent:
No pattern language here — just parameter objects with when timestamps
against the audio clock:
jsconst { z, ctx } = await engine();
z.scheduleAll([
{ params: { s: "sawtooth", note: 33, duration: 0.44,
attack: 0.005, release: 0.1, lpf: 500, gain: 0.8 },
when: ctx.currentTime + 0.1 },
// …seven more events make a bar of dub
]);
The license story, told properly
Now the part the first post kept gentle. Both engines in this series are AGPL-3.0 — and zaltz makes the license's point beautifully: it's a derivative of superdough, the Strudel project's sound layer, and its own NOTICE says it "honors its ancestor's terms." That's copyleft working as designed — an engine got rewritten in C, got faster, and stayed open, because its ancestor's license asked it to.
Our approach is chosen to respect exactly that deal. When your vibe plays a note:
- Your app's code contains your UI, your parameter objects, and one URL.
- The engine's code travels from the CDN to the visitor's browser unmodified, at its own versioned address, with its license and source a click away — the same way it would if the visitor opened the engine's own website.
We never copy, bundle, minify-into, or redistribute the engine inside the app. The two programs meet in the visitor's browser and talk through a public API. We think that's both the respectful reading and the practical one — though the standing caveat from part one stands taller here: we're enthusiasts, not lawyers, and if your product hangs on this question, ask a real one. What isn't in question: credit loudly. Strudel is at strudel.cc (source: codeberg.org/uzu/strudel), zaltz is at zaltz.klappn.com, and both footers in our demos link them.
What's next
The runtime worked on the first try; the remaining gap is that our code generator doesn't know this pattern yet — ask it for a drum machine and it won't reach for the full-URL lazy loader on its own. That's filed and tracked, with the working loader shapes from this series as the reference implementation. Until then, the demos are public: remix one and the loader comes with it.
Bring your own engine
If it ships on a CDN, a vibe can probably play it. Remix the patchbay and find out.
Start building →