START: render transposed clips with Freeze/Flatten, TEMPO leader clips at their own tempo

Tested in Live on copies of 261101 (2026-10-26):
- Edit > Consolidate cannot be aimed at one clip from a script (no LOM selection; Edit > Select Loop
  selects every track, so Consolidate merged the SECTIONS markers and lighting cues). Each pitched track is
  now Freeze Track + Flatten Track (same audio: the stems have no devices), warp off, named like Consolidate
  names it; the Freeze tail clips and "(Freeze)" track names are removed. Groups unfolded to select, refolded.
- The TEMPO label clips lead the song tempo (IsSongTempoMaster): START renamed the base set's 143 clip
  without its warp markers, so Live played 261101 at 143 and consolidating rendered songs at the wrong
  length. lead_tempo() makes the markers match the BPM.
- AbletonOSC commands match Live's numbered/stamped track names; new refold and drop_tails commands.
- A run that stopped picks up flattened clips; START's look runs before the ROUTING/IN EARS reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:26:07 -04:00
co-authored by Claude Opus 5.5
parent a3b38cbd5d
commit 47a2c12a12
5 changed files with 216 additions and 64 deletions
+90 -28
View File
@@ -22,8 +22,9 @@ every special service (First / Together Tuesday, Morning Worship, ...).
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
7 in Live: every transposed clip is rendered and its warp turned off (the workflow's last two steps,
consolidate and warp off: Freeze Track + Flatten Track on its one track, see consolidate()), 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.
@@ -851,6 +852,15 @@ def clone_clip(tmpl, start, end, name):
return c
def lead_tempo(c, bpm):
"""A TEMPO label clip leads the song tempo (Live's IsSongTempoMaster): the tempo is its warp markers', not its
name's. Markers made for this bpm, else Live plays (and consolidates) the song at the base set's tempo
(261101: "128.00 BPM" clips still marked 143 held the whole set at 143, 2026-10-26)."""
for m in c.findall("WarpMarkers/WarpMarker"):
m.set("SecTime", fmt(float(m.get("BeatTime")) * 60.0 / float(bpm)))
return c
def pad_clip(tmpl, path, start, end, bpm):
"""An ambient pad, unwarped (as the sets have them), from the start of the file."""
c = clone_clip(tmpl, start, end, os.path.splitext(os.path.basename(path))[0])
@@ -1185,7 +1195,7 @@ def build_set(svc, base_path, out_dir, name, cfg, status, log):
runs.append([start, bpm])
for i, (start, bpm) in enumerate(runs):
end = runs[i + 1][0] if i + 1 < len(runs) else end_all
c = clone_clip(tmpl["tempo"], start, end, "%.2f BPM" % bpm)
c = lead_tempo(clone_clip(tmpl["tempo"], start, end, "%.2f BPM" % bpm), bpm)
files.keep_in_place(c, base_proj)
add_clip(tempo_t, c)
# Clips the base set keeps (LTC, ...) come along into the new project.
@@ -1257,14 +1267,18 @@ def check(root):
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."""
start, pitched, clip name)]. 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)))
if c.tag != "AudioClip":
continue
flat = (val(c, "IsWarped") == "true" and "/Processed/Freeze/" in (val(c, "SampleRef/FileRef/Path") or "")
and not (val(c, "Name") or "").endswith("(Freeze tail)"))
if int(fval(c, "PitchCoarse")) != 0 or flat: # (flat: rendered by a run that stopped, warp still on)
out.append((i, tname(t), span(c)[0], pitched(tname(t), unpitched), val(c, "Name") or ""))
i += 1
return out
@@ -1353,6 +1367,12 @@ class Live:
r = self.ask("/live/statuspanel/clip", i, name, float(beat))
return tuple(r[3:7]) if r and len(r) >= 7 else None
def enabled(self, item, menu="Edit"):
r = subprocess.run(["/usr/bin/osascript", "-e", 'tell application "System Events" to tell process "Live" to '
'return enabled of menu item "%s" of menu "%s" of menu bar 1' % (item, menu)],
capture_output=True, text=True, timeout=30)
return r.stdout.strip() == "true"
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'
@@ -1392,7 +1412,7 @@ def open_in_live(live, path, todo, wait=180):
while time.time() < end:
time.sleep(2)
if live_set() == path:
i, name, beat, _ = todo[0]
i, name, beat = todo[0][:3]
got = live.clip(i, name, beat)
if got and got[0]:
return True
@@ -1400,18 +1420,30 @@ def open_in_live(live, path, todo, wait=180):
def save_live(path, live):
"""File > Save Live Set; True once the set on disk is newer (Live moves the old one to Backup first, so for a
moment there is no file)."""
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)
try:
if os.path.getmtime(path) != before:
time.sleep(1)
return True
except OSError:
pass
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."""
"""Step 7 for one set: in Live, every pitched transposed clip rendered as it plays (Complex Pro, transposed),
its warp turned off and named the way Consolidate names it ("Piano [2026-10-26 143548]"); an unpitched one put
back in its own key, warp off. Saved.
Rendered with Edit > Freeze Track then Flatten Track on that one track: a script cannot select a single clip in
the arrangement (Live's control scripts have no selection, and Edit > Select Loop selects every track, so
Consolidate would also merge the SECTIONS markers and lighting cues: tried 2026-10-26). The song stems have no
devices, so the flattened audio is what Consolidate makes."""
name = os.path.splitext(os.path.basename(path))[0]
todo = pending(load(path), cfg.get("unpitched") or UNPITCHED)
if not todo:
@@ -1426,32 +1458,62 @@ def consolidate(path, cfg, status, log, live=None):
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)}
by_track = {}
for i, tn, beat, is_pitched, cname in todo:
by_track.setdefault((i, tn, is_pitched), []).append((beat, cname))
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]:
for n, ((i, tn, is_pitched), cs) in enumerate(by_track.items(), 1):
status.write(step="CONSOLIDATING %d/%d" % (n, len(by_track)), live=time.time())
before = {b: live.clip(i, tn, b) for b, _ in cs}
# Flattened by a run that stopped before its warp went off: only that is left to do.
flat = [(b, c) for b, c in cs if before[b] and before[b][0] and not before[b][1] and before[b][2]
and "/Processed/Freeze/" in before[b][3]]
cs = [(b, c) for b, c in cs if before[b] and before[b][0] and before[b][1]]
if flat and not cs and is_pitched:
stamp = time.strftime("%Y-%m-%d %H%M%S")
for b, cname in flat:
live.ask("/live/statuspanel/set_clip", i, tn, float(b), 0, 0,
"%s [%s]" % (re.sub(r"\s*\[.*?\]", "", cname).strip() or stem_name(tn).title(), stamp))
done += 1
live.ask("/live/statuspanel/drop_tails", i, tn)
continue
if not cs:
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
for b, _ in cs:
live.ask("/live/statuspanel/set_clip", i, tn, float(b), 0, 0)
reset += 1
continue
live.ask("/live/statuspanel/select_clip", i, tn, float(beat))
sel = live.ask("/live/statuspanel/select_clip", i, tn, float(cs[0][0]))
time.sleep(0.3)
if not live.menu("Consolidate"):
missed.append(stem_name(tn))
if not (sel and sel[-1]) or not (live.enabled("Flatten Track") or live.menu("Freeze Track")):
missed.append(stem_name(tn)) # (a track frozen by a run that stopped: flattened now)
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:
while time.time() < end and not live.enabled("Flatten Track"):
time.sleep(1)
if not live.menu("Flatten Track"):
missed.append(stem_name(tn))
continue
stamp = time.strftime("%Y-%m-%d %H%M%S")
for b, cname in cs:
ok = False
for _ in range(60):
now = live.clip(i, tn, b)
if now and now[0] and now[1] == 0 and now[3] != before[b][3]:
live.ask("/live/statuspanel/set_clip", i, tn, float(b), 0, 0,
"%s [%s]" % (re.sub(r"\s*\[.*?\]", "", cname).strip() or stem_name(tn).title(), stamp))
ok = True
break
time.sleep(0.5)
if ok:
done += 1
else:
missed.append(stem_name(tn))
# Flatten leaves a short "(Freeze tail)" clip after each clip and "(Freeze)" in the track's name.
live.ask("/live/statuspanel/drop_tails", i, tn)
live.ask("/live/statuspanel/refold", wait=3)
if done or reset:
status.write(step="SAVING %s" % name.upper()[:14])
if not save_live(path, live):