Vibes DIY
Vibes DIY / Blog
From the build log

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 fileimport { 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:

  1. the ES module (import("https://esm.sh/[email protected]")),
  2. the worklet module (ctx.audioWorklet.addModule(...) pulling dist/zaltz.worklet.js from the CDN),
  3. 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:

zaltz-patchbay — open the live vibe

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 →

Enjoyed this? Get the next post by email

One email field. Real updates. No algorithm required.

Prefer a feed? RSS · Atom