START: bright red guide, no transposing unpitched stems, consolidate + warp off in Live

Per the Location Setup worksheet (2026-10-26):
- a song saved twice holds two guides; START takes the bright red one (Live clip color 14), not the newer light pink one
- drums, percussion, aux drums and other unpitched stems are never transposed (by stem name; sub bass and FX are)
- every transposed clip is consolidated in Live (Edit > Consolidate) and its warp turned off, then the set is saved;
  a hold of START does it for this week's existing sets too. New AbletonOSC commands: clip, select_clip, set_clip
- the START key's CONSOLIDATE comes from the saved set (clips still transposed), not the last build's log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 14:12:38 -04:00
co-authored by Claude Opus 5.5
parent 1f98af4bab
commit e191150f7a
6 changed files with 561 additions and 37 deletions
+264 -15
View File
@@ -12,15 +12,21 @@ every special service (First / Together Tuesday, Morning Worship, ...).
the numbered locators (with their computer-key mappings), the loop locators and the loop cues stay.
3 the locators are named "N- TITLE - KEY" from PCO, the tempo map and the TEMPO label clips follow the
songs, and a Waves Tune key cue goes at every locator
4 every song comes in from Finished Songs at its locator. In another key it is transposed the way the
workflow says (warp on, Complex Pro, formants 0, transpose) and flagged, because consolidating it is
done in Live. Its markers, guide and lighting cues go onto the dedicated tracks.
4 every song comes in from Finished Songs at its locator. In another key its pitched stems are transposed
the way the workflow says (warp on, Complex Pro, formants 0, transpose); drums, percussion, aux drums
and other unpitched stems are left as they are (UNPITCHED). Its markers, bright red guide (a song saved
twice holds a light pink one too) and lighting cues go onto the dedicated tracks.
5 pads: the Call to Worship pad in the first song's key, an ending pad per song past its loop, Salvation
6 every stem routed per the routing template, send A (in-ears) at 0 dB
A song too long for its slot pushes the loop locators, loop cues and later slots along (in whole bars);
more songs than slots add slots; fewer move Salvation up, as the sets have done by hand.
7 in Live: every transposed clip is consolidated and its warp turned off (the workflow's last two steps;
Live's own Consolidate, so Complex Pro renders it), and the set saved. The set is opened in Live first
when another one is open (never while Live plays; Live asks about unsaved changes itself). A hold of
START does this for a set that is already there too.
Every audio file the set uses is copied into its project folder (as Collect All and Save does), so the
project can be sent to the locations as it is. Nothing is overwritten: a set that is there is left alone,
and a new set is built in "<name>.building" and only renamed when it is complete.
@@ -46,6 +52,11 @@ NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I)
NUMBERED = re.compile(r"^\s*(\d+)\s*-")
SALVATION = re.compile(r"salvation", re.I)
# Stems that have no key: never transposed (the workflow: "drums, percussion, aux drums, and other unpitched
# sounds"). Sub bass, timpani and FX are pitched. A bare LOOP stem is a drum loop (SYNTH / VOX LOOP are not).
UNPITCHED = (r"\b(DRUMS?|PERC(USSION)?|SHAKERS?|TAMB(OURINE)?S?|CYMBALS?|TOMS|KICK|SNARE|HI ?HATS?|HATS|CLAPS?|SNAPS"
r"|TIMBALES|CONGAS?|BONGOS?|CAJON|HITS?|IMPACT|BOOM|CLICK|BPM|TEMPO|LTC|GUIDE)\b|^LOOP\b")
GUIDE_RED = 14 # Live's bright red (FF3636); the other guide is light pink (0 or 28)
AUDIO_EVENTS = "DeviceChain/MainSequencer/Sample/ArrangerAutomation/Events"
MIDI_EVENTS = "DeviceChain/MainSequencer/ClipTimeable/ArrangerAutomation/Events"
INITIAL = -63072000.0 # the time Live gives an envelope's starting value
@@ -117,6 +128,11 @@ def stem_name(name):
return " ".join(s.split())
def pitched(name, unpitched=UNPITCHED):
"""A stem that follows the song's key ("Sub Bass", "Piano 2"), not a drum or percussion one."""
return not re.search(unpitched, stem_name(name).replace("_", " "))
def template_for(routing, name):
n = stem_name(name)
if n in routing:
@@ -894,9 +910,9 @@ def dated(t):
return m.group(1) if m else ""
def song_parts(fam):
def song_parts(fam, red=GUIDE_RED):
"""A finished song's group split into its stems and the three helper tracks the workflow puts in it
(one of each: a song saved twice can hold two guides; the newest is the one)."""
(one of each). A song saved twice holds two guides: the bright red one is the one (the newest red)."""
stems, sections, lighting, guide = [], [], [], []
for t in fam[1:]:
cs = clips(t)
@@ -911,7 +927,7 @@ def song_parts(fam):
stems.append(t)
pick = lambda ts, key: clips(max(ts, key=key)) if ts else []
return (stems, pick(sections, lambda t: len(clips(t))), pick(lighting, lambda t: len(clips(t))),
pick(guide, lambda t: (dated(t), -fam.index(t))))
pick(guide, lambda t: (any(val(c, "Color") == str(red) for c in clips(t)), dated(t), -fam.index(t))))
def load_song(entry, cfg):
@@ -926,7 +942,7 @@ def load_song(entry, cfg):
if not tops:
return None
fam = family(ls, tops[0])
stems, sections, lighting, guide = song_parts(fam)
stems, sections, lighting, guide = song_parts(fam, cfg.get("guide_color", GUIDE_RED))
audio = [c for t in stems for c in clips(t) if c.tag == "AudioClip"]
if not audio:
return None
@@ -971,7 +987,7 @@ def build_set(svc, base_path, out_dir, name, cfg, status, log):
if got:
info.update(got, semis=semis, key=s["key"], from_key=got["key"])
if semis:
log.append("TRANSPOSE %s %s>%s: CONSOLIDATE IN LIVE" % (got["title"].upper(), got["key"], s["key"]))
log.append("TRANSPOSE %s %s>%s" % (got["title"].upper(), got["key"], s["key"]))
if s.get("bpm") and abs(float(s["bpm"]) - got["bpm"]) > 0.6:
log.append("TEMPO %s: PCO %s, SONG %g" % (got["title"].upper(), s["bpm"], got["bpm"]))
else:
@@ -1097,7 +1113,7 @@ def build_set(svc, base_path, out_dir, name, cfg, status, log):
for c in clips(t):
place(c, span(c)[0] + off, song["bpm"])
if c.tag == "AudioClip":
if song.get("semis"):
if song.get("semis") and pitched(tname(t), cfg.get("unpitched") or UNPITCHED):
transpose(c, song["semis"], song["bpm"])
files.adopt(c, song["proj"], song["folder"])
if t.tag == "AudioTrack":
@@ -1237,6 +1253,220 @@ def check(root):
return out
# --- 7: in Live, consolidating what was transposed ---------------------------------------------------
def pending(root, unpitched=UNPITCHED):
"""The transposed clips a set still holds: [(track index as Live counts them (no returns), track name, clip
start, pitched)]. A pitched one is consolidated; an unpitched one only goes back to its own key."""
out, i = [], 0
for t in tracks(root.find("LiveSet")):
if t.tag == "ReturnTrack":
continue
for c in clips(t):
if c.tag == "AudioClip" and int(fval(c, "PitchCoarse")) != 0:
out.append((i, tname(t), span(c)[0], pitched(tname(t), unpitched)))
i += 1
return out
def live_set():
"""The set open in Live: the last one its log says it loaded."""
import glob
logs = sorted(glob.glob(os.path.expanduser("~/Library/Preferences/Ableton/Live*/Log.txt")), key=os.path.getmtime)
path = None
if logs:
with open(logs[-1], "rb") as f:
text = f.read().decode("utf-8", "replace")
for m in re.finditer(r'info: Loading document "(.*)"', text):
if "/Templates/" not in m.group(1):
path = m.group(1)
return path
def osc_pad(b):
return b + b"\0" * (4 - len(b) % 4)
def osc_msg(addr, *args):
tags, data = ",", b""
for a in args:
if isinstance(a, int):
tags, data = tags + "i", data + struct.pack(">i", a)
elif isinstance(a, float):
tags, data = tags + "f", data + struct.pack(">f", a)
else:
tags, data = tags + "s", data + osc_pad(str(a).encode())
return osc_pad(addr.encode()) + osc_pad(tags.encode()) + data
def osc_parse(b):
def string(i):
j = b.index(b"\0", i)
return b[i:j].decode("utf-8", "replace"), (j // 4 + 1) * 4
addr, i = string(0)
tags, i = string(i)
out = []
for t in tags[1:]:
if t == "i":
out.append(struct.unpack(">i", b[i:i + 4])[0]); i += 4
elif t == "f":
out.append(struct.unpack(">f", b[i:i + 4])[0]); i += 4
elif t == "s":
x, i = string(i); out.append(x)
elif t in "TF":
out.append(t == "T")
return addr, out
class Live:
"""Live on this Mac: AbletonOSC (with the status panel's commands, tools/abletonosc) and its menus
(System Events: osascript has Accessibility on the Tracks Mac)."""
def __init__(self):
import socket
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
self.s.bind(("127.0.0.1", 11001))
except OSError:
raise RuntimeError("OSC PORT BUSY")
self.s.settimeout(0.3)
def close(self):
self.s.close()
def ask(self, addr, *args, wait=5.0):
import socket
self.s.sendto(osc_msg(addr, *args), ("127.0.0.1", 11000))
end = time.time() + wait
while time.time() < end:
try:
b, _ = self.s.recvfrom(65536)
except socket.timeout:
continue
a, out = osc_parse(b)
if a == addr:
return out
return None
def clip(self, i, name, beat):
"""(found, pitch, warping, file) of the arrangement clip at `beat` on the track."""
r = self.ask("/live/statuspanel/clip", i, name, float(beat))
return tuple(r[3:7]) if r and len(r) >= 7 else None
def menu(self, item, menu="Edit"):
"""Click a menu item of Live's; False when it is greyed out (nothing selected)."""
r = subprocess.run(["/usr/bin/osascript", "-e", 'with timeout of 20 seconds\n'
'tell application "System Events" to tell process "Live"\n'
'set frontmost to true\n'
'set m to menu item "%s" of menu "%s" of menu bar 1\n'
'if not (enabled of m) then return "off"\n'
'click m\nreturn "on"\nend tell\nend timeout' % (item, menu)],
capture_output=True, text=True, timeout=40)
if r.returncode != 0:
raise RuntimeError("LIVE MENU: " + (r.stderr.strip()[-40:] or "FAILED").upper())
return r.stdout.strip() == "on"
def live_app():
"""Live's app bundle (the running one, else the newest in /Applications)."""
r = subprocess.run(["/bin/ps", "-axo", "command"], capture_output=True, text=True)
m = re.search(r"^(/.*?\.app)/Contents/MacOS/Live$", r.stdout, re.M)
if m:
return m.group(1)
apps = sorted(n for n in os.listdir("/Applications") if n.startswith("Ableton Live") and n.endswith(".app"))
return os.path.join("/Applications", apps[-1]) if apps else None
def open_in_live(live, path, todo, wait=180):
"""Open the set in Live (not while it plays); True once it is loaded."""
if live_set() == path:
return True
playing = live.ask("/live/song/get/is_playing", wait=3)
if playing and playing[0]:
raise RuntimeError("LIVE IS PLAYING")
app = live_app()
if not app:
raise RuntimeError("NO LIVE ON THIS MAC")
subprocess.run(["/usr/bin/open", "-a", app, path], capture_output=True, timeout=30)
end = time.time() + wait
while time.time() < end:
time.sleep(2)
if live_set() == path:
i, name, beat, _ = todo[0]
got = live.clip(i, name, beat)
if got and got[0]:
return True
return False
def save_live(path, live):
before = os.path.getmtime(path)
live.menu("Save Live Set", "File")
for _ in range(120):
if os.path.getmtime(path) != before:
return True
time.sleep(0.25)
return False
def consolidate(path, cfg, status, log, live=None):
"""Step 7 for one set: in Live, every pitched transposed clip consolidated (Edit > Consolidate renders it
with Complex Pro) and its warp turned off; an unpitched one put back in its own key, warp off. Saved."""
name = os.path.splitext(os.path.basename(path))[0]
todo = pending(load(path), cfg.get("unpitched") or UNPITCHED)
if not todo:
return {"name": name, "consolidated": 0}
own = live is None
live = live or Live()
status.write(live=time.time()) # the panel hands AbletonOSC back to Companion after
try:
if live.ask("/live/song/get/tempo", wait=3) is None:
raise RuntimeError("LIVE NOT ANSWERING")
status.write(step="OPENING %s IN LIVE" % name.upper()[:14])
if not open_in_live(live, path, todo):
log.append("OPEN %s IN LIVE (SAVE THE SET THAT IS OPEN), HOLD START" % name.upper())
return {"name": name, "consolidated": 0, "left": len(todo)}
done, reset, missed = 0, 0, []
for n, (i, tn, beat, is_pitched) in enumerate(todo, 1):
status.write(step="CONSOLIDATING %d/%d" % (n, len(todo)), live=time.time())
got = live.clip(i, tn, beat)
if not got or not got[0] or not got[1]:
missed.append(stem_name(tn)) # changed in Live since it was saved
continue
if not is_pitched:
live.ask("/live/statuspanel/set_clip", i, tn, float(beat), 0, 0)
reset += 1
continue
live.ask("/live/statuspanel/select_clip", i, tn, float(beat))
time.sleep(0.3)
if not live.menu("Consolidate"):
missed.append(stem_name(tn))
continue
end = time.time() + cfg.get("consolidate_wait", 300)
while time.time() < end:
time.sleep(0.5)
now = live.clip(i, tn, beat)
if now and now[0] and now[1] == 0 and now[3] != got[3]:
live.ask("/live/statuspanel/set_clip", i, tn, float(beat), 0, 0)
done += 1
break
else:
missed.append(stem_name(tn))
if done or reset:
status.write(step="SAVING %s" % name.upper()[:14])
if not save_live(path, live):
log.append("%s: LIVE DID NOT SAVE, SAVE IT IN LIVE" % name.upper())
if reset:
log.append("%s: %d UNPITCHED CLIPS PUT BACK IN THEIR KEY" % (name.upper(), reset))
for m in missed:
log.append("%s: %s NOT CONSOLIDATED" % (name.upper(), m))
return {"name": name, "consolidated": done, "reset": reset, "left": len(missed)}
finally:
status.write(live=time.time())
if own:
live.close()
# --- the job ----------------------------------------------------------------------------------
def projects_dir(cfg):
@@ -1283,12 +1513,9 @@ def run(cfg):
status.data["results"].append({"name": name, "skipped": "THERE ALREADY"})
continue
todo.append(svc)
if not todo:
status.write(state="done", step="NOTHING TO DO")
return
# 1. Last weekend's songs to Finished Songs (from the newest weekend set before the first new one).
first = min(s["date"] for s in todo)
prev = newest_set(site_dir, first, weekend_pattern(cfg))
first = min(s["date"] for s in todo) if todo else None
prev = newest_set(site_dir, first, weekend_pattern(cfg)) if todo else None
if prev and cfg.get("save_songs", True):
status.write(step="SAVING SONGS")
saved = export_songs(prev, cfg, status, log)
@@ -1307,7 +1534,29 @@ def run(cfg):
log.extend(res["notes"])
status.data["results"].append(res)
status.write()
status.write(state="done", step="DONE")
# 7. In Live: the transposed clips consolidated, warp off (the nearest service's set is left open).
live = None
worked = bool(todo)
try:
for svc in sorted(cfg["services"], key=lambda s: s["date"], reverse=True) if cfg.get("consolidate", True) else []:
folder, name = target(cfg, svc)
d = os.path.join(folder, name)
als = [os.path.join(d, x) for x in os.listdir(d) if x.endswith(".als") and not x.startswith(".")] if os.path.isdir(d) else []
if not als or not pending(load(max(als, key=os.path.getmtime)), cfg.get("unpitched") or UNPITCHED):
continue
worked = True
try:
live = live or Live()
res = consolidate(max(als, key=os.path.getmtime), cfg, status, log, live)
except RuntimeError as e:
log.append("%s: CONSOLIDATE IN LIVE: %s" % (name.upper(), e))
continue
status.data["results"].append(res)
status.write()
finally:
if live:
live.close()
status.write(state="done", step="DONE" if worked else "NOTHING TO DO")
except Exception as e: # noqa: BLE001 - the key shows what stopped it
import traceback
status.write(state="failed", step=str(e)[:60].upper(), trace=traceback.format_exc()[-2000:])