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:
@@ -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
|
||||||
|
|||||||
+87
-25
@@ -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:
|
||||||
|
live.ask("/live/statuspanel/set_clip", i, tn, float(b), 0, 0)
|
||||||
reset += 1
|
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]:
|
missed.append(stem_name(tn))
|
||||||
live.ask("/live/statuspanel/set_clip", i, tn, float(beat), 0, 0)
|
continue
|
||||||
done += 1
|
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
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
if ok:
|
||||||
|
done += 1
|
||||||
else:
|
else:
|
||||||
missed.append(stem_name(tn))
|
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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user