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:
+90
-28
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user