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 "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. 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 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). 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: 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, 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, 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 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 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 the job gives up after 3 minutes: **OPEN ... IN LIVE**, hold again). Then, one track at a time, for every pitched
it in the arrangement, clicks Edit › Consolidate (Live renders it with Complex Pro into track with a clip still transposed: it selects the track and clicks **Edit › Freeze Track, then Flatten Track**,
`Samples/Processed/Consolidate`), turns the new clip's warp off, and at the end clicks File › Save Live Set. An which renders the clip exactly as it plays (transposed, Complex Pro, at the set's tempo there) into
unpitched clip found transposed (a set built before 2026-10-26) only goes back to 0 with warp off. A clip that is `Samples/Processed/Freeze`; it turns the new clip's warp off and names it the way Consolidate does
not where the saved set has it (changed in Live since) is left and named. The key shows **CONSOLIDATING 12/49**; (`Piano [2026-10-26 143736]`, so routing and Finished Songs still know the stem). Not Edit › Consolidate itself: a
ROUTING and IN EARS show START IN LIVE meanwhile (the job holds AbletonOSC's answers on the Tracks Mac), and once script cannot select one clip in Live's arrangement, and Edit › Select Loop selects every track, so Consolidate
it is done NA Ingest hands AbletonOSC back to Companion. A hold of START does this for this week's sets that are also merged the SECTIONS markers and lighting cues (tested on a copy 2026-10-26). The song stems carry no devices,
already there too. Uses the status panel's AbletonOSC commands (`clip`, `select_clip`, `set_clip` in so the flattened audio is what Consolidate makes. An unpitched clip found transposed (a set built before
`tools/abletonosc/statuspanel.py`). 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 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 (Live finds no audio without it: 2026-10-22). A set that is already there is never touched; a new one is built as
+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); 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. 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; 7 in Live: every transposed clip is rendered 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 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 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. START does this for a set that is already there too.
@@ -851,6 +852,15 @@ def clone_clip(tmpl, start, end, name):
return c 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): def pad_clip(tmpl, path, start, end, bpm):
"""An ambient pad, unwarped (as the sets have them), from the start of the file.""" """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]) 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]) runs.append([start, bpm])
for i, (start, bpm) in enumerate(runs): for i, (start, bpm) in enumerate(runs):
end = runs[i + 1][0] if i + 1 < len(runs) else end_all 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) files.keep_in_place(c, base_proj)
add_clip(tempo_t, c) add_clip(tempo_t, c)
# Clips the base set keeps (LTC, ...) come along into the new project. # Clips the base set keeps (LTC, ...) come along into the new project.
@@ -1257,14 +1267,18 @@ def check(root):
def pending(root, unpitched=UNPITCHED): def pending(root, unpitched=UNPITCHED):
"""The transposed clips a set still holds: [(track index as Live counts them (no returns), track name, clip """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 out, i = [], 0
for t in tracks(root.find("LiveSet")): for t in tracks(root.find("LiveSet")):
if t.tag == "ReturnTrack": if t.tag == "ReturnTrack":
continue continue
for c in clips(t): for c in clips(t):
if c.tag == "AudioClip" and int(fval(c, "PitchCoarse")) != 0: if c.tag != "AudioClip":
out.append((i, tname(t), span(c)[0], pitched(tname(t), unpitched))) 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 i += 1
return out return out
@@ -1353,6 +1367,12 @@ class Live:
r = self.ask("/live/statuspanel/clip", i, name, float(beat)) r = self.ask("/live/statuspanel/clip", i, name, float(beat))
return tuple(r[3:7]) if r and len(r) >= 7 else None 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"): def menu(self, item, menu="Edit"):
"""Click a menu item of Live's; False when it is greyed out (nothing selected).""" """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' 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: while time.time() < end:
time.sleep(2) time.sleep(2)
if live_set() == path: if live_set() == path:
i, name, beat, _ = todo[0] i, name, beat = todo[0][:3]
got = live.clip(i, name, beat) got = live.clip(i, name, beat)
if got and got[0]: if got and got[0]:
return True return True
@@ -1400,18 +1420,30 @@ def open_in_live(live, path, todo, wait=180):
def save_live(path, live): 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) before = os.path.getmtime(path)
live.menu("Save Live Set", "File") live.menu("Save Live Set", "File")
for _ in range(120): for _ in range(120):
if os.path.getmtime(path) != before:
return True
time.sleep(0.25) time.sleep(0.25)
try:
if os.path.getmtime(path) != before:
time.sleep(1)
return True
except OSError:
pass
return False return False
def consolidate(path, cfg, status, log, live=None): def consolidate(path, cfg, status, log, live=None):
"""Step 7 for one set: in Live, every pitched transposed clip consolidated (Edit > Consolidate renders it """Step 7 for one set: in Live, every pitched transposed clip rendered as it plays (Complex Pro, transposed),
with Complex Pro) and its warp turned off; an unpitched one put back in its own key, warp off. Saved.""" 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] name = os.path.splitext(os.path.basename(path))[0]
todo = pending(load(path), cfg.get("unpitched") or UNPITCHED) todo = pending(load(path), cfg.get("unpitched") or UNPITCHED)
if not todo: if not todo:
@@ -1426,32 +1458,62 @@ def consolidate(path, cfg, status, log, live=None):
if not open_in_live(live, path, todo): if not open_in_live(live, path, todo):
log.append("OPEN %s IN LIVE (SAVE THE SET THAT IS OPEN), HOLD START" % name.upper()) log.append("OPEN %s IN LIVE (SAVE THE SET THAT IS OPEN), HOLD START" % name.upper())
return {"name": name, "consolidated": 0, "left": len(todo)} 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, [] done, reset, missed = 0, 0, []
for n, (i, tn, beat, is_pitched) in enumerate(todo, 1): for n, ((i, tn, is_pitched), cs) in enumerate(by_track.items(), 1):
status.write(step="CONSOLIDATING %d/%d" % (n, len(todo)), live=time.time()) status.write(step="CONSOLIDATING %d/%d" % (n, len(by_track)), live=time.time())
got = live.clip(i, tn, beat) before = {b: live.clip(i, tn, b) for b, _ in cs}
if not got or not got[0] or not got[1]: # 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 missed.append(stem_name(tn)) # changed in Live since it was saved
continue continue
if not is_pitched: if not is_pitched:
live.ask("/live/statuspanel/set_clip", i, tn, float(beat), 0, 0) for b, _ in cs:
reset += 1 live.ask("/live/statuspanel/set_clip", i, tn, float(b), 0, 0)
reset += 1
continue 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) time.sleep(0.3)
if not live.menu("Consolidate"): if not (sel and sel[-1]) or not (live.enabled("Flatten Track") or live.menu("Freeze Track")):
missed.append(stem_name(tn)) missed.append(stem_name(tn)) # (a track frozen by a run that stopped: flattened now)
continue continue
end = time.time() + cfg.get("consolidate_wait", 300) end = time.time() + cfg.get("consolidate_wait", 300)
while time.time() < end: while time.time() < end and not live.enabled("Flatten Track"):
time.sleep(0.5) time.sleep(1)
now = live.clip(i, tn, beat) if not live.menu("Flatten Track"):
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)) 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: if done or reset:
status.write(step="SAVING %s" % name.upper()[:14]) status.write(step="SAVING %s" % name.upper()[:14])
if not save_live(path, live): 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) lines["track_bundle"] = eval_track_bundle(rc, al)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["track_bundle"] if rc.get("locations") else []), e) 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(): if rc.get("tracks_host") and rc.get("ableton_osc") and start_in_live():
for k in ("routing", "ears"): for k in ("routing", "ears"):
lines[k] = Line() 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"]()) lines["lineup"] = eval_lineup(rc, plan, src["lineup"]())
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
failed(["lineup"], e if isinstance(e, ReadyError) else ReadyError("LINEUP UNREACHABLE")) 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: if ((cfg.get("resi_events") or {}).get("pco_specials")) and not sources:
try: try:
LAST["resi_specials"] = resi_specials(rc, now) 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) p = lambda n: os.path.join(base, "Samples/Imported", n)
ext = ("Ext. Out", "5/6 Keys", "AudioOut/External/S2") ext = ("Ext. Out", "5/6 Keys", "AudioOut/External/S2")
last_week = ( 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("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("MidiTrack", 59, "SECTIONS", clips=midi_clip(0, 196, "CALL TO WORSHIP") + midi_clip(196, 236, "INTRO")) +
track("GroupTrack", 60, "MIDI GROUP") + 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"))) 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"])] 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")) 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) self.assertIn((tempo[-1][0], "72.00 BPM"), tempo)
notes = st["results"][0]["notes"] notes = st["results"][0]["notes"]
self.assertIn("TRANSPOSE GRACE E>B", 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.""" """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): 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.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.frozen, self.calls = None, None, []
self.selected, self.calls = None, []
def ask(self, addr, *args, wait=5.0): def ask(self, addr, *args, wait=5.0):
self.calls.append((addr,) + args) self.calls.append((addr,) + args)
@@ -418,20 +423,30 @@ class FakeLive:
if addr == "/live/statuspanel/select_clip": if addr == "/live/statuspanel/select_clip":
self.selected = (args[0], round(args[2], 3)) self.selected = (args[0], round(args[2], 3))
return list(args) + [1] return list(args) + [1]
if addr == "/live/statuspanel/drop_tails":
return list(args) + [1, args[1]]
if addr == "/live/statuspanel/set_clip": if addr == "/live/statuspanel/set_clip":
c = self.clips[(args[0], round(args[2], 3))] c = self.clips[(args[0], round(args[2], 3))]
c[1], c[2] = args[3], args[4] 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 list(args[:3]) + c[:3]
return None return None
def clip(self, i, name, beat): def clip(self, i, name, beat):
return tuple(self.clips.get((i, round(beat, 3)), [0, 0, 0, ""])) 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"): def menu(self, item, menu="Edit"):
self.calls.append((menu, item)) self.calls.append((menu, item))
if item == "Consolidate" and self.selected: if item == "Freeze Track" and self.selected:
c = self.clips[self.selected] self.frozen = self.selected[0]
c[1], c[3] = 0, "/Samples/Processed/Consolidate/x.wav" 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 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")) + 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"), [])) track("AudioTrack", 3, "26-Drums", "1", clip("Drums")) + track("AudioTrack", 4, "7-Bass", "1"), []))
todo = A.pending(A.load(path)) 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) live = FakeLive(todo)
A.STATUS = os.path.join(d, "status.json") A.STATUS = os.path.join(d, "status.json")
status = A.Status({}) status = A.Status({})
@@ -456,14 +471,16 @@ class TestConsolidate(unittest.TestCase):
finally: finally:
A.live_set, A.save_live = orig A.live_set, A.save_live = orig
self.assertEqual((res["consolidated"], res["reset"], res["left"]), (1, 1, 0)) 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[(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 consolidated 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", "Consolidate")]) 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.assertIn("261101 NA PROJECT: 1 UNPITCHED CLIPS PUT BACK IN THEIR KEY", log)
self.assertTrue(status.data.get("live")) self.assertTrue(status.data.get("live"))
def test_a_clip_changed_in_live_is_left(self): 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 = FakeLive(todo)
live.clips[(1, 1992.0)][0] = 0 # not there any more live.clips[(1, 1992.0)][0] = 0 # not there any more
d = tempfile.mkdtemp() d = tempfile.mkdtemp()
@@ -479,7 +496,7 @@ class TestConsolidate(unittest.TestCase):
finally: finally:
A.live_set = orig A.live_set = orig
self.assertEqual((res["consolidated"], res["left"]), (0, 1)) 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): 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. 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), 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> /live/statuspanel/clip <index> <name> <beat>
Answers (<index>, <name>, <beat>, <found>, <pitch_coarse>, <warping>, <file path>). Answers (<index>, <name>, <beat>, <found>, <pitch_coarse>, <warping>, <file path>).
/live/statuspanel/select_clip <index> <name> <beat> /live/statuspanel/select_clip <index> <name> <beat>
Selects the clip in the arrangement (Edit > Consolidate then works on it). Answers (..., <found>). Selects the clip in the arrangement (Edit > Consolidate then works on it), unfolding the groups it is in (Live
/live/statuspanel/set_clip <index> <name> <beat> <pitch_coarse> <warping> cannot select a track inside a folded group). Answers (..., <found>).
Sets the clip's transposition, then its warp. Answers (..., <found>, <pitch_coarse>, <warping>). /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 logging
import re
import Live import Live
@@ -46,12 +56,18 @@ def register(handler):
handler.osc_server.add_handler("/live/statuspanel/move_note", move_note) 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): def find(params):
song = handler.song 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) ts = list(song.tracks)
order = ([ts[i]] if 0 <= i < len(ts) and ts[i].name.strip().upper() == name else []) + \ order = ([ts[i]] if 0 <= i < len(ts) and bare(ts[i].name) == name else []) + \
[t for t in ts if t.name.strip().upper() == name] [t for t in ts if bare(t.name) == name]
for t in order: for t in order:
try: try:
arr = t.arrangement_clips arr = t.arrangement_clips
@@ -71,10 +87,18 @@ def register(handler):
_, c = find(params) _, c = find(params)
return (int(params[0]), str(params[1]), float(params[2])) + info(c) return (int(params[0]), str(params[1]), float(params[2])) + info(c)
unfolded = []
def select_clip(params): def select_clip(params):
t, c = find(params) t, c = find(params)
if c is not None: if c is not None:
song = handler.song 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 = Live.Application.get_application().view
view.show_view("Arranger") view.show_view("Arranger")
view.focus_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) 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) 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): def set_clip(params):
_, c = find(params) _, c = find(params)
if c is not None: if c is not None:
c.pitch_coarse = int(params[3]) c.pitch_coarse = int(params[3])
c.warping = bool(int(params[4])) 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]) 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] 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/clip", clip)
handler.osc_server.add_handler("/live/statuspanel/select_clip", select_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/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)