Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

206 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

M4L-JWEB

Build Ableton Live devices like a web developer.

M4L-JWEB lets you author Ableton Live Max for Live devices (.amxd) in an ordinary TypeScript repo, with the tools any developer already expects: a package manager, a typechecker, unit tests, CI. The device UI is a React app, and it can be run, simulated and tested outside Ableton and outside Max - against a mocked Live, in a browser.

(Quick remark: this project is a developer tool, not a DAW replacement. It doesn't replace Ableton Live or generate audio on its own. You will still need a licensed copy of Ableton Live (Suite or with Max for Live extras) to actually load these devices and produce sound!)

Moreover, because M4L-JWEB is based entirely on declarative code, it not only provides an improved developer experience, but also natively enables LLM-assisted development of Max patches and devices—just as an LLM would assist with any other text-based programming language.

The glue that a device needs is provided rather than rewritten each time: the message bridge between the browser and Max, the [js] script that talks to Live's object model, the generated patcher, and the binary .amxd writer. So pnpm build produces installable devices on a machine that has never had Max on it, which means CI can ship them.

pnpm install
pnpm dev:hello-midi   # the device in a browser, with a mocked Live beside it
pnpm build            # .amxd files, no Max installed
pnpm install:device   # into Ableton's User Library

The repo builds several example devices out of the box to demonstrate the architecture:

Device Type What it is
hello-midi MIDI effect A pulse generator. A Rate slider (off, 1/4, 1/8, 1/16, 1/32) plays C3 on every division, placed on Max's scheduler.
hello-audio audio effect Three effects in series - a lowpass, a soft-clipping drive and a level - each one a chain named in the manifest, each with its own Live parameter.
hello-audio-rev audio effect Not an example - a test case. The same app, the same parameters, the opposite chain order. You run it with your ears: doc/TEST-CHAIN-FX.md.
hello-downloads audio effect Fetch-to-disk (fetchToFile). A download chain hands Max's [maxurl] a request, and libcurl writes the file - so the bytes never cross the message bridge.
hello-state audio effect State persistence (useStateSync). Arbitrary JSON, saved into the Ableton Live Set and restored with it, per instance.
hello-window audio effect Floating windows (useWindow). A second page, in a window of its own, for a UI that does not fit in the device view's fixed ~169 px.
hello-clip MIDI effect Clip I/O (readClip / readSelectedClip / writeClip). Writes a scale into a clip and reads notes back - either this track's own clip, or the one the cursor is on.
hello-remote audio effect Pattern modulation (the remote chain). Binds live.remote~ to its own parameter by LOM id and sweeps it - resolveParamId + bindRemote + writeRemote, visible as a moving native knob.
hello-sampler instrument Sample playback in the page (webaudio chain). Fetched, decoded with decodeAudioData, and played through the track - no [buffer~], no disk.
hello-instrument instrument Playable multi-sample instrument (webaudio + midiin). Three piano notes cover the keyboard by repitching from the nearest one, and MIDI into the track plays it - put hello-midi or a clip in front. Polyphony is the browser's.
hello-synth instrument The same job from the other side: audio generated rather than recorded (webaudio + midiin). An oscillator per held note, so it also exercises note-OFFs, which a decaying sample does not need.

Testing the feature examples: Because hello-downloads and hello-state are compiled as audio effects with a passthrough audio chain, they won't swallow or block sound. You can drop them on the Master channel (or any audio track) to test their UI and features without disrupting your musical signal flow!

Each lives in its own folder under src/app/, and each builds into its own .amxd carrying its own UI bundle. (hello-audio-rev is the exception that proves it: it shares hello-audio's folder, via the manifest's ui field, so that the order of its chains is the only thing about it that differs.)

Both of them, in a real chain

hello-midi, an instrument, and hello-audio on a Live track

hello-midi (left, a MIDI effect) is pulsing C3 at 1/16. It feeds Hello Bass - an ordinary Ableton instrument, nothing to do with this repo - which turns those notes into audio. That audio then runs through hello-audio (right, an audio effect), whose Cutoff slider is riding a real lowpass filter at 10.7 kHz.

Two devices built from TypeScript, sitting in a normal Live device chain either side of a stock instrument, behaving like any other device. Note that hello-midi says "free-running": the transport is stopped, so it is pulsing off its own fallback clock rather than Live's - see the tutorial for why a sequencer must use the transport when it is running.

For how any of it works underneath - the message protocol, the generated patchers, the .amxd container writer, Push support - see doc/ARCHITECTURE.md.


What is supported

M4L-JWEB handles the entire Max bridge so your React app feels like a native Ableton device. Out of the box, it supports:

  • Parameter Automation: Your UI components drive real Max parameters underneath. Recording an automation lane in Live, automating via clip envelopes, or MIDI-mapping a physical controller just works without fighting your app.
  • Push Integration: Parameters declared in your code are automatically exposed and grouped for Ableton Push encoders.
  • Accurate MIDI Timing: Notes are placed on Max's scheduler. Your JavaScript sequencer computes when a note should fall, and Max places it with sample-accurate precision despite the UI's 20Hz refresh rate.
  • Audio DSP Chains: Declarative audio signal paths (filters, gains, overdrives) that process sound at native C++ speeds. Audio never crosses the JS bridge.
  • State Persistence: useStateSync() gives you a useState-shaped binding to arbitrary JSON that is saved inside the Live set and restored with it, per device instance - for the pattern, preset or drum map that a numeric parameter cannot hold.
  • Clip I/O: read and write the MIDI notes of a clip. writeClip(lengthBeats, notes) fills the first empty slot; readClip() reads this device's own track (playing-else-first, selection-blind - what a track-bound generator like Strudel wants), while readSelectedClip() reads the clip the cursor is on and treats an empty highlighted slot as "no clip". Notes are control-plane, so they cross the bridge; velocity is written but not read back.
  • A rendered file into a Live clip: createAudioClip(path, { target: "selected" }) puts a WAV your device just wrote into a clip slot - named, warped, and looped to the length you rendered rather than the one Live infers. Live 12.0.5 or newer; older Lives are refused by a version READ, because the call exists there and quietly does nothing. It needs an audio track, which an instrument on a MIDI track can never highlight from its own view - so { target: "new" } creates one, and a device that means to bounce ships as an audio effect. See doc/MAX-FACTS.md.
  • Which container am I in: onTrackKind() answers audio, midi or none at ui_ready, read from the device's own track. A device that behaves differently per container (an audio clip only lands on an audio track; a MIDI clip only on a MIDI one) asks Live instead of being told by its build.
  • Observing Live: defineWatch() declares which Live properties to watch (the tempo, the scale, the selected track), and useWatch() reads them in React. The observers are generated into the device's bang() - the one place a LiveAPI object is not born dead - so the trap that makes a hand-written observer silently watch nothing is not one you can fall into.
  • Floating Windows: useWindow() opens a second page in a window of its own. The device view in Live is a fixed ~169 px tall and does not scroll, so this is where a UI that needs room goes. A window can be resizable (its page grows with it) and can hold a whole prebuilt site rather than a component of yours - window({ site: "dist/my-site" }) ships that directory beside the .amxd instead of embedding it, for content far too big for a payload.
  • Windows That Make Sound: window({ audio: true }) compiles the window's page to [jweb~], and its Web Audio output is summed into the device's signal path - so a whole app running in a floating window can BE the track's instrument. The page loads at device load and keeps playing with the window closed. m4l-strudel runs the real strudel.cc this way.
  • Runtime Control Descriptions: a device whose real controls come from the user's code declares a POOL of native dials (knobPool(8)) and lends them out with useControls(). Each borrowed dial carries the borrower's name, unit and range - describeParam() - so the panel stops reading S1 and the readout stops reading 0.44 for 600 Hz. Live's parameter registry still keeps the declared short name, so render the name in your own UI too.
  • Page-to-Page Messages: sendToWindow() hands a message from one of a device's pages to another's window. State slots already crossed that gap, but a slot saves with the set - wrong for anything continuous, like a knob being swept.
  • Fetch to Disk: fetchToFile(url, path) downloads straight to the filesystem through Max's [maxurl], with progress. The bytes never cross the JS bridge, so a 40 MB sample pack is not a problem - and no [node.script] is involved.
  • Sample Playback and Polyphony: the samples chain (a named [buffer~] per slot, previewed through the track) and the instrument chain (a generated [poly~] voice patch, frozen into the device, playing a keymap of buffers via playVoice()). Buffer names are instance-scoped with Live's --- prefix, so two copies of a sampler on two tracks keep their own sound.
  • Pattern Modulation: the remote chain puts one live.remote~ per declared slot in the device. resolveParamId() + bindRemote() point a slot at any Live parameter by LOM id; writeRemote() streams values per tick, each ramped into a signal by a [line~] - continuous modulation with no automation written.
  • Push Banks: banks in the surface declaration become real parameter banks in the patcher, so a Push page turn lands on the group you declared.
  • Native Layout: parameters can render as native live.* objects in the device view (layout.native), including the two-screen panel pattern (web UI or knob panel, flipped at runtime).
  • Presets: a repo's presets/ folder (hand-saved .adg/.adv) ships next to the devices - in dist/, the release zip, and the installers.
  • Mocked Development: A simulated Live environment runs in the browser, providing transport controls, tempo, and message logs so you can build the UI without opening Ableton.

The last three, running in Live

hello-downloads, hello-window and hello-state loaded on a Live track

Left to right: hello-downloads has just pulled a URL to a file next to the device (83 bytes, no browser download dialog, no Node); hello-window opens and closes a second [jweb] page in a floating window; hello-state is holding a JSON blob that will still be there when the set is reopened. Each is an audio effect with a passthrough chain, so you can drop them on any track - including Master - and they will not touch the sound.


What you need

To build

  • Node.js 20+ and pnpm 10+.
  • No Max license, and no Max editor. The patcher is generated, and the container is written byte-for-byte by packages/build/src/amxd.mjs.
  • No Ableton Live. The entire UI develops in a browser, against a mocked Live.

To run the device: Live, and not every edition

Not every Ableton edition can run Max for Live devices:

Edition Runs this device?
Live Suite Yes. Max for Live is included. This is the normal path.
Live Standard Only with the paid Max for Live add-on. Not included by default.
Live Intro No. Max for Live is not available, and there is no add-on path.
Live Lite (bundled with hardware) No. Same as Intro.

So the entry-level Ableton license cannot run this. You need Suite, or Standard plus the Max for Live add-on. You do not need a separate Cycling '74 Max license on top - Max for Live bundles the Max runtime, and this repo never opens the Max editor anyway.

Versions. Developed and tested against Live 12 with Max 9 on Windows.

The floor moved up in 0.9.9. Devices are now built on [jweb~] - the browser view WITH signal outlets - rather than plain [jweb], and the generated patcher declares Max 9. Plain [jweb] dates to Max 8, which is where the old "Live 10/11 should work in principle" claim came from; [jweb~] is newer than that, and I have not established which Max version first shipped it. Treat anything below Live 12 / Max 9 as unverified, and check [jweb~] exists before assuming an older host will load these devices at all.

Platforms. Live runs on macOS and Windows only. The build runs anywhere Node does, so CI on Linux is fine - you just cannot run the result there.


Build, run, install

Develop without Live

pnpm dev:hello-midi     # or dev:hello-audio, or dev:spike

A mocked Live renders beside your device: a transport (play/stop, BPM) driving real tick and tempo messages at the same 20 Hz cadence the wrapper polls Live at, and a log of every message crossing the bridge, in both directions. A sequencer becomes developable, and debuggable, in a browser tab.

The device keeps its true 169 px height there, deliberately: the Live device view does not scroll, it silently clips, and that is the cheapest bug to catch early.

A mock is a mock. It gives you the entire message-level contract without a DAW - the tedious, easy-to-get-wrong part. It cannot tell you about MIDI jitter, real DSP, or LiveAPI on a loaded set. Keep "load it in Live" for those.

Build and test

pnpm build   # one UI bundle per device, then one .amxd per device
pnpm test    # container round-trip, ES5 gate, protocol lint, bundle separation

Install into Live

pnpm install:device

That picks the right script for your platform, finds your User Library, and replaces any previous install of this device folder:

  installed hello-midi.amxd
  installed hello-audio.amxd
Installed to <User Library>\Max For Live\m4l-jweb

Then in Live: User Library > Max For Live > m4l-jweb.

The one gotcha: Live embeds a copy of a device into the set. Instances already sitting on a track will not update when you reinstall - delete them and re-drag from the browser. Every device prints a build stamp in its footer, so a stale one is visible rather than mysterious.

The User Library is read from Live's own preferences (Library.cfg, the ProjectPath value), newest version first, falling back to Live's default location. No registry keys and no environment variables are involved, so a custom library location is picked up automatically.

pnpm install:device wraps the same per-platform scripts the build copies into dist/ and into the release zip, so you can run them yourself (dist\install-windows.ps1, dist/install-mac.sh). Both accept an optional device name and source folder, which is how the CLI drives them - someone who receives only the release zip runs the script sitting next to the device folder, with no repo and no Node. There is no Linux installer: Live has no Linux build.


Why: what Max for Live development normally costs

Ableton has no public plugin SDK for its device area. What it has is Max for Live: an embedding of Cycling '74's Max, a visual programming environment with four decades of history. A device is a Max patcher - a graph of boxes connected by patch cords - wrapped in a binary .amxd container and hosted in Live's device chain.

The canonical workflow:

  1. Open Live, drop a Max device on a track, click Edit. The Max editor opens.
  2. Drag objects onto a canvas: midiin, live.dial, [js], MSP signal objects. Draw cords between them. Position everything by pixel.
  3. For anything algorithmic, write ES5 JavaScript inside the [js] object, which also carries LiveAPI - the only scriptable access to Live's object model (tracks, clips, scenes, transport, scale).
  4. Save. "Freeze" the device so its file dependencies travel inside the .amxd. Distribute that file.

This workflow has real strengths: it is direct, live-editable, and the Max object library is enormous. Thousands of excellent devices are built this way. None of this is a criticism of Max - it was designed for musicians patching live, and it excels at that. It just means a software engineer's entire toolbox sits unused:

What is missing What M4L-JWEB does instead
No components, no CSS, no state management. The UI toolkit is Max's own, positioned visually and styled sparsely. The UI is a React app, running in the [jweb] Chromium view that ships with Max. Components, CSS, canvas, WebGL, Web Workers.
No modern language. [js] runs an ES5-era interpreter: no modules, no let/const, no promises, no npm. You write TypeScript, everywhere, including the [js] glue. ES5 is a compiler target, not a way of life - and the build re-parses the emitted glue to prove it is ES5 before it will package.
No build, no diff, no CI. The patcher is both source and artifact; version control sees JSON full of pixel coordinates; producing a distributable needs a human clicking inside a licensed Max editor. Patchers are generated from a manifest, so patch cords become code review, and pnpm build emits .amxd files on a runner that has never had Max on it.
A virtual filesystem quirk. Frozen dependencies live inside the device where only Max-native objects can read them - an embedded browser cannot open the files you shipped with your own device. The UI travels inside the device as a payload the [js] wrapper extracts to a real file on load, then points [jweb] at.

The result is an Ableton device you build the way you build anything else: edit text, run tests, push, let CI produce the artifact. No editor in the loop, no pixel coordinates in your diffs.

What it does not change. Push still sees only Live parameters, never your UI. Audio still belongs to Max's signal path, not to your app. Timing still belongs to Max's scheduler. M4L-JWEB moves the authoring, not the runtime - and the tutorial below is mostly about respecting that line.

It also makes the repo unusually agent-friendly, for the same reason: every artifact is text, and every invariant is enforced by the build (ES5 gate, container round-trip, protocol lint, bundle separation), so an LLM can implement a device end to end and verify its own work. CLAUDE.md spells out the guardrails.


Tutorial: author a device

1. Scaffold a repo

m4l-jweb init creates a new device repo, with @m4l-jweb/bridge and @m4l-jweb/build as published dependencies rather than workspace links:

pnpm dlx @m4l-jweb/build init my-device
cd my-device && pnpm install
pnpm dev

You get a working hello-midi device that builds and runs unmodified. The rest of this tutorial is what you change in it.

The template lives inside @m4l-jweb/build at packages/build/templates/starter/, and most of it is this repo's own infrastructure - the same scripts/, vite.config.ts, tsconfig and src/app/shared/, with one device instead of three. tests/starter.test.mjs compares those files byte-for-byte and fails if they diverge, so the template cannot quietly fall behind the library again.

2. Declare the device - patcher/devices.mjs

The manifest says what the device is. The patcher is generated from it, so patch cords become something you review rather than something you drag.

export default [
  {
    name: "my-device",
    type: "midi",                    // midi | audio | instrument
    chains: ["midiin", "midiout"],   // canned wiring, applied in order
    unmatchedTo: "js",
  },
];

Parameters are not here - they live in src/app/<device>/surface.ts (step 4), and the build generates the Max objects and their wiring from that declaration.

A chain is a small function that adds boxes and cords. Shipped today:

Chain What you get
midiin Notes played into the device arrive in your app.
midiout Notes your app generates are placed by Max, with sample-accurate timing.
lowpass An audio effect you can hear: a filter with a Cutoff parameter.
drive Soft-clipping distortion (overdrive~), with a Drive parameter.
gain An audio effect with a Live parameter on the level.
passthrough A straight wire. It does nothing to the audio - a scaffold, not a feature.

The order of the list is the signal path. An audio device's plugin~ and plugout~ come from the build, and each audio chain claims one stage between them - so hello-audio's chains: ["lowpass", "drive", "gain"] is plugin~ -> onepole~ -> overdrive~ -> *~ -> plugout~. Reorder those three words and the device is rewired: no patcher is opened, no cord is drawn, and no line of the app changes.

That reordering is audible, which is worth knowing before you test it. Move gain before drive and a quiet signal barely clips; leave it after and the distortion happens at full level and is then turned down. Swapping lowpass and gain, on the other hand, would generate a different patcher and sound identical - they are both linear, so they commute. If you reorder two linear stages and hear nothing, the build is not broken.

Adding an effect to a device is therefore a one-word diff, and this is the whole argument for generating patchers rather than drawing them:

-    chains: ["lowpass"],
+    chains: ["lowpass", "drive"],
     cutoff: dial({ range: [40, 18000], unit: "Hz", exponent: 4, default: 18000, short: "Cutoff" }),
+    drive:  dial({ range: [1, 10], unit: "x", default: 1, short: "Drive" }),

pnpm build, and the device has an overdrive~ in its signal path, a Drive dial in Live, an automation lane, a Push encoder and a typed useParam(surface, "drive") in the app. Nothing was wired by hand, and if you forget the second diff the build fails: a chain that drives DSP from a parameter says which one it needs, and no device ships a distortion with no drive control.

Write your own in patcher/chains.mjs. A chain that drives DSP from a parameter (lowpass wants cutoff, gain wants gain) fails the build if the device's surface does not declare it - and it takes that parameter in real units, doing no arithmetic on it: the range, the unit and the curve belong to the parameter.

3. Define the protocol - src/app/<device>/protocol.ts

Every message crossing the bridge is a selector (a word) followed by arguments. This file is the single source of truth for both sides, and pnpm test fails if you name a selector nothing on the Max side handles - because an unrouted selector produces no error at runtime, it just falls on the floor.

Spread in the library's contracts rather than retyping the names. DEVICE_IN is what the wrapper sends every device; CHAIN_IN/CHAIN_OUT are what the chains own.

import { CHAIN_IN, CHAIN_OUT, DEVICE_IN } from "@m4l-jweb/bridge";

export const IN = {
  ...DEVICE_IN,         // mode, build, tick, tempo
  ...CHAIN_IN,          // notein <pitch> <velocity>
  density: "density",   // a parameter is just another message
} as const;

export const OUT = {
  ...CHAIN_OUT,         // midinote ..., flush
  ui_ready: "ui_ready",
} as const;

4. Write the device - src/app/<device>/App.tsx

It is a React app. The only thing that makes it a device is the bridge.

import { flushNotes, onNote, sendNote } from "@m4l-jweb/bridge";
import { useDevice } from "../shared/device";

// mode, build stamp, tempo, transport - and the `ui_ready` handshake, which is
// not optional: the page loads asynchronously, so anything the wrapper sent
// before your handlers existed is simply gone.
const device = useDevice((playing, beats) => {
  // Called on every transport poll. Send your notes from in here.
});

// Notes played INTO the device. Note-offs are filtered - Max owns the release.
onNote((pitch, velocity) => { /* ... */ });

// Notes OUT of it. You compute WHEN; Max places the note on its scheduler.
sendNote({ pitch: 60, velocity: 100, durationMs: 120, delayMs: 80 });

// Notes are HELD by Max. A device that just stops sending leaves them sounding.
flushNotes();

delayMs is the whole point of the split. Live's transport reaches you at 20 Hz, so each tick covers a slice of musical time rather than an instant - and a note almost never falls exactly on a poll. Work out which notes land inside the slice, send each one with the delay that carries it to its true position, and Max places them precisely. The notes land tight even though the clock driving them is coarse, and your app never touches a timer.

Audio is not yours to carry. An audio effect's parameter is wired straight into the signal path inside the patcher: your React code moves a value, never a sample, and the sound keeps working even if the browser stalls.

5. Add parameters - src/app/<device>/surface.ts

Push shows Live parameters, not your UI - not yours, not anyone's. So every musically meaningful control has to exist as a real Live parameter as well as in your app. You declare it once:

import { defineSurface, dial } from "@m4l-jweb/surface";

export default defineSurface({
  params: {
    cutoff: dial({ range: [0, 1], default: 1, short: "Cutoff" }),
  },
});

The build generates the rest: a live.dial that is automatable, MIDI-mappable and visible to Push, wired in both directions. A knob turn (or an automation lane, or a Push encoder) arrives in your app as cutoff 0.42; your app writes it back with set_cutoff 0.42, which moves the dial and the DSP the parameter drives.

Set default. Without it a live.* object loads at the bottom of its range, and for a filter cutoff that is a device which swallows the signal the moment you drop it on a track. And declare the unit: with no unit style Live prints a float as an integer, so a smooth sweep reads "0" and "1" on a Push.

Bind it in your app with one hook - typed from the declaration, two-way, and naming no selectors:

import { useParam } from "@m4l-jweb/surface/react";
import surface from "./surface";

const [cutoff, setCutoff] = useParam(surface, "cutoff"); // number

Turning the Push encoder moves the React state; moving the React control moves the Live parameter - so automation, MIDI mapping and Push all follow. pnpm dev:<device> renders the same declaration as a parameter panel and a Push preview, so you can see what a performer will see without leaving the browser.

To read something Live owns - the tempo, the scale, the selected track - declare a watch in src/app/<device>/watch.ts. It is the read-only twin of the surface:

import { defineWatch, watch } from "@m4l-jweb/surface";

export default defineWatch({
  watches: {
    scale: watch({ path: "live_set", property: "scale_name", default: "C" }),
  },
});
import { useWatch } from "@m4l-jweb/surface/react";
import watches from "./watch";

const scale = useWatch(watches, "scale"); // string, updates when Live's scale changes

The build injects the list and the packaged wrapper creates every observer from the device's bang() - the one moment a LiveAPI object is not born dead. You never write the observer, so you cannot write it in the one place (loadbang) that silently makes it watch nothing forever.

To write files - an export, a bounce, a downloaded sample - declare them in src/app/<device>/files.ts:

import { defineFiles } from "@m4l-jweb/surface";

export default defineFiles({ saves: true, fetches: true });
import { onDeviceFolder, saveToFile } from "@m4l-jweb/bridge";

useEffect(() => onDeviceFolder(setFolder), []); // where the files land
await saveToFile("export.wav", bytes);

That one declaration derives all three things a write needs: the download chain (which owns [maxurl], and a save's last step is a file:// place through it, even for a device that downloads nothing), the device folder the page is told at ui_ready, and the selectors. They used to be remembered separately, and forgetting the chain failed silently - the bytes were written, the place request left on an outlet with nothing on the other end, and the promise never settled.

6. Declare Floating Windows and State Persistence

If your UI gets too big to fit inside the standard device view, you can offload sections to floating windows. You can also declare arbitrary JSON state that is automatically saved into the Live Set (so the user doesn't lose their settings when they reopen the project).

Declare them both inside surface.ts:

import { defineSurface, state, window } from "@m4l-jweb/surface";

export default defineSurface({
  params: { /* ... */ },
  windows: {
    drumMap: window({ title: "Drum Mapping", width: 800, height: 600, entry: "DrumMap" })
  },
  state: {
    kitSettings: state({ default: { voices: 4, tuning: "C" } })
  }
});

And bind to them in your React app with hooks:

import { useWindow, useStateSync } from "@m4l-jweb/surface/react";
import surface from "./surface";

export default function App() {
  const drumWindow = useWindow(surface, "drumMap");
  const [kitSettings, setKitSettings] = useStateSync(surface, "kitSettings");

  return (
    <div>
      <button onClick={drumWindow.open}>Open Drum Mapping</button>
      <button onClick={() => setKitSettings({ ...kitSettings, voices: 8 })}>Set Voices to 8</button>
    </div>
  );
}

Both are typed from the declaration: kitSettings is { voices: number, tuning: string } with no cast, and a window id that is not declared is a build error rather than a button that quietly does nothing.

entry: "DrumMap" names the component the window bundles - src/app/<device>/DrumMap.tsx. It is a separate page: its own [jweb], its own bundle, no shared React state with the device view (they talk through Max, like any two devices).

The build emits a [pcontrol] and a subpatcher for each window, and a [dict] with a parameter_enabled [pattr] bound to it for each state slot - which is what makes Live save the JSON into the set, not into the patcher. See ARCHITECTURE.md for why that attribute is the one that matters.

7. Fetch a file to disk

[jweb] is a Chromium view with no filesystem, and this repo bans [node.script]. So downloads go through Max's [maxurl]: add the download chain to the device in the manifest, and call fetchToFile().

import { fetchToFile } from "@m4l-jweb/bridge";

const { bytes } = await fetchToFile(
  "https://example.com/kick.wav",
  "samples/kick.wav",                       // relative -> next to the .amxd
  (downloaded, total) => setProgress(downloaded / (total || 1)),
);

libcurl writes the file itself, so the bytes never cross the message bridge - a 40 MB sample pack costs the same as a 40-byte one. You get progress for free, and a promise that rejects with the HTTP status if it failed.

8. One device, one bundle

Each device is a folder under src/app/, and each .amxd embeds its own UI bundle: hello-midi carries no filter code, hello-audio carries no sequencer. pnpm dev:<device> runs one of them; pnpm build bundles each in turn. A device ships what it is, not what its siblings are.


License

MIT - see LICENSE. The published packages (@m4l-jweb/bridge, @m4l-jweb/surface, @m4l-jweb/wrapper, @m4l-jweb/build) carry the same licence.

In practice that means you can use this commercially, modify it, and ship closed-source devices built with it, with no obligation to publish your changes. The only condition is that the copyright notice and licence text travel with copies or substantial portions of this software - not with the devices you build using it. It comes with no warranty and no liability.

What it does not cover. Ableton Live and Max are Cycling '74's and Ableton's, under their own licences - this project neither redistributes them nor grants any rights to them. A .amxd you build here runs inside Max for Live and needs a licence for it (see What you need). The dependencies pulled in at build time (React, vite, and so on) carry their own licences, all permissive.

About

Build Ableton Live devices like a web developer: React UI and headless .amxd builds with no Max editor in the loop

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages