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
+21 -10
View File
@@ -205,7 +205,10 @@ other special service `Projects/NA/<date> NA <plan title> Project`.
"5- SALVATION" did with 3 songs); more: slots are added like the last one. A song too long for its slot pushes the
loop locators, loop cues and later slots along in whole bars, so a loop never fires inside a song.
4. **Tempo map** and one **TEMPO** label clip per tempo (`128.00 BPM`). The tempo is the one the song's audio was
cut at; a different PCO tempo is named in the log.
cut at; a different PCO tempo is named in the log. The label clips **lead the song tempo** (Live's leader /
`IsSongTempoMaster`): Live plays the tempo their warp markers say, over the tempo map, so each clip's markers are
made for its BPM. (Until 2026-10-26 they kept the base set's 143 under every name, so 261101 played, and was
consolidated, at 143 throughout.)
5. **Waves Tune** key cue at every locator (minor keys use their relative major).
6. **Each song from Finished Songs** at its locator, in its PCO key if that version exists, else the nearest key:
then its **pitched** stems are transposed the PDF's way (warp on, Complex Pro, formants 0, transpose). Drums,
@@ -222,15 +225,23 @@ other special service `Projects/NA/<date> NA <plan title> Project`.
9. **Consolidate, warp off** (in Live, `ready.start_consolidate`): the PDF's order is warp on, Complex Pro,
formants 0, transpose, **consolidate, warp off**. The first four are in the set file; for the last two the job opens
the set in Live (when another set is open, and never while Live plays; Live asks about unsaved changes itself, and
the job gives up after 3 minutes: **OPEN ... IN LIVE**, hold again), then for every clip still transposed: selects
it in the arrangement, clicks Edit › Consolidate (Live renders it with Complex Pro into
`Samples/Processed/Consolidate`), turns the new clip's warp off, and at the end clicks File › Save Live Set. An
unpitched clip found transposed (a set built before 2026-10-26) only goes back to 0 with warp off. A clip that is
not where the saved set has it (changed in Live since) is left and named. The key shows **CONSOLIDATING 12/49**;
ROUTING and IN EARS show START IN LIVE meanwhile (the job holds AbletonOSC's answers on the Tracks Mac), and once
it is done NA Ingest hands AbletonOSC back to Companion. A hold of START does this for this week's sets that are
already there too. Uses the status panel's AbletonOSC commands (`clip`, `select_clip`, `set_clip` in
`tools/abletonosc/statuspanel.py`).
the job gives up after 3 minutes: **OPEN ... IN LIVE**, hold again). Then, one track at a time, for every pitched
track with a clip still transposed: it selects the track and clicks **Edit › Freeze Track, then Flatten Track**,
which renders the clip exactly as it plays (transposed, Complex Pro, at the set's tempo there) into
`Samples/Processed/Freeze`; it turns the new clip's warp off and names it the way Consolidate does
(`Piano [2026-10-26 143736]`, so routing and Finished Songs still know the stem). Not Edit › Consolidate itself: a
script cannot select one clip in Live's arrangement, and Edit › Select Loop selects every track, so Consolidate
also merged the SECTIONS markers and lighting cues (tested on a copy 2026-10-26). The song stems carry no devices,
so the flattened audio is what Consolidate makes. An unpitched clip found transposed (a set built before
2026-10-26) only goes back to 0 with warp off. A clip that is not where the saved set has it (changed in Live since)
is left and named. At the end the groups it unfolded are folded again and File › Save Live Set is clicked (about
10 s a track). The key shows **CONSOLIDATING 12/44**; ROUTING and IN EARS show START IN LIVE meanwhile (the job holds
AbletonOSC's answers on the Tracks Mac), and once it is done NA Ingest hands AbletonOSC back to Companion. A hold of
START does this for this week's sets that are already there too. Uses the status panel's AbletonOSC commands
(`clip`, `select_clip`, `set_clip`, `refold` in `tools/abletonosc/statuspanel.py`; a track with no name of its own
shows its number in Live, "56-Piano" for the file's "5-Piano", so names are matched without it).
**The tempo matters**: a transposed clip is warped, so it is rendered at the tempo Live plays there. With the
TEMPO label clips leading at a wrong tempo (step 4) a song is rendered at the wrong length.
Every audio file is copied into the new project (as Collect All and Save does) with an `Ableton Project Info` folder
(Live finds no audio without it: 2026-10-22). A set that is already there is never touched; a new one is built as
+87 -25
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)
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
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):
+7 -6
View File
@@ -1848,6 +1848,13 @@ def look(cfg, now=None, data_dir=None, sources=None):
lines["track_bundle"] = eval_track_bundle(rc, al)
except Exception as e: # noqa: BLE001
failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["track_bundle"] if rc.get("locations") else []), e)
# START first: while it consolidates in Live it holds AbletonOSC's answers on the Tracks Mac.
if start_on(rc):
try:
services = src["services"]()
lines["start"] = eval_start(rc, services, src["start"](services), now)
except Exception as e: # noqa: BLE001
failed(["start"], e)
if rc.get("tracks_host") and rc.get("ableton_osc") and start_in_live():
for k in ("routing", "ears"):
lines[k] = Line()
@@ -1862,12 +1869,6 @@ def look(cfg, now=None, data_dir=None, sources=None):
lines["lineup"] = eval_lineup(rc, plan, src["lineup"]())
except Exception as e: # noqa: BLE001
failed(["lineup"], e if isinstance(e, ReadyError) else ReadyError("LINEUP UNREACHABLE"))
if start_on(rc):
try:
services = src["services"]()
lines["start"] = eval_start(rc, services, src["start"](services), now)
except Exception as e: # noqa: BLE001
failed(["start"], e)
if ((cfg.get("resi_events") or {}).get("pco_specials")) and not sources:
try:
LAST["resi_specials"] = resi_specials(rc, now)
+30 -13
View File
@@ -214,7 +214,9 @@ class TestBuild(unittest.TestCase):
p = lambda n: os.path.join(base, "Samples/Imported", n)
ext = ("Ext. Out", "5/6 Keys", "AudioOut/External/S2")
last_week = (
track("AudioTrack", 57, "TEMPO", clips=audio_clip(0, 1132, "143.00 BPM", "Samples/Recorded/x.aif", warped=True)) +
track("AudioTrack", 57, "TEMPO", clips=audio_clip(0, 1132, "143.00 BPM", "Samples/Recorded/x.aif", warped=True).replace(
"<WarpMarkers />", '<WarpMarkers><WarpMarker Id="0" SecTime="0" BeatTime="0" />'
'<WarpMarker Id="1" SecTime="0.013111888111888112" BeatTime="0.03125" /></WarpMarkers>')) +
track("AudioTrack", 58, "LTC", clips=audio_clip(0, 4000, "LTC", p("LTC.wav"), "Samples/Imported/LTC.wav", True)) +
track("MidiTrack", 59, "SECTIONS", clips=midi_clip(0, 196, "CALL TO WORSHIP") + midi_clip(196, 236, "INTRO")) +
track("GroupTrack", 60, "MIDI GROUP") +
@@ -328,6 +330,10 @@ class TestBuild(unittest.TestCase):
self.assertTrue(os.path.exists(os.path.join(os.path.dirname(path), "Samples/Imported/LTC.wav")))
tempo = [(A.span(c)[0], A.val(c, "Name")) for c in A.clips(by["TEMPO"])]
self.assertEqual(tempo[0], (0.0, "72.00 BPM"))
for c in A.clips(by["TEMPO"]): # a leader clip: its markers are the tempo Live plays
bpm = float(A.val(c, "Name").split()[0])
for m in c.findall("WarpMarkers/WarpMarker"):
self.assertAlmostEqual(float(m.get("SecTime")), float(m.get("BeatTime")) * 60 / bpm)
self.assertIn((tempo[-1][0], "72.00 BPM"), tempo)
notes = st["results"][0]["notes"]
self.assertIn("TRANSPOSE GRACE E>B", notes)
@@ -407,9 +413,8 @@ class FakeLive:
"""Live as AbletonOSC and the menus show it: one clip per track, consolidated when Edit > Consolidate is clicked."""
def __init__(self, todo, open_set=True):
self.clips = {(i, b): [1, -5, 1, "/Samples/Imported/%s.wav" % n] for i, n, b, _ in todo}
self.names = {i: n for i, n, _, _ in todo}
self.selected, self.calls = None, []
self.clips = {(i, b): [1, -5, 1, "/Samples/Imported/%s.wav" % n] for i, n, b, _, _ in todo}
self.selected, self.frozen, self.calls = None, None, []
def ask(self, addr, *args, wait=5.0):
self.calls.append((addr,) + args)
@@ -418,20 +423,30 @@ class FakeLive:
if addr == "/live/statuspanel/select_clip":
self.selected = (args[0], round(args[2], 3))
return list(args) + [1]
if addr == "/live/statuspanel/drop_tails":
return list(args) + [1, args[1]]
if addr == "/live/statuspanel/set_clip":
c = self.clips[(args[0], round(args[2], 3))]
c[1], c[2] = args[3], args[4]
if len(args) > 5:
self.calls.append(("name", args[5]))
return list(args[:3]) + c[:3]
return None
def clip(self, i, name, beat):
return tuple(self.clips.get((i, round(beat, 3)), [0, 0, 0, ""]))
def enabled(self, item, menu="Edit"):
return item == "Flatten Track" and self.frozen is not None
def menu(self, item, menu="Edit"):
self.calls.append((menu, item))
if item == "Consolidate" and self.selected:
c = self.clips[self.selected]
c[1], c[3] = 0, "/Samples/Processed/Consolidate/x.wav"
if item == "Freeze Track" and self.selected:
self.frozen = self.selected[0]
if item == "Flatten Track":
for (i, b), c in self.clips.items():
if i == self.frozen:
c[1], c[3] = 0, "/Samples/Processed/Freeze/Freeze x.wav"
return True
@@ -444,7 +459,7 @@ class TestConsolidate(unittest.TestCase):
write_als(path, live_set(track("GroupTrack", 1, "FULL SURRENDER") + track("AudioTrack", 2, "6-Piano", "1", clip("Piano")) +
track("AudioTrack", 3, "26-Drums", "1", clip("Drums")) + track("AudioTrack", 4, "7-Bass", "1"), []))
todo = A.pending(A.load(path))
self.assertEqual(todo, [(1, "6-Piano", 1992.0, True), (2, "26-Drums", 1992.0, False)])
self.assertEqual(todo, [(1, "6-Piano", 1992.0, True, "Piano"), (2, "26-Drums", 1992.0, False, "Drums")])
live = FakeLive(todo)
A.STATUS = os.path.join(d, "status.json")
status = A.Status({})
@@ -456,14 +471,16 @@ class TestConsolidate(unittest.TestCase):
finally:
A.live_set, A.save_live = orig
self.assertEqual((res["consolidated"], res["reset"], res["left"]), (1, 1, 0))
self.assertEqual(live.clips[(1, 1992.0)], [1, 0, 0, "/Samples/Processed/Consolidate/x.wav"]) # consolidated, warp off
self.assertEqual(live.clips[(2, 1992.0)][:3], [1, 0, 0]) # drums: own key, not consolidated
self.assertEqual([c for c in live.calls if c[0] == "Edit"], [("Edit", "Consolidate")])
self.assertEqual(live.clips[(1, 1992.0)], [1, 0, 0, "/Samples/Processed/Freeze/Freeze x.wav"]) # rendered, warp off
self.assertEqual(live.clips[(2, 1992.0)][:3], [1, 0, 0]) # drums: own key, not rendered
self.assertEqual([c for c in live.calls if c[0] == "Edit"], [("Edit", "Freeze Track"), ("Edit", "Flatten Track")])
self.assertTrue([c for c in live.calls if c[0] == "name" and c[1].startswith("Piano [20")]) # named as Consolidate names it
self.assertIn(("/live/statuspanel/drop_tails", 1, "6-Piano"), live.calls) # no Freeze tail left
self.assertIn("261101 NA PROJECT: 1 UNPITCHED CLIPS PUT BACK IN THEIR KEY", log)
self.assertTrue(status.data.get("live"))
def test_a_clip_changed_in_live_is_left(self):
todo = [(1, "6-Piano", 1992.0, True)]
todo = [(1, "6-Piano", 1992.0, True, "Piano")]
live = FakeLive(todo)
live.clips[(1, 1992.0)][0] = 0 # not there any more
d = tempfile.mkdtemp()
@@ -479,7 +496,7 @@ class TestConsolidate(unittest.TestCase):
finally:
A.live_set = orig
self.assertEqual((res["consolidated"], res["left"]), (0, 1))
self.assertNotIn(("Edit", "Consolidate"), live.calls)
self.assertNotIn(("Edit", "Freeze Track"), live.calls)
class TestHandBack(unittest.TestCase):
+68 -7
View File
@@ -6,15 +6,25 @@
Answers (<track name>, <beat>, <pitch>, <moved: 0 or 1>). Undo in Live (Cmd+Z) puts it back.
START's consolidating (ableton_start.py) finds an arrangement clip by <track index> (Live's, no returns),
<track name> (any track of that name when the index has moved) and the <beat> it starts at:
<track name> (any track of that name when the index has moved) and the <beat> it starts at. A track with no name of
its own shows its number in Live ("56-Piano" is "5-Piano" in the set file) and its clip's name ("Piano [2026-10-26
113835]" after a consolidate, "Piano (Freeze)" after a flatten), so only the name in between is compared:
/live/statuspanel/clip <index> <name> <beat>
Answers (<index>, <name>, <beat>, <found>, <pitch_coarse>, <warping>, <file path>).
/live/statuspanel/select_clip <index> <name> <beat>
Selects the clip in the arrangement (Edit > Consolidate then works on it). Answers (..., <found>).
/live/statuspanel/set_clip <index> <name> <beat> <pitch_coarse> <warping>
Sets the clip's transposition, then its warp. Answers (..., <found>, <pitch_coarse>, <warping>).
Selects the clip in the arrangement (Edit > Consolidate then works on it), unfolding the groups it is in (Live
cannot select a track inside a folded group). Answers (..., <found>).
/live/statuspanel/refold
Folds the groups select_clip unfolded back up. Answers (<groups folded>).
/live/statuspanel/drop_tails <index> <name>
Deletes the "... (Freeze tail)" clips Flatten Track leaves after a clip, and takes "(Freeze)" out of the track's
name (back to Live's own name). Answers (<index>, <name>, <deleted>, <track name>); deleted is -1 when this
Live cannot delete a clip from a script.
/live/statuspanel/set_clip <index> <name> <beat> <pitch_coarse> <warping> [<new clip name>]
Sets the clip's transposition, then its warp (and its name). Answers (..., <found>, <pitch_coarse>, <warping>).
"""
import logging
import re
import Live
@@ -46,12 +56,18 @@ def register(handler):
handler.osc_server.add_handler("/live/statuspanel/move_note", move_note)
def bare(n):
# "56-Piano [2026-10-26 113835]" and "56-Piano (Freeze)" are the file's "5-Piano"
n = re.sub(r"^\s*\d+\s*-\s*", "", str(n))
n = re.sub(r"\[.*?\]|\(freeze\)", " ", n, flags=re.I)
return " ".join(n.split()).upper()
def find(params):
song = handler.song
i, name, beat = int(params[0]), str(params[1]).strip().upper(), float(params[2])
i, name, beat = int(params[0]), bare(params[1]), float(params[2])
ts = list(song.tracks)
order = ([ts[i]] if 0 <= i < len(ts) and ts[i].name.strip().upper() == name else []) + \
[t for t in ts if t.name.strip().upper() == name]
order = ([ts[i]] if 0 <= i < len(ts) and bare(ts[i].name) == name else []) + \
[t for t in ts if bare(t.name) == name]
for t in order:
try:
arr = t.arrangement_clips
@@ -71,10 +87,18 @@ def register(handler):
_, c = find(params)
return (int(params[0]), str(params[1]), float(params[2])) + info(c)
unfolded = []
def select_clip(params):
t, c = find(params)
if c is not None:
song = handler.song
g = t.group_track if t.is_grouped else None
while g is not None:
if g.fold_state:
g.fold_state = 0
unfolded.append(g)
g = g.group_track if g.is_grouped else None
view = Live.Application.get_application().view
view.show_view("Arranger")
view.focus_view("Arranger")
@@ -84,14 +108,51 @@ def register(handler):
logger.info("statuspanel: select %s at %s: %s", params[1], params[2], c is not None)
return int(params[0]), str(params[1]), float(params[2]), int(c is not None)
def refold(params):
n = 0
while unfolded:
g = unfolded.pop()
try:
g.fold_state = 1
n += 1
except Exception:
pass
return (n,)
def set_clip(params):
_, c = find(params)
if c is not None:
c.pitch_coarse = int(params[3])
c.warping = bool(int(params[4]))
if len(params) > 5 and str(params[5]):
c.name = str(params[5])
logger.info("statuspanel: %s at %s: pitch %s warp %s", params[1], params[2], params[3], params[4])
return (int(params[0]), str(params[1]), float(params[2])) + info(c)[:3]
handler.osc_server.add_handler("/live/statuspanel/clip", clip)
handler.osc_server.add_handler("/live/statuspanel/select_clip", select_clip)
handler.osc_server.add_handler("/live/statuspanel/set_clip", set_clip)
handler.osc_server.add_handler("/live/statuspanel/refold", refold)
def drop_tails(params):
i, name = int(params[0]), bare(params[1])
ts = list(handler.song.tracks)
order = ([ts[i]] if 0 <= i < len(ts) and bare(ts[i].name) == name else []) + [t for t in ts if bare(t.name) == name]
if not order:
return i, str(params[1]), 0, ""
t = order[0]
n = -1
if hasattr(t, "delete_clip"):
n = 0
for c in list(t.arrangement_clips):
if c.name.endswith("(Freeze tail)"):
t.delete_clip(c)
n += 1
if "(Freeze)" in t.name:
t.name = "" # Live's own name again, from the renamed clip
if "(Freeze)" in t.name:
t.name = re.sub(r"\s*\(Freeze\)", "", t.name)
logger.info("statuspanel: %s: %d freeze tails deleted, now %s", params[1], n, t.name)
return i, str(params[1]), n, str(t.name)
handler.osc_server.add_handler("/live/statuspanel/drop_tails", drop_tails)