diff --git a/README.md b/README.md index 9610ffa..f4eba74 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,10 @@ other special service `Projects/NA/ NA 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/ NA 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 diff --git a/ableton_start.py b/ableton_start.py index ec5486a..ff694e8 100644 --- a/ableton_start.py +++ b/ableton_start.py @@ -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): diff --git a/readiness.py b/readiness.py index ade3ddc..ce593ee 100644 --- a/readiness.py +++ b/readiness.py @@ -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) diff --git a/tests/test_start.py b/tests/test_start.py index 8d1edc1..a14c543 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -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( + "", '' + '')) + 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): diff --git a/tools/abletonosc/statuspanel.py b/tools/abletonosc/statuspanel.py index df2636d..3536592 100644 --- a/tools/abletonosc/statuspanel.py +++ b/tools/abletonosc/statuspanel.py @@ -6,15 +6,25 @@ Answers (, , , ). Undo in Live (Cmd+Z) puts it back. START's consolidating (ableton_start.py) finds an arrangement clip by (Live's, no returns), - (any track of that name when the index has moved) and the it starts at: + (any track of that name when the index has moved) and the 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 Answers (, , , , , , ). /live/statuspanel/select_clip - Selects the clip in the arrangement (Edit > Consolidate then works on it). Answers (..., ). -/live/statuspanel/set_clip - Sets the clip's transposition, then its warp. Answers (..., , , ). + 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 (..., ). +/live/statuspanel/refold + Folds the groups select_clip unfolded back up. Answers (). +/live/statuspanel/drop_tails + 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 (, , , ); deleted is -1 when this + Live cannot delete a clip from a script. +/live/statuspanel/set_clip [] + Sets the clip's transposition, then its warp (and its name). Answers (..., , , ). """ 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)