clausters
v0.8.1
Published
Clausters in the browser: the audio engine in an AudioWorklet, the GUI host on a canvas, web components that boot standalone bundles, and the OSC/connection seam of the TypeScript client — no server process needed.
Maintainers
Readme
clausters (web)
npm install claustersClausters in the browser: the audio server compiled to WebAssembly running inside an AudioWorklet, the GUI host on a canvas, web components that boot native-format standalone bundles — no server process anywhere — and the TypeScript client that drives all of it: the OSC codec through the shared native core, the carrier-agnostic connection seam, the def model and Server above them, and the sequencing layer that plays on it. See PLAN.md here for the client roadmap.
This is the repo's one web directory: every browser artifact (the package modules, the engine's worklet/loader runtime, the examples, the test pages, the tools) lives here, and the wasm crates stay Rust-only — build.sh stages their wasm-bindgen bundles into dist/. The package mirrors the Python client's structure: sources under src/ at the same relative paths as clausters/'s modules (base/, gui/, …), with examples/ and tests/ beside them; dist/ reproduces the src/ tree 1:1 (plain ES modules plus .d.ts and source maps — no bundler), and the staged wasm bundles inside it (engine/, gui-host/, core/) are the browser's _bin/_libs. Any static file server serves the result. Toolchain details and the from-scratch recipe are in BUILD.md.
📖 Documentation: clausters-web.readthedocs.io — the client's own book, the mdBook in docs/ (./docs/build.sh builds it locally, with the API reference generated from the sources' TSDoc by TypeDoc). It is the third of the repository's three, beside the server book (the OSC protocol, the /gui_* reference, the bundle format) and the Python client book (the shared client model).
./build.sh # cargo-builds the three wasm bundles, stages them
# into dist/, then emits src/ -> dist/ (needs
# `npm install` once)
python3 -m http.server # then open /examples/demo.html
./test.sh # type-check + node suites + the headless-Chrome smokeAn instrument in the page
A bundle is an instrument written to a directory (defs, one GuiDef, presets, samples) plus a generated ES module that registers its tag. Importing that module is the whole integration — no client library is loaded at run time:
<script type="module">
import "./fm-voice/index.js"; // registers <fm-voice>
</script>
<style>fm-voice { display: block; width: 100%; height: 340px; }</style>
<p>Prose, and then the instrument in the flow of the page:</p>
<fm-voice></fm-voice>
<fm-voice freq="110" preset="bright"></fm-voice>Each element owns its canvas, and the document places it — CSS, the order of
the markup. Two instances of one bundle hold their own node ids and buses, and
the def they share is sent once. Declared parameters are attributes, resolved
attribute → preset → default. The first gesture anywhere on the page starts the
audio for all of them; a component scrolled out of the viewport stops drawing
and stops streaming. Write one with clausters.bundle.Bundle in the Python
client — examples/piano/make_bundle.py is the worked example — and see the
server book's clients chapter for the format.
<clausters-bundle src="./fm-voice"> mounts a bundle with no generated module.
Driving it from script
<script type="module">
import { server, guiHost } from "./dist/index.js";
// registers <clausters-bundle> and <clausters-power> as a side effect
</script>The page surface:
server()— the lazy per-page engine singleton:send(bytes)/addReply(listener)raw OSC,clock(),bLoad(...)(the browser's/buffer_allocRead),resume()/suspend(). Every component and script on the page gets the same engine, so they meet in one node/bus/buffer namespace.guiHost()— the per-page GUI-host singleton, wired to the engine over the in-page server leg (GuiBridge.connect_page). It draws one canvas perwindow-rooted def:attach(defId, canvas)gives a def its surface, and the host is told its size and its visibility, never the DOM.openBundle(...)/startBundle(...)— the two phases of a mount by hand (allocate + resolve + draw, then the engine half on a gesture);bootBundle(...)does both for a script that already has one.<clausters-bundle>/<clausters-power>— the elements; the power button is the standard autoplay-policy affordance.
Two entry points. dist/runtime.js is what a page that mounts components
loads: the engine, the host, the OSC codec and the mount. dist/index.js adds
the TypeScript client — the def builders, the GuiDef builders, the sequencing
layer — for a page that sequences, responds or edits live. Both target the same
element, and tests/runtime-graph.test.ts holds the line between them.
The client seam (what the TypeScript client builds on):
loadOsc()/encodeMessage(addr, args)/decodePacket(bytes)— the OSC codec throughclausters-corecompiled to wasm (thedist/core/bundle), byte-identical to the server and the Python client by construction;tests/osc-vectors.json(generated from the Python client's codec) holds the parity. Arguments are tagged pairs —encodeMessage("/synth_new", [["s","sine"], ["i",1000], ["f",440]])— so the int/float distinction stays explicit.Connection— one duplex-OSC interface, two carriers:WsConnection.open(url)(a browser/nodeWebSocketto aclausters --wsserver, default port 57120) andpageConnection()(the in-page engine, no process, no socket). Everything above the seam never names a transport.
The client proper (src/defs/, mirroring the Python client's clausters/defs/):
Server.open(connection)— the only object that knows a connection. It sizes its node/bus/buffer allocators from the server's own/server_query, registers for the server's pushes (which is what recycles a node id once its/node_endarrives), and carries what is the server's own: the transport (sendMsg,sendBundle,request,sync()), the id pools,freeDef, the bus and tap subscriptions and the introspection queries about what it holds (queryInfo,queryDefs,queryBuffers,queryUgens,queryTree). A command addressed to a resource belongs to that resource, a question about one included:def.send(server),Synth.new(server, …),Group.graph(server, …),node.set/map/free/run/info,Bus.audio(server)andbus.set,Buffer.alloc(server, …),buffer.infoandbuffer.getSamples. Everything that waits is a promise: where the Python client blocks a thread on a reply, this oneawaits.SynthDef+ the lowercase UGen callables (sine,saw,rlpf,envGen,out,pan2, …), andFaustDef+ the Faust signal API (signals), the two def families as peers.GraphDefwires several of either into one named, instantiable configuration with a port surface.- The graph composes by method, TypeScript having no operator overloading:
sine(freq).mul(amp)where the Python client writessine(freq) * amp. The emitted spec JSON is identical, andtests/def-parity.test.tsholds that against vectors frozen from the Python builders (tests/gen-def-vectors.py).
The GUI client (src/gui/, mirroring the Python client's clausters/gui/):
GuiHost— the object that drives a GUI host, on the same connection seam:GuiHost.page()for the wasm host on this page's canvas (through theguiHost()singleton's binding bridge) andGuiHost.connect(url)for a nativeclausters-gui --wshost. It carriesopen/define(a whole tree in one/gui_def, with the ids assigned into it in place),set,free,bind/unbind,queryandload.- The GuiDef builders in the
guinamespace (gui.window,gui.knob,gui.waveform,gui.track, …) — the whole widget catalogue, emitting the same JSON document the Python builders do (tests/gui-parity.test.tsholds that against vectors frozen from them). The options are camelCase where the wire's props are snake_case (textSize→text_size). - Widgets are addressed by name, not by integer:
win.widget("cutoff").set({ value: 800.0 }),.onEvent(fn),.bind("/node_set", node.id, "freq"). A bound widget's value goes from the host straight to the audio server, with no round trip through the page's script. Nothing pumps — events arrive as callbacks,queryresolves a promise.
import { loadOsc, pageConnection, Server, Synth, SynthDef, control, out, sine }
from "./dist/index.js";
await loadOsc();
const server = await Server.open(await pageConnection()); // or a WsConnection
const freq = control("freq", 440.0);
await new SynthDef("beep", out(0.0, sine(freq).mul(0.2))).send(server);
const note = Synth.new(server, "beep", { freq: 330.0 });
note.set({ freq: 220.0 });
note.free();The sequencing layer (src/base/clock.ts + src/seq/, mirroring the Python client's clausters/base and clausters/seq):
TempoClock— musical time and the driver that resumes routines on it. The queue and every conversion (beats to seconds, seconds to samples, the bar grid, the timetag) areclausters-core's, reached through the wasm door, so a beat resolves to the same instant here, in the Python client and in the server. The logical beat advances only by the routines' yields, so a late wake-up never shifts the music.- A routine is a generator:
function* () { … yield 0.25 … }yields a delay in beats. Neverawaitinside one — the page has a single thread, and the exactness lives in the timetag, not in the wake-up. - The wake-up sits behind a
Ticker: a shared worker in the browser (the page's own timers are throttled to about a second in a background tab),setTimeoutelsewhere. Tests fill the same seam by hand, along with the timebase, and so drive the real driver deterministically. server.sampleTimebase()— the Server anchors the sample clock, because the Server is what knows the carrier: in-page it pairs the engine's counter with the AudioContext's in one round trip (the same clock, so the offset is exact and there is no drift); over a socket it feeds/clock_queryanchors into the core's sample-clock model. Hand the result to a clock; the clock never talks to a server.- Emission is the bundle path:
server.sendBundle(...)stamps at the running routine's exact logical beat — a wall-clock timetag under the monotonic timebase,/sched_atat an absolute sample under a sample one — andEvent.play(server)is a note's/synth_newplus the release that follows it. seq—Event/rest, the value patterns (Pseq,Pser,Prand,Pwhite,Pseries,Pgeom,Pfunc,Pn,Pconst) andPbind, plus the seekable counterpart:Timeline(a static, editable, beat-sorted list,Timeline.fromPatternto bounce one) andPlayhead(play/stop/locate/loop). Random values come from the context stream a routine derives at creation, soseed(n)replays a whole piece.
import { Server, TempoClock, pageConnection, seq } from "./dist/index.js";
const clock = new TempoClock(2.0, { timebase: await server.sampleTimebase() });
clock.start();
new seq.Pbind({
instrument: "beep",
degree: new seq.Pseq([0, 2, 4, 7], seq.INF),
dur: new seq.Pseq([0.5, 0.25, 0.25]),
}).play(server, { clock });import { GuiHost, gui } from "./dist/index.js";
const host = await GuiHost.page(); // or GuiHost.connect(wsUrl)
const win = host.open(gui.window(
{ title: "a tone", w: 480, h: 240, layout: "col" },
gui.knob({ name: "freq", label: "freq", min: 50.0, max: 2000.0, value: 220.0 }),
gui.slider({ name: "amp", label: "amp", min: 0.0, max: 1.0, value: 0.2 }),
));
win.widget("freq").bind("/node_set", note.id, "freq"); // host -> engine, no script
win.widget("amp").onEvent((value) => note.set({ amp: value }));The examples (examples/, served pages): synth.html a def built, sent, played and retuned from TypeScript over either carrier (the choice is the one line of the page that names one), demo.html the web-components demo, standalone.html the raw-API standalone boot, engine.html the audible engine harness, gui-host.html a GUI built and driven from TypeScript — the bound and the scripted control paths side by side, a metered bus, the linked waveform + spectrogram, and one button that swaps the in-page host for a native --ws one — five ports of Python client examples, each named after the one it mirrors (multichannel.html, typed-controls.html, graph-maths.html, wavetables.html, pause-resume.html), graph-controls/ — a GraphDef's control surface as one component, its bundle authored with the Python client (make_bundle.py) — piano/ — a playable piano keyboard whose keys the GUI host maps to server voices itself (the widget's voice mode), the same authored-bundle posture — and document/, an interactive text with both of them interleaved with the prose, which is the shape the whole component format is for. The bundle format and the underlying pieces are documented in the server book (docs/clients.md, docs/using-as-a-library.md); the scripted acceptances are the scripts/smoke-web*.sh set at the repo root plus scripts/parity-web.sh, and ./test.sh here.
