from sideman import Live
live = Live()
live.ping(){'pong': True, 'port': 9878, 'handlers': True, 'handler_error': None}
The runnable mirror of TUTORIAL.md. Same Set, same 76 notes, same filter sweep — but where that page shows the calls Claude sends over MCP, here you drive the identical engine from a notebook, fastai-style: one small step per cell, and the output of every cell is a running Ableton Live. Three clients, one wire protocol: the MCP server (models), lomcli.py (shell), sideman.client (this).
Before you start
AbletonLOM selected as a Control Surfacepip install -e . into yoursNo tool here knows what a “chord” is. You navigate the Live Object Model and it does what you say.
from sideman import Live
live = Live()
live.ping(){'pong': True, 'port': 9878, 'handlers': True, 'handler_error': None}
handlers: false means the engine failed to import — read handler_error.
Never guess member names. The describe result is the API: everything in children is navigable (live_set tracks 0 is a path — zero-indexed, space-separated, no quotes), everything in functions is callable.
d = live.describe("live_set", include_values=False)
d["counts"], d["children"]({'properties': 52, 'children': 6, 'functions': 186, 'unavailable': 0},
{'cue_points': 0,
'return_tracks': 2,
'scale_intervals': 7,
'scenes': 8,
'tracks': 4,
'visible_tracks': 4})
A default Set gives you 2 MIDI + 2 audio tracks. A third MIDI track for drums, inserted at index 2 — create_midi_track is simply a name from the functions list above; there is no create_track sugar anywhere.
live.call("live_set", "create_midi_track", [2]){'path': 'live_set',
'function': 'create_midi_track',
'result': {'__lom__': 'Track.Track', 'name': '3-MIDI'}}
Six writes, one Cmd-Z. Read the undo note in the result: names and colours group, but automatable parameters (tempo, mixer volume, mute, device parameters) each get their own undo step no matter what you wrap them in — measured, not assumed. And this is not a database transaction: if op 5 fails, ops 1–4 have already applied.
tracks = {0: ("Chords", 26), 1: ("Bass", 14), 2: ("Drums", 5)}
t = live.transaction(ops=[
{"op": "set", "path": f"live_set tracks {i}", "property": prop, "value": val}
for i, (name, color) in tracks.items()
for prop, val in (("name", name), ("color_index", color))
])
t["applied"], t["undo"](6,
'Groupable ops collapse into ONE Cmd-Z. Automatable parameters (tempo, mixer volume, mute, device parameters) each form their OWN undo step in Live regardless of grouping, so reverting those needs one extra undo apiece. Measured on Live 12.2.7.')
Browse by name, then load onto a specific track. Without track_index the item lands on whatever track is selected — always check the track the result reports. Presets are real library files, so the drum kit is an .adg.
[i["name"] for i in live.browser_list("instruments")["items"]][:10]['Analog',
'Collision',
'Drift',
'Drum Rack',
'Drum Sampler',
'DS Clang',
'DS Clap',
'DS Cymbal',
'DS FM',
'DS HH']
live.browser_load("instruments/Drift", track_index=0)
live.browser_load("instruments/Operator", track_index=1)
live.browser_load("drums/909 Core Kit.adg", track_index=2){'loaded': 'drums/909 Core Kit.adg', 'track': 'Drums'}
16 beats = 4 bars. A clip_slot always exists; the clip at ... clip_slots 0 clip only exists once created.
for i in range(3):
live.call(f"live_set tracks {i} clip_slots 0", "create_clip", [16.0])Notes are one of the few typed ops — Live returns MidiNote objects but wants MidiNoteSpecification on write, which generic get/set cannot express. Times are in beats, pitch is a MIDI note number (60 = C3).
This is where the notebook earns its keep over the prose tutorial: the 76 notes are comprehensions, not typing. Chords first — Am7, Fmaj7, Cmaj7, G, one per bar:
def note(pitch, start, dur, vel):
return {"pitch": pitch, "start_time": start, "duration": dur, "velocity": vel}
chords = [[57, 60, 64, 67], [53, 57, 60, 64], [60, 64, 67, 71], [55, 59, 62, 67]]
live.notes_add("live_set tracks 0 clip_slots 0 clip",
[note(p, bar * 4, 3.5, 85)
for bar, chord in enumerate(chords) for p in chord]){'path': 'live_set tracks 0 clip_slots 0 clip', 'added': 16}
Bass — root on the downbeat, then offbeat eighths. Roots A1/F1/C2/G1:
roots = [33, 29, 36, 31]
bass = []
for bar, root in enumerate(roots):
bass.append(note(root, bar * 4, 0.45, 105))
bass += [note(root, bar * 4 + off, 0.45, 85) for off in (0.5, 1.5, 2.5, 3.5)]
live.notes_add("live_set tracks 1 clip_slots 0 clip", bass){'path': 'live_set tracks 1 clip_slots 0 clip', 'added': 20}
Drums — a 909 rack maps 36 = kick, 39 = clap, 42 = closed hat. Kick on every beat, clap on the backbeat, hats offbeat with alternating velocity so the pattern breathes:
drums = (
[note(36, b, 0.25, 112) for b in range(16)]
+ [note(39, b, 0.25, 96) for b in range(1, 16, 2)]
+ [note(42, b + 0.5, 0.2, 72 if b % 2 else 90) for b in range(16)]
)
live.notes_add("live_set tracks 2 clip_slots 0 clip", drums){'path': 'live_set tracks 2 clip_slots 0 clip', 'added': 40}
Read them back any time with live.notes_get(clip) — keep the returned note_ids, notes_modify needs them to edit in place.
Find the parameter by searching, not guessing: Drift’s filter cutoff is called LP Freq, and a search for “Cutoff” returns nothing — vendor naming is exactly why search exists. Device parameters are normalised, so check min/max before writing.
hits = live.search(query="LP Freq", root="live_set tracks 0", type="DeviceParameter")
lp = hits["results"][0]["path"]
lp, live.get(lp, "min")["value"], live.get(lp, "max")["value"]('live_set tracks 0 devices 0 parameters 1', 0.0, 1.0)
A rising sweep, one flat step per bar. The envelope belongs to a (clip, parameter) pair — two paths, not one:
clip = "live_set tracks 0 clip_slots 0 clip"
for bar, v in enumerate([0.35, 0.5, 0.72, 0.95]):
live.envelope_insert_step(clip, lp, time=bar * 4, length=4, value=v)
live.envelope_get(clip, lp, samples=9)["points"][{'time': 0.0, 'value': 1.0},
{'time': 2.0, 'value': 0.3499999940395355},
{'time': 4.0, 'value': 0.3499999940395355},
{'time': 6.0, 'value': 0.5},
{'time': 8.0, 'value': 0.5},
{'time': 10.0, 'value': 0.7199999690055847},
{'time': 12.0, 'value': 0.7199999690055847},
{'time': 14.0, 'value': 0.949999988079071},
{'time': 16.0, 'value': 0.949999988079071}]
Sampling exactly on a step’s edge (beat 0) returns the pre-envelope value, not the step’s — Live evaluates the boundary that way. Sample inside a step.
Firing a scene launches its clips; start_playing guarantees the transport is rolling. Listen.
live.call("live_set scenes 0", "fire")
live.call("live_set", "start_playing")
live.get("live_set", "is_playing")["value"], live.get("live_set", "current_song_time")["value"](True, 0.0)
Observers report changes including ones made by hand in Live’s GUI. Run the next cell, then go drag the tempo control in Live for a second or two, then poll.
live.observe("live_set", "tempo")
live.observe("live_set tracks 0 mixer_device volume", "value"){'path': 'live_set tracks 0 mixer_device volume',
'property': 'value',
'observing': True,
'active_listeners': 2}
The outputs frozen in this notebook were produced by a script, with nobody at the keyboard — so the nudge below comes from a second client rather than a hand on the tempo control. Drag the tempo in Live instead and skip this cell: the events read the same either way, which is the point.
from sideman import Live as AnotherClient
AnotherClient().set("live_set", "tempo", 122){'path': 'live_set', 'property': 'tempo', 'value': 122.0}
ev = live.poll_events()
ev["count"], ev["events"][-5:](1, [{'seq': 1, 'path': 'live_set', 'property': 'tempo', 'value': 122.0}])
since=ev["next_since"] next time to get only new events. If ev["truncated"], poll again: more are waiting.dropped_events: the buffer holds 2000, oldest dropped first.describe lists what an object has.Always clean up. A listener Live still holds after you lose track of it fires forever; leaked must be 0:
live.unobserve_all(){'attempted': 2, 'outcomes': {'removed': 2}, 'failures': [], 'leaked': 0}
Stop the transport, and — if you want the Set back — delete the three tracks. Deletion is destructive, so it demands confirm=True; highest index first, because deleting reindexes everything after it.
live.call("live_set", "stop_playing")
# for i in (2, 1, 0):
# live.call("live_set", "delete_track", [i], confirm=True){'path': 'live_set', 'function': 'stop_playing', 'result': None}
The three cells above stop the transport and detach every listener. Assert it rather than assume it — a re-run of this notebook should end here without raising, and if it does raise, Live is not where the notebook thinks it is.
assert live.ping()["handlers"] is True
assert live.get("live_set", "tempo")["value"] == 122.0 # set two cells up
assert live.get("live_set", "is_playing")["value"] is False # stop_playing
assert live.observers()["active_listeners"] == 0 # nothing left listening
print("tutorial ok — transport stopped, no observers left behind")tutorial ok — transport stopped, no observers left behind
live.get_batch([...]) — 40 reads, one round trip.live.canonical_path("live_set view selected_track") — resolve whatever is selected into a stable path you can store.live.request(op, params) — the escape hatch: every op in PROTOCOL.md works by name, typed ones included.live.types() — the census of everything Live exposes.