from sideman import Live
live = Live()
live.count("live_set", "tracks"){'path': 'live_set', 'child': 'tracks', 'count': 1}
Two Drift tracks under the kick: a sub playing F on the off-beats, a mid bass playing a syncopated F–Ab–C an octave above it, and a filter on the mid that opens across the four bars. The filter sweep is the first thing in this book that a Max for Live device could not do at all.
Prerequisites: lesson 2 executed — Drums at track 0 with an 88-note clip.
The ask: “Add a Sub and a Mid bass on Drift. Sub plays F on the off-beats under the kick; Mid plays a syncopated F–Ab–C line an octave up. Name and colour both in one undo. Open the Mid’s filter over the four bars.”
from sideman import Live
live = Live()
live.count("live_set", "tracks"){'path': 'live_set', 'child': 'tracks', 'count': 1}
Make the tracks first; they arrive with default names.
live.call("live_set", "create_midi_track", [-1])
live.call("live_set", "create_midi_track", [-1])
[live.get(f"live_set tracks {i}", "name")["value"] for i in range(3)]['Drums', '2-MIDI', '3-MIDI']
transaction wraps several ops in one native undo step, so the user gets one ⌘Z instead of four. Read the undo field in the result before you promise that: names and colours group, automatable parameters never do. It is also not a database transaction — if op 3 fails, ops 1 and 2 have already applied.
t = live.transaction(ops=[
{"op": "set", "path": f"live_set tracks {i}", "property": prop, "value": val}
for i, (name, color) in {1: ("Sub", 14), 2: ("Mid", 17)}.items()
for prop, val in (("name", name), ("color_index", color))
])
t["applied"], t["undo"](4,
'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.')
.adv not .adgDrift’s library is sorted by role. A .adv is a preset for one device; a .adg is a rack with the device inside it. Both sound fine, but the rack buries the parameters one level deeper — live_set tracks 2 devices 0 chains 0 devices 0 parameters … instead of live_set tracks 2 devices 0 parameters …. For a lesson about automating a filter, take the flat one.
[i["name"] for i in live.browser_list("instruments/Drift/Bass")["items"]
if i["name"].endswith(".adv")]['AG Bass.adv',
'Airfare Bass.adv',
'Climb The Ladder.adv',
'Clob Bass.adv',
'Deep Bass.adv',
'Dub Techno Bass.adv',
'Dub Wob Bass.adv',
'Floot Bass.adv',
'Grimer Bass.adv',
'Not Enough Bass.adv',
'Seventy Two Bass.adv',
'Sol Bass.adv',
'Vintage Chorus Bass.adv']
live.browser_load("instruments/Drift/Bass/Deep Bass.adv", track_index=1)
live.browser_load("instruments/Drift/Bass/Dub Techno Bass.adv", track_index=2){'loaded': 'instruments/Drift/Bass/Dub Techno Bass.adv', 'track': 'Mid'}
[(live.get(f"live_set tracks {i}", "name")["value"],
live.get(f"live_set tracks {i} devices 0", "class_name")["value"],
live.get(f"live_set tracks {i} devices 0", "name")["value"])
for i in (1, 2)][('Sub', 'Drift', 'Deep Bass'), ('Mid', 'Drift', 'Dub Techno Bass')]
The track names survived because they were set before the load. The device class_name is Drift on both; name is the preset.
The kick is on every beat, so the sub goes between them. One pitch, sixteen notes, no movement — a sub bass that moves is a sub bass you can hear as a melody, which is not its job.
SUB = "live_set tracks 1 clip_slots 0 clip"
MID = "live_set tracks 2 clip_slots 0 clip"
for i in (1, 2):
live.call(f"live_set tracks {i} clip_slots 0", "create_clip", [16.0])
[live.get(c, "length")["value"] for c in (SUB, MID)][16.0, 16.0]
def note(pitch, start, dur, vel):
return {"pitch": pitch, "start_time": start, "duration": dur, "velocity": vel}
F1 = 29 # C3 = 60, so F1 = 29
live.notes_add(SUB, [note(F1, b + 0.5, 0.45, 100) for b in range(16)]){'path': 'live_set tracks 1 clip_slots 0 clip', 'added': 16}
F2, Ab2, C3 — the root, the minor third and the fifth of F minor. The pattern lands on the beat twice a bar and pushes against it the rest of the time; the off-beats at 0.75 and 2.25 are what makes it a bass line rather than a pad.
F2, Ab2, C3 = 41, 44, 48
bar = [(0.00, F2, 0.40, 104),
(0.75, F2, 0.25, 88),
(1.50, Ab2, 0.40, 96),
(2.25, C3, 0.25, 92),
(3.00, F2, 0.40, 100),
(3.50, Ab2, 0.40, 86)]
live.notes_add(MID, [note(p, b * 4 + off, d, v)
for b in range(4) for off, p, d, v in bar]){'path': 'live_set tracks 2 clip_slots 0 clip', 'added': 24}
Drift’s low-pass cutoff is called LP Freq. A search for “Cutoff” returns nothing — every vendor names its own parameters, and that is exactly why search exists. Device parameters are normalised, so read min and max before writing: 0 is the bottom of the range, not a neutral setting.
hits = live.search(query="LP Freq", root="live_set tracks 2", type="DeviceParameter")
LP = hits["results"][0]["path"]
LP, {p: live.get(LP, p)["value"] for p in ("name", "value", "min", "max")}('live_set tracks 2 devices 0 parameters 1',
{'name': 'LP Freq', 'value': 0.7383065223693848, 'min': 0.0, 'max': 1.0})
Two paths, not one: the envelope belongs to the clip, the value belongs to the device. insert_step writes one flat step and creates the envelope if it does not exist yet. One step per bar, opening from nearly closed to nearly open.
for b, value in enumerate([0.35, 0.50, 0.72, 0.95]):
live.envelope_insert_step(MID, LP, time=b * 4, length=4, value=value)
live.envelope_get(MID, LP, samples=9)["points"][{'time': 0.0, 'value': 0.7383065223693848},
{'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}]
Two boundary readings in there. Beat 0 returns 0.738 — the parameter’s own value, from before the envelope existed. Beat 4, the edge between the first step and the second, returns the first step’s 0.35. Live evaluates a boundary as the end of what came before, so the samples that mean anything are the ones taken inside a step: beats 2, 6, 10, 14.
This is the capability that puts Sideman in a Remote Script rather than a Max for Live device: clip.automation_envelope(parameter) is not in the Max object model. No amount of patching reaches it.
live.get("live_set", "can_undo")["value"]True
One ⌘Z here removes the two names and two colours together. The four envelope steps are four more undo steps, and Live decided that, not the server.
Device parameters are normalised 0–1 — read min/max before you write, and never assume 0 means “off” or “middle”. Automation takes two paths, because an envelope is a relationship between a clip and a parameter rather than a property of either.
Undo grouping is partial and measured, not assumed: names, colours and time signatures collapse into one step; tempo, mixer volume, mute and device parameters each take their own, however you wrap them. Say which is which rather than promising “one undo”.
points = live.envelope_get(MID, LP, times=[2.0, 6.0, 10.0, 14.0])["points"]
assert [round(p["value"], 2) for p in points] == [0.35, 0.50, 0.72, 0.95], points
assert live.count("live_set", "tracks")["count"] == 3
assert [live.get(f"live_set tracks {i}", "name")["value"] for i in range(3)] == \
["Drums", "Sub", "Mid"]
assert live.notes_get(SUB)["count"] == 16
assert live.notes_get(MID)["count"] == 24
assert live.get("live_set", "can_undo")["value"] is True
print("lesson 3 ok — Sub and Mid on Drift, filter opening 0.35 -> 0.95")lesson 3 ok — Sub and Mid on Drift, filter opening 0.35 -> 0.95