3  Build a 4-bar house loop — from Python

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

No 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.

3.1 1. Orient yourself

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})

3.2 2. Make a track

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'}}

3.3 3. Name and colour them in one undo step

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.')

3.4 4. Load instruments

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'}

3.5 5. Create clips

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])

3.6 6. Write notes

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.

3.7 7. Automate a filter

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.

3.8 8. Play it

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)

3.9 9. Watch what you do

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}])
  • Pass since=ev["next_since"] next time to get only new events. If ev["truncated"], poll again: more are waiting.
  • Watch dropped_events: the buffer holds 2000, oldest dropped first.
  • Not every property is observable — 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}

3.10 Clean up (optional)

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}

3.11 Check it landed

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

3.12 Where to go next

  • 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.
  • TUTORIAL.md — this same session as Claude drives it over MCP.
  • live.types() — the census of everything Live exposes.