START: bright red guide, no transposing unpitched stems, consolidate + warp off in Live
Per the Location Setup worksheet (2026-10-26): - a song saved twice holds two guides; START takes the bright red one (Live clip color 14), not the newer light pink one - drums, percussion, aux drums and other unpitched stems are never transposed (by stem name; sub bass and FX are) - every transposed clip is consolidated in Live (Edit > Consolidate) and its warp turned off, then the set is saved; a hold of START does it for this week's existing sets too. New AbletonOSC commands: clip, select_clip, set_clip - the START key's CONSOLIDATE comes from the saved set (clips still transposed), not the last build's log Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+264
-15
@@ -12,15 +12,21 @@ every special service (First / Together Tuesday, Morning Worship, ...).
|
||||
the numbered locators (with their computer-key mappings), the loop locators and the loop cues stay.
|
||||
3 the locators are named "N- TITLE - KEY" from PCO, the tempo map and the TEMPO label clips follow the
|
||||
songs, and a Waves Tune key cue goes at every locator
|
||||
4 every song comes in from Finished Songs at its locator. In another key it is transposed the way the
|
||||
workflow says (warp on, Complex Pro, formants 0, transpose) and flagged, because consolidating it is
|
||||
done in Live. Its markers, guide and lighting cues go onto the dedicated tracks.
|
||||
4 every song comes in from Finished Songs at its locator. In another key its pitched stems are transposed
|
||||
the way the workflow says (warp on, Complex Pro, formants 0, transpose); drums, percussion, aux drums
|
||||
and other unpitched stems are left as they are (UNPITCHED). Its markers, bright red guide (a song saved
|
||||
twice holds a light pink one too) and lighting cues go onto the dedicated tracks.
|
||||
5 pads: the Call to Worship pad in the first song's key, an ending pad per song past its loop, Salvation
|
||||
6 every stem routed per the routing template, send A (in-ears) at 0 dB
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Every audio file the set uses is copied into its project folder (as Collect All and Save does), so the
|
||||
project can be sent to the locations as it is. Nothing is overwritten: a set that is there is left alone,
|
||||
and a new set is built in "<name>.building" and only renamed when it is complete.
|
||||
@@ -46,6 +52,11 @@ NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I)
|
||||
NUMBERED = re.compile(r"^\s*(\d+)\s*-")
|
||||
SALVATION = re.compile(r"salvation", re.I)
|
||||
# Stems that have no key: never transposed (the workflow: "drums, percussion, aux drums, and other unpitched
|
||||
# sounds"). Sub bass, timpani and FX are pitched. A bare LOOP stem is a drum loop (SYNTH / VOX LOOP are not).
|
||||
UNPITCHED = (r"\b(DRUMS?|PERC(USSION)?|SHAKERS?|TAMB(OURINE)?S?|CYMBALS?|TOMS|KICK|SNARE|HI ?HATS?|HATS|CLAPS?|SNAPS"
|
||||
r"|TIMBALES|CONGAS?|BONGOS?|CAJON|HITS?|IMPACT|BOOM|CLICK|BPM|TEMPO|LTC|GUIDE)\b|^LOOP\b")
|
||||
GUIDE_RED = 14 # Live's bright red (FF3636); the other guide is light pink (0 or 28)
|
||||
AUDIO_EVENTS = "DeviceChain/MainSequencer/Sample/ArrangerAutomation/Events"
|
||||
MIDI_EVENTS = "DeviceChain/MainSequencer/ClipTimeable/ArrangerAutomation/Events"
|
||||
INITIAL = -63072000.0 # the time Live gives an envelope's starting value
|
||||
@@ -117,6 +128,11 @@ def stem_name(name):
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def pitched(name, unpitched=UNPITCHED):
|
||||
"""A stem that follows the song's key ("Sub Bass", "Piano 2"), not a drum or percussion one."""
|
||||
return not re.search(unpitched, stem_name(name).replace("_", " "))
|
||||
|
||||
|
||||
def template_for(routing, name):
|
||||
n = stem_name(name)
|
||||
if n in routing:
|
||||
@@ -894,9 +910,9 @@ def dated(t):
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def song_parts(fam):
|
||||
def song_parts(fam, red=GUIDE_RED):
|
||||
"""A finished song's group split into its stems and the three helper tracks the workflow puts in it
|
||||
(one of each: a song saved twice can hold two guides; the newest is the one)."""
|
||||
(one of each). A song saved twice holds two guides: the bright red one is the one (the newest red)."""
|
||||
stems, sections, lighting, guide = [], [], [], []
|
||||
for t in fam[1:]:
|
||||
cs = clips(t)
|
||||
@@ -911,7 +927,7 @@ def song_parts(fam):
|
||||
stems.append(t)
|
||||
pick = lambda ts, key: clips(max(ts, key=key)) if ts else []
|
||||
return (stems, pick(sections, lambda t: len(clips(t))), pick(lighting, lambda t: len(clips(t))),
|
||||
pick(guide, lambda t: (dated(t), -fam.index(t))))
|
||||
pick(guide, lambda t: (any(val(c, "Color") == str(red) for c in clips(t)), dated(t), -fam.index(t))))
|
||||
|
||||
|
||||
def load_song(entry, cfg):
|
||||
@@ -926,7 +942,7 @@ def load_song(entry, cfg):
|
||||
if not tops:
|
||||
return None
|
||||
fam = family(ls, tops[0])
|
||||
stems, sections, lighting, guide = song_parts(fam)
|
||||
stems, sections, lighting, guide = song_parts(fam, cfg.get("guide_color", GUIDE_RED))
|
||||
audio = [c for t in stems for c in clips(t) if c.tag == "AudioClip"]
|
||||
if not audio:
|
||||
return None
|
||||
@@ -971,7 +987,7 @@ def build_set(svc, base_path, out_dir, name, cfg, status, log):
|
||||
if got:
|
||||
info.update(got, semis=semis, key=s["key"], from_key=got["key"])
|
||||
if semis:
|
||||
log.append("TRANSPOSE %s %s>%s: CONSOLIDATE IN LIVE" % (got["title"].upper(), got["key"], s["key"]))
|
||||
log.append("TRANSPOSE %s %s>%s" % (got["title"].upper(), got["key"], s["key"]))
|
||||
if s.get("bpm") and abs(float(s["bpm"]) - got["bpm"]) > 0.6:
|
||||
log.append("TEMPO %s: PCO %s, SONG %g" % (got["title"].upper(), s["bpm"], got["bpm"]))
|
||||
else:
|
||||
@@ -1097,7 +1113,7 @@ def build_set(svc, base_path, out_dir, name, cfg, status, log):
|
||||
for c in clips(t):
|
||||
place(c, span(c)[0] + off, song["bpm"])
|
||||
if c.tag == "AudioClip":
|
||||
if song.get("semis"):
|
||||
if song.get("semis") and pitched(tname(t), cfg.get("unpitched") or UNPITCHED):
|
||||
transpose(c, song["semis"], song["bpm"])
|
||||
files.adopt(c, song["proj"], song["folder"])
|
||||
if t.tag == "AudioTrack":
|
||||
@@ -1237,6 +1253,220 @@ def check(root):
|
||||
return out
|
||||
|
||||
|
||||
# --- 7: in Live, consolidating what was transposed ---------------------------------------------------
|
||||
|
||||
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."""
|
||||
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)))
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def live_set():
|
||||
"""The set open in Live: the last one its log says it loaded."""
|
||||
import glob
|
||||
logs = sorted(glob.glob(os.path.expanduser("~/Library/Preferences/Ableton/Live*/Log.txt")), key=os.path.getmtime)
|
||||
path = None
|
||||
if logs:
|
||||
with open(logs[-1], "rb") as f:
|
||||
text = f.read().decode("utf-8", "replace")
|
||||
for m in re.finditer(r'info: Loading document "(.*)"', text):
|
||||
if "/Templates/" not in m.group(1):
|
||||
path = m.group(1)
|
||||
return path
|
||||
|
||||
|
||||
def osc_pad(b):
|
||||
return b + b"\0" * (4 - len(b) % 4)
|
||||
|
||||
|
||||
def osc_msg(addr, *args):
|
||||
tags, data = ",", b""
|
||||
for a in args:
|
||||
if isinstance(a, int):
|
||||
tags, data = tags + "i", data + struct.pack(">i", a)
|
||||
elif isinstance(a, float):
|
||||
tags, data = tags + "f", data + struct.pack(">f", a)
|
||||
else:
|
||||
tags, data = tags + "s", data + osc_pad(str(a).encode())
|
||||
return osc_pad(addr.encode()) + osc_pad(tags.encode()) + data
|
||||
|
||||
|
||||
def osc_parse(b):
|
||||
def string(i):
|
||||
j = b.index(b"\0", i)
|
||||
return b[i:j].decode("utf-8", "replace"), (j // 4 + 1) * 4
|
||||
addr, i = string(0)
|
||||
tags, i = string(i)
|
||||
out = []
|
||||
for t in tags[1:]:
|
||||
if t == "i":
|
||||
out.append(struct.unpack(">i", b[i:i + 4])[0]); i += 4
|
||||
elif t == "f":
|
||||
out.append(struct.unpack(">f", b[i:i + 4])[0]); i += 4
|
||||
elif t == "s":
|
||||
x, i = string(i); out.append(x)
|
||||
elif t in "TF":
|
||||
out.append(t == "T")
|
||||
return addr, out
|
||||
|
||||
|
||||
class Live:
|
||||
"""Live on this Mac: AbletonOSC (with the status panel's commands, tools/abletonosc) and its menus
|
||||
(System Events: osascript has Accessibility on the Tracks Mac)."""
|
||||
|
||||
def __init__(self):
|
||||
import socket
|
||||
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
self.s.bind(("127.0.0.1", 11001))
|
||||
except OSError:
|
||||
raise RuntimeError("OSC PORT BUSY")
|
||||
self.s.settimeout(0.3)
|
||||
|
||||
def close(self):
|
||||
self.s.close()
|
||||
|
||||
def ask(self, addr, *args, wait=5.0):
|
||||
import socket
|
||||
self.s.sendto(osc_msg(addr, *args), ("127.0.0.1", 11000))
|
||||
end = time.time() + wait
|
||||
while time.time() < end:
|
||||
try:
|
||||
b, _ = self.s.recvfrom(65536)
|
||||
except socket.timeout:
|
||||
continue
|
||||
a, out = osc_parse(b)
|
||||
if a == addr:
|
||||
return out
|
||||
return None
|
||||
|
||||
def clip(self, i, name, beat):
|
||||
"""(found, pitch, warping, file) of the arrangement clip at `beat` on the track."""
|
||||
r = self.ask("/live/statuspanel/clip", i, name, float(beat))
|
||||
return tuple(r[3:7]) if r and len(r) >= 7 else None
|
||||
|
||||
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'
|
||||
'tell application "System Events" to tell process "Live"\n'
|
||||
'set frontmost to true\n'
|
||||
'set m to menu item "%s" of menu "%s" of menu bar 1\n'
|
||||
'if not (enabled of m) then return "off"\n'
|
||||
'click m\nreturn "on"\nend tell\nend timeout' % (item, menu)],
|
||||
capture_output=True, text=True, timeout=40)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError("LIVE MENU: " + (r.stderr.strip()[-40:] or "FAILED").upper())
|
||||
return r.stdout.strip() == "on"
|
||||
|
||||
|
||||
def live_app():
|
||||
"""Live's app bundle (the running one, else the newest in /Applications)."""
|
||||
r = subprocess.run(["/bin/ps", "-axo", "command"], capture_output=True, text=True)
|
||||
m = re.search(r"^(/.*?\.app)/Contents/MacOS/Live$", r.stdout, re.M)
|
||||
if m:
|
||||
return m.group(1)
|
||||
apps = sorted(n for n in os.listdir("/Applications") if n.startswith("Ableton Live") and n.endswith(".app"))
|
||||
return os.path.join("/Applications", apps[-1]) if apps else None
|
||||
|
||||
|
||||
def open_in_live(live, path, todo, wait=180):
|
||||
"""Open the set in Live (not while it plays); True once it is loaded."""
|
||||
if live_set() == path:
|
||||
return True
|
||||
playing = live.ask("/live/song/get/is_playing", wait=3)
|
||||
if playing and playing[0]:
|
||||
raise RuntimeError("LIVE IS PLAYING")
|
||||
app = live_app()
|
||||
if not app:
|
||||
raise RuntimeError("NO LIVE ON THIS MAC")
|
||||
subprocess.run(["/usr/bin/open", "-a", app, path], capture_output=True, timeout=30)
|
||||
end = time.time() + wait
|
||||
while time.time() < end:
|
||||
time.sleep(2)
|
||||
if live_set() == path:
|
||||
i, name, beat, _ = todo[0]
|
||||
got = live.clip(i, name, beat)
|
||||
if got and got[0]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def save_live(path, live):
|
||||
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)
|
||||
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."""
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
todo = pending(load(path), cfg.get("unpitched") or UNPITCHED)
|
||||
if not todo:
|
||||
return {"name": name, "consolidated": 0}
|
||||
own = live is None
|
||||
live = live or Live()
|
||||
status.write(live=time.time()) # the panel hands AbletonOSC back to Companion after
|
||||
try:
|
||||
if live.ask("/live/song/get/tempo", wait=3) is None:
|
||||
raise RuntimeError("LIVE NOT ANSWERING")
|
||||
status.write(step="OPENING %s IN LIVE" % name.upper()[:14])
|
||||
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)}
|
||||
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]:
|
||||
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
|
||||
continue
|
||||
live.ask("/live/statuspanel/select_clip", i, tn, float(beat))
|
||||
time.sleep(0.3)
|
||||
if not live.menu("Consolidate"):
|
||||
missed.append(stem_name(tn))
|
||||
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:
|
||||
missed.append(stem_name(tn))
|
||||
if done or reset:
|
||||
status.write(step="SAVING %s" % name.upper()[:14])
|
||||
if not save_live(path, live):
|
||||
log.append("%s: LIVE DID NOT SAVE, SAVE IT IN LIVE" % name.upper())
|
||||
if reset:
|
||||
log.append("%s: %d UNPITCHED CLIPS PUT BACK IN THEIR KEY" % (name.upper(), reset))
|
||||
for m in missed:
|
||||
log.append("%s: %s NOT CONSOLIDATED" % (name.upper(), m))
|
||||
return {"name": name, "consolidated": done, "reset": reset, "left": len(missed)}
|
||||
finally:
|
||||
status.write(live=time.time())
|
||||
if own:
|
||||
live.close()
|
||||
|
||||
|
||||
# --- the job ----------------------------------------------------------------------------------
|
||||
|
||||
def projects_dir(cfg):
|
||||
@@ -1283,12 +1513,9 @@ def run(cfg):
|
||||
status.data["results"].append({"name": name, "skipped": "THERE ALREADY"})
|
||||
continue
|
||||
todo.append(svc)
|
||||
if not todo:
|
||||
status.write(state="done", step="NOTHING TO DO")
|
||||
return
|
||||
# 1. Last weekend's songs to Finished Songs (from the newest weekend set before the first new one).
|
||||
first = min(s["date"] for s in todo)
|
||||
prev = newest_set(site_dir, first, weekend_pattern(cfg))
|
||||
first = min(s["date"] for s in todo) if todo else None
|
||||
prev = newest_set(site_dir, first, weekend_pattern(cfg)) if todo else None
|
||||
if prev and cfg.get("save_songs", True):
|
||||
status.write(step="SAVING SONGS")
|
||||
saved = export_songs(prev, cfg, status, log)
|
||||
@@ -1307,7 +1534,29 @@ def run(cfg):
|
||||
log.extend(res["notes"])
|
||||
status.data["results"].append(res)
|
||||
status.write()
|
||||
status.write(state="done", step="DONE")
|
||||
# 7. In Live: the transposed clips consolidated, warp off (the nearest service's set is left open).
|
||||
live = None
|
||||
worked = bool(todo)
|
||||
try:
|
||||
for svc in sorted(cfg["services"], key=lambda s: s["date"], reverse=True) if cfg.get("consolidate", True) else []:
|
||||
folder, name = target(cfg, svc)
|
||||
d = os.path.join(folder, name)
|
||||
als = [os.path.join(d, x) for x in os.listdir(d) if x.endswith(".als") and not x.startswith(".")] if os.path.isdir(d) else []
|
||||
if not als or not pending(load(max(als, key=os.path.getmtime)), cfg.get("unpitched") or UNPITCHED):
|
||||
continue
|
||||
worked = True
|
||||
try:
|
||||
live = live or Live()
|
||||
res = consolidate(max(als, key=os.path.getmtime), cfg, status, log, live)
|
||||
except RuntimeError as e:
|
||||
log.append("%s: CONSOLIDATE IN LIVE: %s" % (name.upper(), e))
|
||||
continue
|
||||
status.data["results"].append(res)
|
||||
status.write()
|
||||
finally:
|
||||
if live:
|
||||
live.close()
|
||||
status.write(state="done", step="DONE" if worked else "NOTHING TO DO")
|
||||
except Exception as e: # noqa: BLE001 - the key shows what stopped it
|
||||
import traceback
|
||||
status.write(state="failed", step=str(e)[:60].upper(), trace=traceback.format_exc()[-2000:])
|
||||
|
||||
Reference in New Issue
Block a user